Compare commits

..

7 Commits

Author SHA1 Message Date
ttipo ff1948b7df docs: 휴대폰 앱 사내 배포를 계약 · 운영 · 앱 · 디자인 문서와 스킬에 적는다
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m41s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m13s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m13s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m13s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m5s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 21m49s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m13s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m54s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m47s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 10m26s
계약 1절 "휴대폰 앱 받기"와 6절 운영 도구 명령, 서버 B 에 APK 올리는 차례와 직원 안내(링크 없이),
앱의 서명 키 · release 빌드 · 올리기 전 확인, 웹 화면 절, 스킬 표, App1Shell 보드의 앱 정보 줄.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:06 +09:00
ttipo 7c70862647 feat(docker): 휴대폰 앱 받기 경로에 앱 안 받기와 같은 상한 · 흘려보내기를 둔다
APK 받기가 일반 /api 로 가면 X-Accel-Buffering 이 외부 프록시로 넘어가지 않아 받는 사람마다 임시 파일이 쌓이고,
버퍼링을 끈 받기에 둘 동시 연결 상한도 없다(리뷰). 앱 안 대용량 받기와 같은 location(대소문자를 가리지 않는 정규식 ·
같은 그릇)을 두고, CI 의 받기 경로 시험에 변형 · 정보 경로 · 그릇 공유를 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:05 +09:00
ttipo e9acb7809c feat(flutter): [새 버전 받기]를 포털 받기 화면으로 바꾸고 release 는 우리 키 · 운영 주소로만 만든다
스토어에 올리지 않으니 업데이트 화면의 Play 스토어 열기를 포털 /app 을 바깥 브라우저로 여는 것으로 바꾸고,
설정 › 앱 정보에도 같은 줄을 더했다. release 빌드는 우리 서명 키(key.properties) · google-services.json ·
운영 포털의 https API 주소가 갖춰지지 않으면 까닭을 알리고 멈춘다 — debug 키로 조용히 서명되거나
개발 주소가 든 판이 직원에게 나가지 않게(알리는 글에 주소 값은 싣지 않는다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:05 +09:00
ttipo 433ef12cc3 feat(frontend): 설정 › 휴대폰 앱과 휴대폰 받기 화면 /app 을 더한다
확정 보드 SettingsMobileApp 대로 PC 에서는 QR 로 휴대폰에 넘기고, 셸 밖 /app(로그인 필요)에서 휴대폰으로 받는다.
내려받기 전에 세션을 확인하고, 올린 판이 없으면(404 RES_001) 알림 없이 안내와 문의 줄을 보인다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:04 +09:00
ttipo f2ba81513e feat(backend): 휴대폰 앱 받기 API 와 운영 도구 올리기 · 목록 명령을 더한다
앱을 스토어 없이 사내에만 나눠 주기로 해(2026-11-08), 로그인한 직원만 최신 안드로이드 APK 의
정보와 파일을 받게 한다(GET /api/app-releases/android/latest · /latest/file — 제한된 세션은 막힌다).
올리기는 API 없이 서버의 운영 도구로 한다(파일 검사 · SHA-256 · 임시 이름에 쓴 뒤 바꾸기 · 실행 기록).
최신은 가장 늦게 올린 판이라 옛 APK 를 다시 올려 되돌릴 수 있고, app-config 의 최소 판보다 낮은 판은 올리지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:03 +09:00
ttipo 1f8555340e refactor(backend): 내려받기 머리글을 download-headers 한 곳으로 모은다
휴대폰 앱 받기(다음 커밋)가 대용량 첨부 받기와 같은 머리글을 쓰게 하려고,
file-sharing 의 writeFileHeaders 가 만들던 머리글을 shared/utils/download-headers.ts 로 옮겼다.
머리글 이름 · 값 · 차례는 그대로다(새 단위 시험과 대용량 받기의 단위 · e2e 가 지킨다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:02 +09:00
ttipo 178a53fd6f docs: 휴대폰 앱 받기 보드를 더하고 앱 업데이트 화면을 사내 배포에 맞춘다
앱을 스토어에 올리지 않고 직접 빌드한 APK 를 사내에만 나눠 주기로 했다(2026-11-08).
포털에 로그인한 직원만 받는 화면(설정 › 계정 › 휴대폰 앱, 셸 밖 /app)을 새 보드로 두고,
앱 업데이트 화면의 [Play 스토어 열기]를 [새 버전 받기](받기 화면을 브라우저로 연다)로,
설정 › 앱 정보에는 「새 버전 받기」 줄을 더했다. 캔버스 v68 에서 확정했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:02:36 +09:00
85 changed files with 6841 additions and 256 deletions
+12 -4
View File
@@ -85,7 +85,7 @@ sudo docker compose --env-file .env.production logs -f backend
이 경로만 오류 로그를 `error` 부터 남긴다: 본문을 임시 파일에 받는다는 warn 이 조각마다 남았다),
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` · 대용량 조각 · 대용량 받기 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` · 대용량 조각 · 대용량 받기 · 휴대폰 앱 받기 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
@@ -106,13 +106,19 @@ sudo docker compose --env-file .env.production logs -f backend
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
버퍼링을 끈 받기에서 `proxy_read_timeout` 은 돌지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 `send_timeout`(기본 60s)으로 끊는다 — 느려도 조금씩 받으면 이어진다(2026-10-30 확인).
- **동시 연결 상한**(`limit_conn` — 받기가 연결 둘과 백엔드의 파일을 잡으므로): 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 서버 전체는 경로마다 따로 공개 400 · 앱 안 200
(함께 세면 바깥 IP 몇십 개로 직원의 앱 안 받기까지 막힌다 — 2026-10-30 리뷰).
휴대폰 앱 받기는 앱 안 받기의 그릇(`download_app_ip` 30 · `download_app_total` 200)을 함께 쓴다 — 둘 다 로그인한 직원의 받기라 막으려는 것이 같고, 그릇(받기 600 개 — `worker_connections` 셈)을 늘리지 않으려고.
판이 나온 날 사무실 휴대폰이 한꺼번에 받다 대용량 받기와 합쳐 주소마다 30 에 닿으면 넘은 받기만 429 다(잠시 뒤 다시 — 처리 중인 요청만 센다).
넘으면 429 `REQ_001`(JSON). 클라이언트 주소는 `X-Forwarded-For` 의 **마지막 칸**(외부 프록시가 이어 붙인 값 — 없으면 접속 주소)이다 — 앞 칸을 꾸며도 바뀌지 않는다.
8080 에 프록시를 건너뛰고 바로 붙는 길이 있으면 주소마다 한도는 피한다(경로마다 서버 전체 한도는 그대로) — 그 길을 막는 것이 ACG · proxy 네트워크다. IPv6 는 /64 로 묶지 않는다.
⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`) —
⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`,
휴대폰 앱 `~* ^/api/app-releases/.+/file` — 끝을 묶지 않는다: 끝의 `/` 같은 꼴도 상한을 타게) —
백엔드 라우팅이 대소문자를 가리지 않던 때 `/FILES/` · `/api/Messages/…` 처럼 글자 하나로 상한과 `X-Accel-Buffering` 넘김을 비켜 갔다(2026-10-30 리뷰).
같은 블록의 prefix 에 `^~` 가 있으면 그 아래 경로는 블록의 정규식을 건너뛰므로 `/api/messages` 는 `^~` 없이 둔다. id · 토큰 칸은 `.+`(nginx 는 `%2F` 를 풀어 location 을 고른다).
포털이 닫는 공개 API 도 `~* ^/api/public-links(?:/|$)` 다. 백엔드도 라우팅이 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
@@ -216,7 +222,8 @@ sudo docker compose --env-file .env.production logs -f backend
긴 한글 이름 파일의 받기 응답 머리글(3.5KB)이 4k 를 넘으면 그 받기만 502 다.
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
@@ -333,7 +340,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
`docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
11단계부터는 nginx 를 두 꼴(공개 호스트를 가를 때 · 아닐 때)로 `nginx -t` 하고 렌더링 결과(server 이름 · 포털이 닫는지 · 받기 영역 400 · 200)와 틀린 `SHARE_PUBLIC_URL` 의 기동 거부를 본다.
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다.
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다
(휴대폰 앱 받기 `…/file` 도 — 앱 안 받기와 그릇을 함께 쓰는지 · 공개 호스트는 404 인지 · 정보 경로 `…/latest` 는 타지 않는지. "nginx 설정 검사"는 `limit_conn download_app_*` 가 두 꼴 모두 둘씩인지 센다).
"닫는 경로 시험"(12단계)은 같은 가짜 백엔드로 두 server × 닫는 경로 셋(`/api/internal` · `/api/health` · 공개 API)의 변형이 nginx 가 직접 낸 JSON 인지 본다 —
대소문자 · 겹 `/` · `%2F` · `%69` · `%5C` · `..` · absolute-form · 끝 `/` 는 404, 날 `\` · `#` · 0xA0 는 400, 쿼리의 `\` 는 통과, `/api/internals` 같은 이웃 경로는 백엔드로 넘어간다
(경계 확인은 wget — busybox `nc` 는 표준 입력이 끝나면 닫아 프록시를 거친 응답을 받지 못한다). 정적 검사는 include 와 `if ($request_target_rejected)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
+5 -4
View File
@@ -13,7 +13,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
## 템플릿 기본 제공 골격
> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 스토어에 올린 뒤에는 못 바꾼다), 패키지 `arasystem_app`.
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`.
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
@@ -39,8 +39,8 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · `AppStoreLauncher` · 글) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). [Play 스토어 열기]는 `market://details` → 웹 주소, 패키지 이름은 `appPackageNameProvider`(main 이 기기 정보로) |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보 · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 |
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
@@ -80,6 +80,7 @@ lib/
│ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판
│ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes)
│ ├── app_config/ ← 앱 설정(최소 판 · 알림을 보낼 수 있는가)
│ ├── app_download/ ← [새 버전 받기] — 포털의 휴대폰 앱 받기 화면 주소 · 런처 · 글 (업데이트 화면 · 설정이 함께 쓴다)
│ ├── push/ ← 새 메일 알림(FCM · 채널 · 서버 등록 맞추기) · 알림 → 경로
│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry)
│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시
@@ -292,7 +293,7 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
- [ ] **모듈**: 새 기능은 그 모듈 폴더(`features/<모듈>/`)와 라우트 묶음 안에. 모듈끼리 import 하지 않는다 — 함께 쓰는 것은 `core/` · `shared/`. 모듈 목록을 바꾸면 웹 `app-modules.ts` 도(짝).
- [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상.
- [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`).
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다).
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판(release)은 우리 서명 키(`android/key.properties` — 저장소 밖 keystore)와 `google-services.json` 이 있고 `flutter/.env` 의 `API_URL` 이 운영 포털의 https 주소일 때만 만든다 — 하나라도 아니면 Gradle 이 까닭을 알리고 멈춘다(debug 키로 조용히 서명하지 않고, 개발 주소가 든 판이 나가지 않게 — 알리는 글에 주소 값은 싣지 않는다, `flutter/README.md` "사내 배포"). 만들면 release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다는 것도 본다.
Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고,
Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다).
Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다.
File diff suppressed because one or more lines are too long
+1
View File
@@ -69,6 +69,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
| 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `<rect>` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다. **로그인된 기기**(2026-11-06 — 앱 세션이 함께 보인다): 웹 · 앱은 세션의 `client` · 로그인 기록의 `method` 로만 가른다(User-Agent 이름으로 짐작하지 않는다 — `shared/utils/account-security.ts` 의 `sessionCounts` · `isMobileSession`), 앱 이름은 `MOBILE_APP_NAME`(짝: backend `APP_BROWSER_NAME`) — 계약 5절, 디자인 README "12단계" |
| 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" |
| 휴대폰 앱 받기 (2026-11-08) | 설정 › 계정 › 휴대폰 앱 `src/pages/settings/AccountMobileAppPage.vue` · 앱 틀 밖 받기 화면 `pages/mobile-app/MobileAppPage.vue`(`/app` — 인쇄 화면처럼 `meta.requiresAuth` 만) · `components/mobile-app/`(`MobileAppDownloadLink` · `MobileAppIcon` · `MobileAppSteps` · `MobileAppIphoneNote`) · `stores/app-release.store.ts` · API `composables/useAppReleases.ts` · `shared/utils/app-release.ts` · `shared/types/app-release.ts` · 글 `shared/constants/mobile-app.ts`(`MOBILE_APP_PATH` · `MOBILE_APP_ROUTE` — 글은 이 상수가 원본) | 안드로이드 APK 를 로그인한 직원만 받는다(스토어 없음). 판 정보 `GET /api/app-releases/android/latest` 는 들어올 때마다 `silent` 로 받는다 — 404 `RES_001` 은 "받을 앱 없음"(알림 없음 · 안내와 문의 줄만), 그 밖의 실패는 칸 자리에 `MailLoadError`(사전 문구 + [다시 시도]), 세션 세대(`authStore.generation`)가 바뀌면 늦은 응답을 버리고 값을 지운다. [앱 내려받기]는 첨부처럼 `<a href download>` + 이동 직전 `confirmSession()`(`…/latest/file`). QR(`QrCodeImage`)은 `location.origin + '/app'` 만 담는다(비밀 아님) — `/app` 은 앱의 [새 버전 받기]도 여는 약속이라 경로를 바꾸면 앱도 함께. 문의 줄은 `LoginSupportLine`(`/app` 은 `size="base"` 13). 올린 날은 브라우저 시간대(`formatLongDate`). 설정은 좁은 폭(480 이하)에서 QR 줄을 빼고 [앱 내려받기]를 전체 폭으로, `/app` 은 휴대폰 폭이 먼저 · 480 초과는 띠 전체 폭 + 가운데 칸 480 · [앱 내려받기] 48(밀도 예외 아님). 글자 17 토큰 `--font-size-title-md` — 디자인 README "휴대폰 앱 받기" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
+26 -3
View File
@@ -236,6 +236,11 @@ jobs:
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
# 앱 안 받기 · 휴대폰 앱 받기(2026-11-08)가 같은 그릇(download_app_*)을 쓴다 — 포털의 두 location 에 하나씩, 두 꼴 모두 둘이다
for want in 'download_app_ip 30;' 'download_app_total 200;'; do
n=$(printf '%s\n' "$conf" | awk -v want="$want" '$1 == "limit_conn" && ($2 " " $3) == want { c++ } END { print c + 0 }')
[ "$n" -eq 2 ] || { echo "::error::limit_conn $want 이 든 location 이 둘(앱 안 받기 · 휴대폰 앱 받기)이 아닙니다 ($form: $n)"; exit 1; }
done
done
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소, 출처가 아닌 공개 내려받기 주소)
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
@@ -253,7 +258,9 @@ jobs:
# 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 ·
# X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말).
# 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로,
# ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다
# ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다.
# 휴대폰 앱(APK) 받기(/api/app-releases/…/file — 2026-11-08)도 본다: 같은 변형이 받기 location 을 타고 앱 안 받기와 그릇(주소마다 30)을 함께 쓰는지,
# 공개 호스트는 404 로 닫는지, 정보 경로(…/latest — JSON)는 받기 location 밖인지
- name: 대용량 받기 경로 시험
run: |
base="ci-nginx-config:${{ github.run_id }}"
@@ -290,26 +297,42 @@ jobs:
release() { killall wget >/dev/null 2>&1; sleep 2; }
PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F"
INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F"
# 휴대폰 앱 받기 — 끝의 / · %66(f) 꼴도. 정보 경로는 받기 location 밖이다
APK="/api/app-releases/android/latest/file /api/App-Releases/Android/Latest/FILE /api/APP-RELEASES/android/latest/File /api//app-releases/android/latest/file /api/app-releases/android%2Flatest/file /api/app-releases/android/latest/file/ /api/app-releases/android/latest/%66ile"
INFO="/api/app-releases/android/latest /api/App-Releases/Android/Latest /api/app-releases/android/latest/"
echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도"
for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done
for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done
echo "== 휴대폰 앱 받기도 받기 location 을 탄다 · 공개 호스트는 닫는다 · 정보 경로는 /api 그대로"
for p in $APK; do expect "200 xab" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
for p in $INFO; do expect "200 -" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
expect "200 -" files.ci.test "/api/Public-Links/$T"
expect "200 -" portal.ci.test "/api/Messages/M1"
expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1"
expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1"
expect "200 xab" portal.ci.test "/api/App-Releases/android/latest/file?pad=1"
echo "== nginx 가 끝내는 /api 응답은 JSON"
expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me
expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000"
expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F"
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30. 붙잡아 둔 뒤 변형으로 하나 더"
expect_raw "404 application/json RES_001" files.ci.test GET /api/app-releases/android/latest/file
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30(휴대폰 앱 받기와 함께). 붙잡아 둔 뒤 변형으로 하나 더"
hold files.ci.test "/api/public-links/$T/files/$F" 10
for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done
expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F"
expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F"
expect "200 xab" portal.ci.test "/api/app-releases/android/latest/file"
release
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
for p in $INAPP; do expect "429 -" portal.ci.test "$p"; done
for p in $INAPP $APK; do expect "429 -" portal.ci.test "$p"; done
expect_raw "429 application/json REQ_001" portal.ci.test GET "/api/App-Releases/android/latest/FILE"
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
release
hold portal.ci.test "/api/app-releases/android/latest/file" 30
for p in $APK $INAPP; do expect "429 -" portal.ci.test "$p"; done
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
release
echo "fails=$fails"
+3
View File
@@ -10,6 +10,7 @@ import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guar
import { OriginGuard } from '@/common/guards/origin.guard';
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
import { AppConfigModule } from '@/modules/app-config/app-config.module';
import { AppReleasesModule } from '@/modules/app-releases/app-releases.module';
import { AuthModule } from '@/modules/auth/auth.module';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
@@ -86,6 +87,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
PushModule,
// 앱 설정 (앱 1단계) — GET /api/app-config (로그인 없음 — 최소 판 · 알림을 보낼 수 있는지)
AppConfigModule,
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
AppReleasesModule,
UploadModule,
UserModule,
],
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 휴대폰 앱 판 표 (docs/mail/api-contract.md 1절 "휴대폰 앱 받기" — 사내 배포, 2026-11-08).
* - app_releases: 운영 도구 「휴대폰 앱 올리기」가 올린 판마다 한 줄 — 플랫폼 · 판 · 크기 · SHA-256 · 디스크 이름(file_key UUID) · 올린 이(운영자 이름) · 올린 때.
* file_key 유일 인덱스(디스크 이름은 한 줄에만), (platform, created_at) 인덱스(최신 = 그 플랫폼에서 가장 늦게 올린 줄)
* 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다.
* (엔티티: modules/app-releases/app-release.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다)
*/
export class AddAppReleases1794140434961 implements MigrationInterface {
name = 'AddAppReleases1794140434961';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "app_releases" ("id" uuid NOT NULL, "platform" character varying(16) NOT NULL, "version" character varying(16) NOT NULL, "size_bytes" bigint NOT NULL, "sha256" character(64) NOT NULL, "file_key" uuid NOT NULL, "uploaded_by" character varying(100) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e5b28b6fe128172639b75251cd1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_app_releases_file_key" ON "app_releases" ("file_key") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_app_releases_platform_created_at" ON "app_releases" ("platform", "created_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_app_releases_platform_created_at"`,
);
await queryRunner.query(`DROP INDEX "public"."UQ_app_releases_file_key"`);
await queryRunner.query(`DROP TABLE "app_releases"`);
}
}
@@ -0,0 +1,223 @@
import { execFileSync } from 'child_process';
import { createHash, randomBytes } from 'crypto';
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
truncateSync,
writeFileSync,
} from 'fs';
import { open } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { forEachChunk, inspectApk, type ApkInspection } from './apk-file';
import {
APK_MAX_BYTES,
APK_MIN_BYTES,
APK_ZIP_SIGNATURE,
} from './app-releases.constants';
/** zip 머리 + 무작위 내용 */
function apkBytes(size: number): Buffer {
return Buffer.concat([
APK_ZIP_SIGNATURE,
randomBytes(size - APK_ZIP_SIGNATURE.length),
]);
}
const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');
/** 검사가 막은 까닭 (통과했으면 연 파일을 닫고 'ok') */
async function problemOf(inspection: ApkInspection): Promise<string> {
if (inspection.ok) {
await inspection.apk.close();
return 'ok';
}
return inspection.problem;
}
/** 이 시험을 root 로 돌리는가 — root 는 권한과 상관없이 읽는다 */
const runningAsRoot = process.getuid?.() === 0;
const onWindows = process.platform === 'win32';
describe('inspectApk — 운영자가 넣은 APK 검사 (휴대폰 앱 올리기)', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'apk-file-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
const file = (name: string, data: Buffer) => {
const path = join(dir, name);
writeFileSync(path, data);
return path;
};
it('통과하면 크기 · SHA-256 · 파일 이름과 연 파일을 준다 — 연 파일로 다시 읽을 수 있다', async () => {
const data = apkBytes(64 * 1024);
const path = file('ARA-SYSTEM-1.0.0.apk', data);
const inspection = await inspectApk(path);
expect(inspection.ok).toBe(true);
if (!inspection.ok) return;
const { apk } = inspection;
try {
expect(apk).toMatchObject({
path,
fileName: 'ARA-SYSTEM-1.0.0.apk',
sizeBytes: data.length,
sha256: sha256(data),
});
const again = Buffer.alloc(data.length);
await apk.handle.read(again, 0, again.length, 0);
expect(again.equals(data)).toBe(true);
} finally {
await apk.close();
}
});
it('이름은 .apk 로 끝나야 한다 (대소문자는 가리지 않는다) — 이름이 틀리면 열지도 않는다', async () => {
const data = apkBytes(APK_MIN_BYTES);
await expect(
problemOf(await inspectApk(file('ARA-SYSTEM-1.0.0.APK', data))),
).resolves.toBe('ok');
for (const name of ['ARA-SYSTEM-1.0.0.zip', 'ARA-SYSTEM-1.0.0', 'apk']) {
await expect(problemOf(await inspectApk(file(name, data)))).resolves.toBe(
'not-apk-name',
);
}
await expect(
problemOf(await inspectApk(join(dir, 'none.zip'))),
).resolves.toBe('not-apk-name');
});
it('없는 경로 · 폴더 · 파일이 아닌 것은 알린다', async () => {
await expect(
problemOf(await inspectApk(join(dir, 'none.apk'))),
).resolves.toBe('missing');
await expect(
problemOf(await inspectApk(join(dir, 'no-dir', 'x.apk'))),
).resolves.toBe('missing');
mkdirSync(join(dir, 'folder.apk'));
await expect(
problemOf(await inspectApk(join(dir, 'folder.apk'))),
).resolves.toBe('not-file');
});
(onWindows ? it.skip : it)(
'바로가기(심볼릭 링크)는 따라가지 않는다',
async () => {
const target = file('real.apk', apkBytes(APK_MIN_BYTES));
symlinkSync(target, join(dir, 'link.apk'));
await expect(
problemOf(await inspectApk(join(dir, 'link.apk'))),
).resolves.toBe('symlink');
},
);
(onWindows ? it.skip : it)(
'파이프(FIFO)를 열 때 멈추지 않는다 — 일반 파일이 아니라고 알린다',
async () => {
const path = join(dir, 'pipe.apk');
try {
execFileSync('mkfifo', [path]);
} catch {
// mkfifo 가 없는 환경 — 이 시험은 건너뛴다 (개발 컨테이너 · CI 에는 있다)
return;
}
await expect(problemOf(await inspectApk(path))).resolves.toBe('not-file');
},
);
(onWindows || runningAsRoot ? it.skip : it)(
'읽을 권한이 없으면 알린다 (docker cp 는 root 소유로 넣는다)',
async () => {
const path = file('locked.apk', apkBytes(APK_MIN_BYTES));
chmodSync(path, 0o000);
await expect(problemOf(await inspectApk(path))).resolves.toBe(
'unreadable',
);
},
);
it('크기 — 1KB 아래 · 200MB 위는 받지 않는다(큰 파일은 읽지 않고 크기로만), 경계는 받는다', async () => {
await expect(
problemOf(
await inspectApk(file('small.apk', apkBytes(APK_MIN_BYTES - 1))),
),
).resolves.toBe('too-small');
await expect(
problemOf(await inspectApk(file('edge.apk', apkBytes(APK_MIN_BYTES)))),
).resolves.toBe('ok');
// 빈 곳만 있는 큰 파일(디스크를 쓰지 않는다) — 머리를 읽기 전에 크기로 막는다
const large = join(dir, 'large.apk');
writeFileSync(large, APK_ZIP_SIGNATURE);
truncateSync(large, APK_MAX_BYTES + 1);
await expect(problemOf(await inspectApk(large))).resolves.toBe('too-large');
});
it('zip 머리(PK\\x03\\x04)가 아니면 APK 가 아니다', async () => {
const notZip = Buffer.concat([
Buffer.from('<html>'),
randomBytes(APK_MIN_BYTES),
]);
await expect(
problemOf(await inspectApk(file('page.apk', notZip))),
).resolves.toBe('not-zip');
// 빈 디렉터리 머리(PK\x05\x06 — 빈 zip)도 APK 가 아니다
const emptyZip = Buffer.concat([
Buffer.from([0x50, 0x4b, 0x05, 0x06]),
Buffer.alloc(APK_MIN_BYTES),
]);
await expect(
problemOf(await inspectApk(file('empty.apk', emptyZip))),
).resolves.toBe('not-zip');
});
});
describe('forEachChunk — 연 파일을 처음부터 끝까지', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'apk-chunks-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('조각을 차례로 넘기고 읽은 크기가 맞으면 true — 다르면(그사이 바뀜) false', async () => {
const data = randomBytes(3 * 1024 * 1024 + 17);
const path = join(dir, 'data.bin');
writeFileSync(path, data);
const handle = await open(path, 'r');
try {
const parts: Buffer[] = [];
await expect(
forEachChunk(handle, data.length, (chunk) => {
parts.push(Buffer.from(chunk));
}),
).resolves.toBe(true);
expect(Buffer.concat(parts).equals(data)).toBe(true);
expect(parts.length).toBeGreaterThan(1);
// 생각한 것보다 짧다(줄었다) · 길다(늘었다) — 넘게 읽히면 거기서 멈춘다(끝까지 읽지 않는다)
await expect(
forEachChunk(handle, data.length + 1, () => undefined),
).resolves.toBe(false);
let seen = 0;
await expect(
forEachChunk(handle, 10, (chunk) => {
seen += chunk.length;
}),
).resolves.toBe(false);
expect(seen).toBeLessThanOrEqual(10);
} finally {
await handle.close();
}
});
});
@@ -0,0 +1,172 @@
import { createHash } from 'crypto';
import { constants } from 'fs';
import { open, type FileHandle } from 'fs/promises';
import { basename, extname } from 'path';
import {
APK_EXTENSION,
APK_MAX_BYTES,
APK_MIN_BYTES,
APK_ZIP_SIGNATURE,
} from './app-releases.constants';
/**
* 운영자가 컨테이너 안에 넣은 APK 읽기 (운영 도구 「휴대폰 앱 올리기」 — 계약 1절 "휴대폰 앱 받기").
* 파일 하나를 한 번 열고, 그 열린 파일(같은 inode)로 검사 · SHA-256 · 저장소에 들이기를 모두 한다 —
* 검사한 뒤 경로가 다른 파일로 바뀌어도 들이는 것은 검사한 그 파일이다(내용이 바뀌면 들일 때 다시 센 SHA-256 이 막는다)
*/
/** 읽을 수 없는 까닭 — 운영 도구가 한국어로 알린다 (modules/ops/app-release-commands.ts) */
export type ApkProblem =
/** 이름이 .apk 로 끝나지 않는다 */
| 'not-apk-name'
/** 그 경로에 없다 */
| 'missing'
/** 바로가기(심볼릭 링크)다 — 따라가지 않는다 */
| 'symlink'
/** 일반 파일이 아니다 (폴더 · 장치 · 파이프 · 소켓) */
| 'not-file'
/** 읽을 권한이 없다 (docker cp 는 root 소유로 넣는다 — 원본이 644 가 아니면 node 가 읽지 못한다) */
| 'unreadable'
| 'too-small'
| 'too-large'
/** zip 머리(PK\x03\x04)가 아니다 */
| 'not-zip'
/** 읽는 동안 크기가 바뀌었다 (아직 복사 중) */
| 'changed';
/** 검사를 마친 APK — 다 쓰면 close() (부르는 쪽이 닫는다) */
export interface InspectedApk {
/** 운영자가 넣은 경로 그대로 — 실행 기록 · 바뀔 것에 보인다(감사용) */
readonly path: string;
/** 경로의 마지막 이름 — 판을 짐작하는 데만 쓴다(디스크 · 내려받는 이름이 되지 않는다) */
readonly fileName: string;
readonly sizeBytes: number;
/** SHA-256 (소문자 16진수) */
readonly sha256: string;
/** 연 파일 — 저장소에 들일 때 처음부터 다시 읽는다 */
readonly handle: FileHandle;
close(): Promise<void>;
}
export type ApkInspection =
| { readonly ok: true; readonly apk: InspectedApk }
| { readonly ok: false; readonly problem: ApkProblem };
/** 한 번에 읽는 양 */
const READ_CHUNK_BYTES = 1024 * 1024;
/**
* 열기 — 바로가기를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
* 일반 파일인지는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0)
*/
const OPEN_FLAGS =
constants.O_RDONLY |
(constants.O_NOFOLLOW ?? 0) |
(constants.O_NONBLOCK ?? 0);
function problem(kind: ApkProblem): ApkInspection {
return { ok: false, problem: kind };
}
/** 열지 못한 까닭 → 알릴 것 (모르는 오류는 null — 그대로 던진다) */
function openProblem(error: unknown): ApkProblem | null {
switch ((error as NodeJS.ErrnoException | null)?.code) {
case 'ENOENT':
case 'ENOTDIR':
return 'missing';
case 'ELOOP':
return 'symlink';
case 'EACCES':
case 'EPERM':
return 'unreadable';
// 소켓 등 — 열 수 없는 특수 파일
case 'ENXIO':
case 'EISDIR':
return 'not-file';
default:
return null;
}
}
/**
* 연 파일을 처음부터 끝까지 차례로 읽어 조각마다 부른다 — 읽은 바이트 수가 `size` 와 다르면(그사이 파일이 바뀜) false.
* 넘게 읽히면 거기서 멈춘다. 조각 버퍼는 다음 읽기에 다시 쓴다 — 부르는 쪽은 그 안에서 다 쓴다(해시 · 쓰기를 기다린 뒤 돌아온다)
*/
export async function forEachChunk(
handle: FileHandle,
size: number,
onChunk: (chunk: Buffer) => Promise<void> | void,
): Promise<boolean> {
const buffer = Buffer.alloc(Math.max(1, Math.min(READ_CHUNK_BYTES, size)));
let position = 0;
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
if (bytesRead === 0) return position === size;
position += bytesRead;
if (position > size) return false;
await onChunk(buffer.subarray(0, bytesRead));
}
}
/** 열린 파일의 SHA-256 — 크기가 바뀌었으면 null */
async function sha256Of(
handle: FileHandle,
size: number,
): Promise<string | null> {
const hash = createHash('sha256');
const complete = await forEachChunk(handle, size, (chunk) => {
hash.update(chunk);
});
return complete ? hash.digest('hex') : null;
}
/**
* 운영자가 넣은 경로의 APK 를 검사한다 — 이름(.apk) · 일반 파일 · 크기(1KB ~ 200MB) · zip 머리 · SHA-256.
* 큰 파일은 크기를 본 뒤에야 읽는다(200MB 를 넘는 파일은 읽지 않는다). 통과하면 연 파일째 돌려준다 — 부르는 쪽이 닫는다
*/
export async function inspectApk(path: string): Promise<ApkInspection> {
if (extname(path).toLowerCase() !== APK_EXTENSION) {
return problem('not-apk-name');
}
let handle: FileHandle;
try {
handle = await open(path, OPEN_FLAGS);
} catch (error) {
const reason = openProblem(error);
if (reason === null) throw error;
return problem(reason);
}
let handedOver = false;
try {
const stats = await handle.stat();
if (!stats.isFile()) return problem('not-file');
if (stats.size < APK_MIN_BYTES) return problem('too-small');
if (stats.size > APK_MAX_BYTES) return problem('too-large');
const head = Buffer.alloc(APK_ZIP_SIGNATURE.length);
const { bytesRead } = await handle.read(head, 0, head.length, 0);
if (bytesRead !== head.length || !head.equals(APK_ZIP_SIGNATURE)) {
return problem('not-zip');
}
const sha256 = await sha256Of(handle, stats.size);
if (sha256 === null) return problem('changed');
handedOver = true;
const opened = handle;
return {
ok: true,
apk: {
path,
fileName: basename(path),
sizeBytes: stats.size,
sha256,
handle: opened,
close: () => opened.close(),
},
};
} finally {
if (!handedOver) await handle.close();
}
}
@@ -0,0 +1,154 @@
import { createHash, randomBytes } from 'crypto';
import {
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from 'fs';
import { open, type FileHandle } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import {
AppReleaseFileChangedError,
AppReleaseStorage,
} from './app-release-storage';
const KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const OTHER_KEY = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');
describe('AppReleaseStorage (휴대폰 앱 파일 — 업로드 볼륨의 app-releases/)', () => {
let root: string;
let dir: string;
let storage: AppReleaseStorage;
let source: FileHandle;
const data = randomBytes(2 * 1024 * 1024 + 5);
beforeEach(async () => {
root = mkdtempSync(join(tmpdir(), 'app-release-storage-'));
// 아직 없는 하위 폴더 — 처음 들일 때 만든다
dir = join(root, 'app-releases');
storage = new AppReleaseStorage(dir);
const sourcePath = join(root, 'ARA-SYSTEM-1.0.0.apk');
writeFileSync(sourcePath, data);
source = await open(sourcePath, 'r');
});
afterEach(async () => {
await source.close();
rmSync(root, { recursive: true, force: true });
});
const expected = () => ({ sizeBytes: data.length, sha256: sha256(data) });
it('들이면 <key>.apk 만 남는다(임시 파일 없음 · 소유자만 쓴다) — 열고 · 있는지 보고 · 지운다', async () => {
await storage.importFile(source, KEY, expected());
expect(readdirSync(dir)).toEqual([`${KEY}.apk`]);
expect(readFileSync(join(dir, `${KEY}.apk`)).equals(data)).toBe(true);
if (process.platform !== 'win32') {
expect(statSync(join(dir, `${KEY}.apk`)).mode & 0o777).toBe(0o640);
}
const opened = await storage.open(KEY);
expect(opened?.sizeBytes).toBe(data.length);
await opened?.handle.close();
await expect(storage.exists(KEY)).resolves.toBe(true);
await storage.remove(KEY);
await storage.remove(KEY);
await expect(storage.exists(KEY)).resolves.toBe(false);
await expect(storage.open(KEY)).resolves.toBeNull();
});
it('들이는 동안 다시 센 SHA-256 · 크기가 검사 때와 다르면 들이지 않고 아무것도 남기지 않는다', async () => {
await expect(
storage.importFile(source, KEY, {
...expected(),
sha256: sha256(Buffer.from('other')),
}),
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
expect(readdirSync(dir)).toEqual([]);
await expect(
storage.importFile(source, KEY, {
...expected(),
sizeBytes: data.length - 1,
}),
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
await expect(
storage.importFile(source, KEY, {
...expected(),
sizeBytes: data.length + 1,
}),
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
expect(readdirSync(dir)).toEqual([]);
});
it('쓰다 실패해도(원본을 읽지 못함) 반쯤 쓴 임시 파일을 남기지 않는다', async () => {
const broken = {
read: jest.fn().mockRejectedValue(new Error('EIO')),
} as unknown as FileHandle;
await expect(storage.importFile(broken, KEY, expected())).rejects.toThrow(
'EIO',
);
expect(readdirSync(dir)).toEqual([]);
});
it('같은 키의 임시 파일이 있으면(다른 올리기가 쓰는 중) 덮어쓰지도 지우지도 않는다', async () => {
await storage.importFile(source, OTHER_KEY, expected());
writeFileSync(join(dir, `${KEY}.apk.part`), 'someone else');
await expect(storage.importFile(source, KEY, expected())).rejects.toThrow();
expect(readFileSync(join(dir, `${KEY}.apk.part`), 'utf8')).toBe(
'someone else',
);
});
it('서버가 만든 키(소문자 UUID)가 아니면 경로로 쓰지 않는다', async () => {
for (const key of [
'../../etc/passwd',
`../${KEY}`,
`${KEY}/x`,
KEY.toUpperCase(),
`${KEY}.apk`,
'',
]) {
await expect(storage.importFile(source, key, expected())).rejects.toThrow(
'휴대폰 앱 파일 키 형식이 올바르지 않습니다.',
);
await expect(storage.open(key)).rejects.toThrow();
await expect(storage.exists(key)).rejects.toThrow();
await expect(storage.remove(key)).rejects.toThrow();
}
});
it('멈춘 올리기의 오래된 임시 파일만 지운다 — 새 임시 파일 · 판 파일 · 다른 이름은 그대로, 폴더가 없으면 0', async () => {
await expect(storage.removeStaleParts(new Date())).resolves.toBe(0);
await storage.importFile(source, OTHER_KEY, expected());
const old = join(dir, `${KEY}.apk.part`);
const fresh = join(dir, '0f8fad5b-d9cb-469f-a165-70867728950e.apk.part');
const stranger = join(dir, 'notes.part');
for (const path of [old, fresh, stranger]) writeFileSync(path, 'x');
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
utimesSync(old, longAgo, longAgo);
utimesSync(stranger, longAgo, longAgo);
await expect(
storage.removeStaleParts(new Date(Date.now() - 60 * 60 * 1000)),
).resolves.toBe(1);
expect(readdirSync(dir).sort()).toEqual(
[
`${OTHER_KEY}.apk`,
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
'notes.part',
].sort(),
);
});
});
@@ -0,0 +1,197 @@
import { createHash } from 'crypto';
import {
mkdir,
open,
readdir,
rename,
stat,
unlink,
type FileHandle,
} from 'fs/promises';
import { join } from 'path';
import { forEachChunk } from './apk-file';
import { APK_EXTENSION, APP_RELEASE_DIR } from './app-releases.constants';
/** 서버가 만든 키(소문자 UUID)만 경로로 만든다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
const FILE_KEY =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** 쓰는 중인 임시 파일의 꼬리 — `<key>.apk.part` */
const PART_SUFFIX = '.part';
/** 임시 파일 이름 — 정리할 때 이 꼴만 지운다 */
const PART_FILE_NAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.apk\.part$/;
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) — 서명 그림 · 대용량 첨부 저장과 같다 */
const FILE_MODE = 0o640;
function isMissing(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
}
async function unlinkIfExists(path: string): Promise<void> {
try {
await unlink(path);
} catch (error) {
if (!isMissing(error)) throw error;
}
}
/** 버퍼를 끝까지 쓴다 (한 번에 다 쓰지 못할 수 있다) */
async function writeAll(
out: FileHandle,
data: Buffer,
position: number,
): Promise<void> {
let done = 0;
while (done < data.length) {
const { bytesWritten } = await out.write(
data,
done,
data.length - done,
position + done,
);
done += bytesWritten;
}
}
/** 저장소에 들이는 동안 원본이 검사한 것과 달라졌다 (SHA-256 · 크기) — 들이지 않았다 */
export class AppReleaseFileChangedError extends Error {
override readonly name = 'AppReleaseFileChangedError';
constructor() {
super('APK 가 검사한 뒤 바뀌었습니다.');
}
}
/** 연 판 파일 — 크기는 열린 파일에서(fstat). 부르는 쪽이 닫거나 읽기 흐름에 넘긴다 */
export interface OpenedAppReleaseFile {
readonly handle: FileHandle;
readonly sizeBytes: number;
}
/** 들이는 파일이 맞아야 할 값 — 검사 때 센 것 */
export interface ExpectedAppReleaseFile {
readonly sizeBytes: number;
readonly sha256: string;
}
/**
* 휴대폰 앱 파일 저장소 — 업로드 볼륨의 하위 폴더(APP_RELEASE_DIR) 안 `<file_key>.apk`.
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 서비스가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드").
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다
*/
export class AppReleaseStorage {
constructor(private readonly dir: string = APP_RELEASE_DIR) {}
/** 판 파일을 연다 — 없으면 null */
async open(fileKey: string): Promise<OpenedAppReleaseFile | null> {
let handle: FileHandle;
try {
handle = await open(this.pathOf(fileKey), 'r');
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
try {
return { handle, sizeBytes: (await handle.stat()).size };
} catch (error) {
await handle.close();
throw error;
}
}
/** 판 파일이 있는가 (운영 도구의 목록) */
async exists(fileKey: string): Promise<boolean> {
try {
await stat(this.pathOf(fileKey));
return true;
} catch (error) {
if (isMissing(error)) return false;
throw error;
}
}
/**
* 운영자가 넣은 APK(검사하며 연 파일)를 들인다 — `<key>.apk.part` 에 쓰고(wx — 덮어쓰지 않는다) 디스크에 내려쓴 뒤 `<key>.apk` 로 이름을 바꾼다.
* 쓰면서 다시 센 SHA-256 · 크기가 검사 때와 다르면(검사한 뒤 원본이 바뀜) AppReleaseFileChangedError.
* 어떻게 실패해도 반쯤 쓴 임시 파일을 남기지 않는다 — 이름을 바꾸기 전에는 `<key>.apk` 가 생기지 않는다
*/
async importFile(
source: FileHandle,
fileKey: string,
expected: ExpectedAppReleaseFile,
): Promise<void> {
const finalPath = this.pathOf(fileKey);
const partPath = `${finalPath}${PART_SUFFIX}`;
await mkdir(this.dir, { recursive: true });
const out = await open(partPath, 'wx', FILE_MODE);
let closed = false;
let renamed = false;
try {
const hash = createHash('sha256');
let written = 0;
const complete = await forEachChunk(
source,
expected.sizeBytes,
async (chunk) => {
hash.update(chunk);
await writeAll(out, chunk, written);
written += chunk.length;
},
);
if (!complete || hash.digest('hex') !== expected.sha256) {
throw new AppReleaseFileChangedError();
}
// 이름을 바꾸기 전에 디스크에 내려쓴다 — 표에 넣은 판의 파일이 전원이 꺼져도 남게
await out.datasync();
closed = true;
await out.close();
await rename(partPath, finalPath);
renamed = true;
} finally {
if (!closed) await out.close().catch(() => undefined);
if (!renamed) await unlinkIfExists(partPath);
}
}
/** 판 파일을 지운다 — 이미 없으면 그대로 (표에 넣지 못했을 때 되돌리기) */
async remove(fileKey: string): Promise<void> {
await unlinkIfExists(this.pathOf(fileKey));
}
/**
* 멈춘 올리기(도구가 쓰는 중에 끊김)가 남긴 임시 파일 가운데 `olderThan` 보다 오래된 것을 지운다 → 지운 수.
* 폴더가 없으면 0. 그사이 지워진 것은 건너뛴다
*/
async removeStaleParts(olderThan: Date): Promise<number> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
if (isMissing(error)) return 0;
throw error;
}
let removed = 0;
for (const name of names.filter((entry) => PART_FILE_NAME.test(entry))) {
const path = join(this.dir, name);
try {
if ((await stat(path)).mtime >= olderThan) continue;
await unlink(path);
removed += 1;
} catch (error) {
if (!isMissing(error)) throw error;
}
}
return removed;
}
private pathOf(fileKey: string): string {
if (!FILE_KEY.test(fileKey)) {
// 서버가 만든 키가 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
throw new Error('휴대폰 앱 파일 키 형식이 올바르지 않습니다.');
}
return join(this.dir, `${fileKey}${APK_EXTENSION}`);
}
}
@@ -0,0 +1,61 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
appReleaseFileName,
compareAppVersions,
isAppVersion,
versionInFileName,
} from './app-release-version';
describe('휴대폰 앱 버전 (사내 배포)', () => {
it('버전 글 — 숫자 셋을 점으로(칸마다 1~4자리 · 앞자리 0 없이), 앱 알림 등록의 appVersion 과 같은 규칙', () => {
for (const version of ['1.0.0', '0.0.1', '10.20.30', '1234.0.9999']) {
expect(isAppVersion(version)).toBe(true);
}
for (const version of [
'',
'1.0',
'1.0.0.1',
'01.0.0',
'1.00.0',
'1.0.0-beta',
'1.0.0+5',
'v1.0.0',
'12345.0.0',
' 1.0.0',
'1.0.0\n',
]) {
expect(isAppVersion(version)).toBe(false);
}
});
it('칸마다 숫자로 견준다 — 글자 차례가 아니다(1.10.0 > 1.9.0)', () => {
expect(compareAppVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
expect(compareAppVersions('1.9.9', '2.0.0')).toBeLessThan(0);
expect(compareAppVersions('1.2.3', '1.2.3')).toBe(0);
expect(compareAppVersions('0.9.0', '1.0.0')).toBeLessThan(0);
expect(compareAppVersions('1.0.10', '1.0.2')).toBeGreaterThan(0);
});
it('파일 이름에서 버전을 찾는다 — 없거나 규칙에 맞지 않으면 null', () => {
expect(versionInFileName('ARA-SYSTEM-1.2.3.apk')).toBe('1.2.3');
expect(versionInFileName('ARA-SYSTEM-v10.0.1.APK')).toBe('10.0.1');
expect(versionInFileName('app-release-1.0.0+12.apk')).toBe('1.0.0');
expect(versionInFileName('app-release.apk')).toBeNull();
// 네 칸 · 다섯 자리 · 앞자리 0 — 판으로 보지 않는다
expect(versionInFileName('ARA-SYSTEM-1.2.3.4.apk')).toBeNull();
expect(versionInFileName('ARA-SYSTEM-12345.0.0.apk')).toBeNull();
expect(versionInFileName('ARA-SYSTEM-01.0.0.apk')).toBeNull();
});
it('파일 이름에서 찾기는 긴 이름에도 길이에 비례한다 (숫자 · 점이 잔뜩 든 이름)', () => {
const name = `${'1.'.repeat(100_000)}apk`;
const cpuMs = startCpuTimer();
expect(versionInFileName(name)).toBeNull();
// 지금 수 ms — 한도는 넉넉히(부하에서 는다 · .claude/rules/testing.md)
expect(cpuMs()).toBeLessThan(1_000);
});
it('내려받는 파일 이름 — ARA-SYSTEM-<버전>.apk', () => {
expect(appReleaseFileName('1.0.0')).toBe('ARA-SYSTEM-1.0.0.apk');
});
});
@@ -0,0 +1,43 @@
import {
APP_VERSION_MAX_LENGTH,
APP_VERSION_PATTERN,
} from '@/modules/push/push.constants';
import { APK_EXTENSION } from './app-releases.constants';
/**
* 휴대폰 앱 판 (순수 함수) — 판 글은 앱 알림 등록의 `appVersion` 과 **같은 규칙**이다(push.constants 의 APP_VERSION_PATTERN —
* `숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 앱 설정의 최소 판(app-config)도 같은 꼴로 칸마다 숫자로 견준다
*/
/** 파일 이름 안의 판 후보 — 앞뒤가 숫자 · 점으로 이어지지 않는 `x.y.z` (`1.2.3.4` 의 일부는 판으로 보지 않는다). 칸마다 4자리까지라 길이에 비례한다 */
const VERSION_IN_NAME =
/(?<![0-9.])([0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4})(?![0-9]|\.[0-9])/;
/** 판 글인가 — 길이를 먼저 본다(정규식 전에) */
export function isAppVersion(text: string): boolean {
return (
text.length <= APP_VERSION_MAX_LENGTH && APP_VERSION_PATTERN.test(text)
);
}
/** 두 판을 칸마다 숫자로 견준다 — a 가 낮으면 음수, 같으면 0, 높으면 양수 (둘 다 판 글이어야 한다) */
export function compareAppVersions(a: string, b: string): number {
const left = a.split('.').map(Number);
const right = b.split('.').map(Number);
for (let index = 0; index < left.length; index += 1) {
const difference = left[index] - right[index];
if (difference !== 0) return difference;
}
return 0;
}
/** 운영자가 넣은 파일 이름에서 판을 찾는다 (`ARA-SYSTEM-1.2.3.apk` → `1.2.3`) — 없거나 판 규칙에 맞지 않으면 null */
export function versionInFileName(fileName: string): string | null {
const match = VERSION_IN_NAME.exec(fileName);
return match && isAppVersion(match[1]) ? match[1] : null;
}
/** 직원이 내려받는 파일 이름 — 서버가 검사한 판으로만 만든다 (`ARA-SYSTEM-1.0.0.apk`) */
export function appReleaseFileName(version: string): string {
return `ARA-SYSTEM-${version}${APK_EXTENSION}`;
}
@@ -0,0 +1,63 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { BIGINT_NUMBER } from '@/shared/utils/bigint-column';
import {
APP_RELEASES_TABLE,
APP_RELEASE_UPLOADER_MAX_LENGTH,
type AppReleasePlatform,
} from './app-releases.constants';
/** 플랫폼 이름 길이 — 'android' (push_registrations.platform 과 같다) */
const PLATFORM_COLUMN_LENGTH = 16;
/** 판 길이 — 'NNNN.NNNN.NNNN' 까지 (push_registrations.app_version 과 같다) */
const VERSION_COLUMN_LENGTH = 16;
/** SHA-256 — 소문자 16진수 64자 */
const SHA256_HEX_LENGTH = 64;
/**
* 나눠 주는 휴대폰 앱 한 판 (계약 1절 "휴대폰 앱 받기" — 사내 배포) — 운영 도구 「휴대폰 앱 올리기」가 한 줄씩 넣는다.
*
* - "최신" = 그 플랫폼에서 가장 늦게 올린 줄(created_at) — 판 번호가 아니다. 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길)
* - 파일은 업로드 볼륨의 `app-releases/<file_key>.apk` — 디스크 이름은 서버가 만든 UUID 뿐이다(운영자가 넣은 파일 이름 · 경로를 쓰지 않는다)
* - 지우는 명령은 없다 — 옛 판은 표 · 볼륨에 남는다(되돌리기 · 문의 대응). 직원 주소로 키링한 표가 아니라 퇴사 마무리와 상관없다
*/
@Entity({ name: APP_RELEASES_TABLE })
@Index('IDX_app_releases_platform_created_at', ['platform', 'createdAt'])
export class AppReleaseEntity {
/** 판 id — 서버가 만든 UUID (내려받기 응답의 ETag) */
@PrimaryColumn({ type: 'uuid' })
id!: string;
/** 플랫폼 — 지금은 android 만 */
@Column({ type: 'varchar', length: PLATFORM_COLUMN_LENGTH })
platform!: AppReleasePlatform;
/** 판 (`1.0.0` — 앱의 versionName. 앱 알림 등록의 appVersion 과 같은 규칙) */
@Column({ type: 'varchar', length: VERSION_COLUMN_LENGTH })
version!: string;
/** 파일 크기(바이트) — 내려받기의 Content-Length · 범위 계산이 이 값을 쓴다(디스크의 크기와 다르면 내려주지 않는다) */
@Column({ name: 'size_bytes', type: 'bigint', transformer: BIGINT_NUMBER })
sizeBytes!: number;
/** 파일의 SHA-256 (소문자 16진수) — 운영자가 빌드한 파일과 견준다 */
@Column({ type: 'char', length: SHA256_HEX_LENGTH })
sha256!: string;
/** 디스크의 파일 이름(확장자 빼고) — 서버가 만든 UUID */
@Index('UQ_app_releases_file_key', { unique: true })
@Column({ name: 'file_key', type: 'uuid' })
fileKey!: string;
/** 올린 이 — 운영 도구를 시작할 때 넣은 운영자 이름 */
@Column({
name: 'uploaded_by',
type: 'varchar',
length: APP_RELEASE_UPLOADER_MAX_LENGTH,
})
uploadedBy!: string;
/** 올린 때 — 최신을 가르는 차례 · 화면의 "올린 날"(`releasedAt`) */
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,49 @@
import { join } from 'path';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 한도 · 형식 · 자리를 한 곳에 모은다.
* 앱은 스토어 없이 직접 빌드한 APK 를 포털에 로그인한 직원에게만 나눠 준다.
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts)
*/
/** 표 이름 — app-release.entity.ts 와 같다 */
export const APP_RELEASES_TABLE = 'app_releases';
/** 나눠 주는 플랫폼 — 지금은 안드로이드만 (아이폰은 나중 거리) */
export const APP_RELEASE_PLATFORMS = ['android'] as const;
export type AppReleasePlatform = (typeof APP_RELEASE_PLATFORMS)[number];
/** APK 의 형식 — 내려받기 응답의 Content-Type (휴대폰이 설치 파일로 알아본다) */
export const APK_CONTENT_TYPE = 'application/vnd.android.package-archive';
/** 확장자 — 운영자가 넣은 파일 이름에서 본다(대소문자는 가리지 않는다) · 내려받는 파일 이름에 붙인다 */
export const APK_EXTENSION = '.apk';
/** zip 의 로컬 파일 머리 `PK\x03\x04` — APK 는 zip 이다 */
export const APK_ZIP_SIGNATURE: Readonly<Buffer> = Buffer.from([
0x50, 0x4b, 0x03, 0x04,
]);
/** 크기 한도 — 1KB 아래는 APK 가 아니다, 200MB 위는 받지 않는다(지금 앱은 30MB 남짓) */
export const APK_MIN_BYTES = 1024;
export const APK_MAX_BYTES = 200 * 1024 * 1024;
/**
* 파일 자리 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR)의 하위 폴더. 이름은 `<file_key>.apk` 뿐이다.
* 운영은 named volume `<PROJECT_NAME>_uploads`(매일 백업에 든다), 개발은 바인드 마운트 (.claude/skills/docker/SKILL.md)
*/
export const APP_RELEASE_DIR = join(UPLOAD_DIR, 'app-releases');
/** 운영 도구 「휴대폰 앱 목록」이 보이는 판 수 — 최근 것부터 */
export const APP_RELEASE_LIST_LIMIT = 10;
/** 올린 이(운영자 이름) 길이 — 운영 도구의 운영자 이름 한도(ops-audit-log.entity.ts 의 OPS_OPERATOR_MAX_LENGTH)와 같다 */
export const APP_RELEASE_UPLOADER_MAX_LENGTH = 100;
/**
* 멈춘 올리기가 남긴 임시 파일(`*.apk.part`)을 지우는 나이 — 올리기를 시작할 때 이보다 오래된 것만 지운다.
* 다른 운영자가 막 쓰는 중인 임시 파일은 건드리지 않게 넉넉히 둔다(200MB 를 쓰는 데 몇 초다)
*/
export const APP_RELEASE_STALE_PART_MS = 60 * 60 * 1000;
@@ -0,0 +1,87 @@
import {
Controller,
Get,
HttpStatus,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { NoStore } from '@/common/decorators/no-store.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { APK_CONTENT_TYPE } from './app-releases.constants';
import { AppReleasesService } from './app-releases.service';
import { AppReleaseDto } from './dto/app-release.dto';
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 포털에 로그인한 직원만 받는다(웹 쿠키 · 앱 Bearer — JwtAuthGuard).
* 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts).
*
* "올린 판이 없음"(404)은 화면이 설정을 열 때마다 묻는 예상된 상태라 debug 로만 남긴다 — 표에는 있는데 파일이 없는 404 는 서비스가 error 로 따로 남긴다
*/
@ApiTags('App')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors()
@UseFilters(new HttpExceptionFilter({ quietStatuses: [HttpStatus.NOT_FOUND] }))
@Controller('app-releases')
export class AppReleasesController {
constructor(private readonly releases: AppReleasesService) {}
@Get('android/latest')
@NoStore()
@ApiOperation({
summary: '안드로이드 앱의 최신 판 정보',
description:
'최신 = 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다. ' +
'올린 판이 없으면 404 RES_001 — 화면은 "아직 받을 수 있는 앱이 없습니다" 칸을 보인다(알림 끔). Cache-Control: private, no-store.',
})
@ApiStandardResponse(AppReleaseDto, { description: '최신 판 정보' })
@ApiErrorResponse('RES_001', '올린 판이 없다')
latest(): Promise<AppReleaseDto> {
return this.releases.latestInfo('android');
}
@Get('android/latest/file')
@SkipTransform()
@ApiOperation({
summary:
'안드로이드 앱의 최신 판 파일 (GET · HEAD · Range — 표준 래퍼 없음)',
description:
`늘 ${APK_CONTENT_TYPE} + Content-Disposition: attachment; filename="ARA-SYSTEM-<버전>.apk" + nosniff + sandbox CSP + private, no-store. ` +
'강한 ETag("<판 id>") · Last-Modified(올린 때) · Accept-Ranges: bytes — 대용량 첨부 받기와 같은 머리글이고 형식만 APK 다. ' +
'Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음). HEAD 는 머리글만. ' +
'올린 판이 없거나 그 파일이 없으면 404 RES_001. 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다.',
})
@ApiProduces(APK_CONTENT_TYPE)
@ApiResponse({
status: HttpStatus.OK,
description: 'APK 파일 (206 — 범위)',
schema: { type: 'string', format: 'binary' },
})
@ApiResponse({
status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음',
})
@ApiErrorResponse('RES_001', '올린 판이 없다 · 그 파일이 없다')
async file(@Req() req: Request, @Res() res: Response): Promise<void> {
await this.releases.serveLatestFile(req, res, 'android');
}
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppReleaseStorage } from './app-release-storage';
import { AppReleaseEntity } from './app-release.entity';
import { AppReleasesController } from './app-releases.controller';
import { AppReleasesRepository } from './app-releases.repository';
import { AppReleasesService } from './app-releases.service';
/**
* 휴대폰 앱 받기 모듈 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 최신 판 정보 · 파일(`/api/app-releases/android/latest` · `/file`).
* 판은 표 app_releases, 파일은 업로드 볼륨의 `app-releases/`. 올리기는 운영 도구가 같은 서비스 · 저장소를 다시 엮어 쓴다(modules/ops/ops.module.ts).
* 이 모듈은 다른 기능 모듈을 가져오지 않는다
*/
@Module({
imports: [TypeOrmModule.forFeature([AppReleaseEntity])],
controllers: [AppReleasesController],
providers: [
AppReleasesRepository,
{
provide: AppReleaseStorage,
useFactory: () => new AppReleaseStorage(),
},
AppReleasesService,
],
exports: [AppReleasesService],
})
export class AppReleasesModule {}
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { AppReleaseEntity } from './app-release.entity';
import type { AppReleasePlatform } from './app-releases.constants';
/** 늦게 올린 것부터 — 같은 시각이면 id 로 차례를 정한다(늘 같은 답) */
const NEWEST_FIRST = { createdAt: 'DESC', id: 'DESC' } as const;
/** 휴대폰 앱 판 표 (app_releases) */
@Injectable()
export class AppReleasesRepository {
constructor(
@InjectRepository(AppReleaseEntity)
private readonly rows: Repository<AppReleaseEntity>,
) {}
/** 그 플랫폼에서 가장 늦게 올린 판 — 없으면 null */
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
return this.rows.findOne({ where: { platform }, order: NEWEST_FIRST });
}
/** 늦게 올린 것부터 `limit` 개 */
recent(
platform: AppReleasePlatform,
limit: number,
): Promise<AppReleaseEntity[]> {
return this.rows.find({
where: { platform },
order: NEWEST_FIRST,
take: limit,
});
}
async insert(release: AppReleaseEntity): Promise<void> {
await this.rows.insert(release);
}
}
@@ -0,0 +1,358 @@
import { Logger } from '@nestjs/common';
import { randomBytes } from 'crypto';
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'fs';
import type { Request, Response } from 'express';
import { tmpdir } from 'os';
import { join } from 'path';
import { PassThrough } from 'stream';
import { BusinessException } from '@/common/errors/business.exception';
import { inspectApk, type InspectedApk } from './apk-file';
import { AppReleaseStorage } from './app-release-storage';
import { AppReleaseEntity } from './app-release.entity';
import { APK_ZIP_SIGNATURE } from './app-releases.constants';
import type { AppReleasesRepository } from './app-releases.repository';
import { AppReleasesService } from './app-releases.service';
/** 표 대역 — 늦게 올린 것부터 (실제 차례는 e2e 가 PostgreSQL 로 본다) */
class FakeRepository {
readonly rows: AppReleaseEntity[] = [];
insertFailure: Error | null = null;
private newestFirst(platform: string): AppReleaseEntity[] {
return this.rows
.filter((row) => row.platform === platform)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
latest(platform: string) {
return Promise.resolve(this.newestFirst(platform)[0] ?? null);
}
recent(platform: string, limit: number) {
return Promise.resolve(this.newestFirst(platform).slice(0, limit));
}
insert(release: AppReleaseEntity) {
if (this.insertFailure) return Promise.reject(this.insertFailure);
this.rows.push(release);
return Promise.resolve();
}
}
/** 응답 대역 — 흘려보낸 바이트 · 상태 · 머리글을 모은다 */
class FakeResponse extends PassThrough {
statusCode = 200;
readonly headers = new Map<string, string>();
readonly received: Buffer[] = [];
constructor() {
super();
this.on('data', (part: Buffer) => this.received.push(part));
}
status(code: number): this {
this.statusCode = code;
return this;
}
setHeader(name: string, value: string): this {
this.headers.set(name.toLowerCase(), value);
return this;
}
get body(): Buffer {
return Buffer.concat(this.received);
}
}
const request = (method: string, headers: Record<string, string> = {}) =>
({ method, headers }) as unknown as Request;
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
try {
await run();
return null;
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
}
describe('AppReleasesService — 휴대폰 앱 받기 (사내 배포)', () => {
let root: string;
let releaseDir: string;
let repository: FakeRepository;
let service: AppReleasesService;
let errors: string[];
const content = Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(4096)]);
/** 운영자가 넣은 것처럼 — 임시 폴더의 APK 를 검사해 연 파일째 */
async function inspected(data: Buffer = content): Promise<InspectedApk> {
const path = join(root, `in-${randomBytes(4).toString('hex')}.apk`);
writeFileSync(path, data);
const inspection = await inspectApk(path);
if (!inspection.ok) throw new Error(inspection.problem);
return inspection.apk;
}
async function publish(
version: string,
at: Date,
data: Buffer = content,
): Promise<AppReleaseEntity> {
const apk = await inspected(data);
try {
return await service.publish(
apk,
{ platform: 'android', version, uploadedBy: '운영자 김' },
at,
);
} finally {
await apk.close();
}
}
async function serve(req: Request): Promise<FakeResponse> {
const res = new FakeResponse();
await service.serveLatestFile(req, res as unknown as Response, 'android');
return res;
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'app-releases-'));
releaseDir = join(root, 'app-releases');
repository = new FakeRepository();
service = new AppReleasesService(
repository as unknown as AppReleasesRepository,
new AppReleaseStorage(releaseDir),
);
errors = [];
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation((message: unknown) => {
errors.push(String(message));
});
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
it('최신 판 정보 — 올린 판이 없으면 404 RES_001, 있으면 가장 늦게 올린 판(버전 번호가 아니다)', async () => {
expect(await codeOf(() => service.latestInfo('android'))).toBe('RES_001');
await publish('1.2.0', new Date('2026-11-18T01:00:00.000Z'));
const older = await publish('1.1.0', new Date('2026-11-19T01:23:45.000Z'));
await expect(service.latestInfo('android')).resolves.toEqual({
version: '1.1.0',
sizeBytes: content.length,
releasedAt: older.createdAt.toISOString(),
});
});
it('올리기 — 볼륨에 <file_key>.apk 하나 · 표에 한 줄(크기 · SHA-256 · 올린 이)', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-18T01:23:45.000Z'),
);
expect(repository.rows).toEqual([release]);
expect(release).toMatchObject({
platform: 'android',
version: '1.0.0',
sizeBytes: content.length,
uploadedBy: '운영자 김',
});
expect(release.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(release.fileKey).not.toBe(release.id);
expect(readdirSync(releaseDir)).toEqual([`${release.fileKey}.apk`]);
});
it('올리기 — 표에 넣지 못하면 들인 파일을 지우고 던진다(반쯤 올린 판을 남기지 않는다)', async () => {
repository.insertFailure = new Error('db down');
const apk = await inspected();
try {
await expect(
service.publish(apk, {
platform: 'android',
version: '1.0.0',
uploadedBy: '운영자 김',
}),
).rejects.toThrow('db down');
} finally {
await apk.close();
}
expect(repository.rows).toEqual([]);
expect(readdirSync(releaseDir)).toEqual([]);
});
it('올리기 — 버전 글이 아니면 아무것도 하지 않고 던진다(부르는 쪽이 먼저 본다)', async () => {
const apk = await inspected();
try {
await expect(
service.publish(apk, {
platform: 'android',
version: '1.0',
uploadedBy: '운영자 김',
}),
).rejects.toThrow('휴대폰 앱 버전 형식이 올바르지 않습니다.');
} finally {
await apk.close();
}
expect(existsSync(releaseDir)).toBe(false);
});
it('올리기 — 멈춘 올리기가 남긴 한 시간 넘은 임시 파일을 먼저 지운다', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await publish('1.0.0', new Date('2026-11-18T01:00:00.000Z'));
const stale = join(
releaseDir,
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
);
writeFileSync(stale, 'half');
// 지금 시각(파일의 고친 때)보다 두 시간 뒤에 올린다
await publish('1.0.1', new Date(Date.now() + 2 * 60 * 60 * 1000));
expect(existsSync(stale)).toBe(false);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('임시 파일 1개를 지웠습니다'),
);
});
describe('파일 응답 (Range · HEAD)', () => {
let release: AppReleaseEntity;
beforeEach(async () => {
release = await publish('1.0.0', new Date('2026-11-18T01:23:45.000Z'));
});
it('전체 200 — APK 형식 · 이름 · 대용량 받기와 같은 머리글(nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
const res = await serve(request('GET'));
expect(res.statusCode).toBe(200);
expect(res.body.equals(content)).toBe(true);
expect(Object.fromEntries(res.headers)).toEqual({
'content-type': 'application/vnd.android.package-archive',
'content-disposition': 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
'referrer-policy': 'no-referrer',
'x-robots-tag': 'noindex',
'accept-ranges': 'bytes',
etag: `"${release.id}"`,
'last-modified': release.createdAt.toUTCString(),
'x-accel-buffering': 'no',
'content-length': String(content.length),
});
});
it('범위 하나 206 · 끝 N 바이트 · 처음이 크기 이상이면 416(본문 없음) · If-Range 가 다르면 전체 200 · HEAD 는 머리글만', async () => {
const partial = await serve(request('GET', { range: 'bytes=0-99' }));
expect(partial.statusCode).toBe(206);
expect(partial.body.equals(content.subarray(0, 100))).toBe(true);
expect(partial.headers.get('content-range')).toBe(
`bytes 0-99/${content.length}`,
);
expect(partial.headers.get('content-length')).toBe('100');
const suffix = await serve(request('GET', { range: 'bytes=-10' }));
expect(suffix.statusCode).toBe(206);
expect(suffix.body.equals(content.subarray(content.length - 10))).toBe(
true,
);
const unsatisfiable = await serve(
request('GET', { range: `bytes=${content.length}-` }),
);
expect(unsatisfiable.statusCode).toBe(416);
expect(unsatisfiable.headers.get('content-range')).toBe(
`bytes */${content.length}`,
);
expect(unsatisfiable.headers.get('content-length')).toBe('0');
expect(unsatisfiable.body.length).toBe(0);
const changed = await serve(
request('GET', { range: 'bytes=0-9', 'if-range': '"other"' }),
);
expect(changed.statusCode).toBe(200);
expect(changed.body.length).toBe(content.length);
const resumed = await serve(
request('GET', { range: 'bytes=10-', 'if-range': `"${release.id}"` }),
);
expect(resumed.statusCode).toBe(206);
expect(resumed.body.equals(content.subarray(10))).toBe(true);
const head = await serve(request('HEAD', { range: 'bytes=0-1' }));
expect(head.statusCode).toBe(206);
expect(head.headers.get('content-length')).toBe('2');
expect(head.body.length).toBe(0);
});
it('표에는 있는데 파일이 없거나 크기가 다르면 404 RES_001 — 머리글을 쓰지 않고, 운영자가 알게 error 로그', async () => {
writeFileSync(join(releaseDir, `${release.fileKey}.apk`), 'cut');
const res = new FakeResponse();
expect(
await codeOf(() =>
service.serveLatestFile(
request('GET'),
res as unknown as Response,
'android',
),
),
).toBe('RES_001');
expect(res.headers.size).toBe(0);
expect(errors.join('\n')).toContain('크기가 표와 다름');
rmSync(join(releaseDir, `${release.fileKey}.apk`));
expect(await codeOf(() => serve(request('GET')))).toBe('RES_001');
expect(errors.join('\n')).toContain('파일 없음');
expect(errors.join('\n')).toContain(`release=${release.id}`);
});
it('올린 판이 없으면 404 RES_001 (머리글을 쓰기 전에)', async () => {
repository.rows.length = 0;
const res = new FakeResponse();
expect(
await codeOf(() =>
service.serveLatestFile(
request('GET'),
res as unknown as Response,
'android',
),
),
).toBe('RES_001');
expect(res.headers.size).toBe(0);
});
it('서버 종료가 시작되면 새 받기는 시작하지 않는다(연결을 끊는다 — 받는 쪽이 이어 받는다)', async () => {
service.beforeApplicationShutdown();
const res = await serve(request('GET'));
expect(res.destroyed).toBe(true);
expect(res.body.length).toBe(0);
});
it('목록 — 늦게 올린 것부터, 디스크에 파일이 있는지 함께', async () => {
const newer = await publish(
'1.0.1',
new Date('2026-11-19T01:00:00.000Z'),
);
rmSync(join(releaseDir, `${release.fileKey}.apk`));
await expect(service.listRecent('android', 10)).resolves.toEqual([
{ release: newer, hasFile: true },
{ release, hasFile: false },
]);
await expect(service.listRecent('android', 1)).resolves.toEqual([
{ release: newer, hasFile: true },
]);
});
});
});
@@ -0,0 +1,325 @@
import {
type BeforeApplicationShutdown,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Request, Response } from 'express';
import type { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { BusinessException } from '@/common/errors/business.exception';
import { currentRequestId, traceTag } from '@/shared/logger/request-context';
import { downloadHeaders } from '@/shared/utils/download-headers';
import { decideRange } from '@/shared/utils/http-range';
import { isStreamAbortError } from '@/shared/utils/stream-abort';
import { inspectApk, type ApkInspection, type InspectedApk } from './apk-file';
import {
AppReleaseStorage,
type OpenedAppReleaseFile,
} from './app-release-storage';
import { appReleaseFileName, isAppVersion } from './app-release-version';
import { AppReleaseEntity } from './app-release.entity';
import {
APK_CONTENT_TYPE,
APP_RELEASE_STALE_PART_MS,
type AppReleasePlatform,
} from './app-releases.constants';
import { AppReleasesRepository } from './app-releases.repository';
import type {
AppReleaseInfo,
AppReleaseListing,
AppReleasePublish,
} from './app-releases.types';
/** 버전 글이 아닌 줄(표를 손으로 고침)의 내려받는 이름 — 따옴표 안에 바깥 글이 들어가지 않게 */
const FALLBACK_FILE_NAME = 'ARA-SYSTEM.apk';
/** 머리글 하나 (여럿이면 첫 값) */
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function reasonOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 직원은 최신 판 정보 · 파일을 받고(로그인 필요), 운영 도구가 판을 올린다.
* "최신" = 그 플랫폼에서 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길).
*
* 파일 응답은 대용량 첨부 받기(file-sharing.service.ts 의 serveFile)와 같은 꼴이다 — 머리글은 shared/utils/download-headers.ts, 범위는 http-range.ts.
* 로그: 내려받기 성공은 남기지 않는다. 표에는 있는데 파일이 없거나 크기가 다르면 error(운영자가 다시 올린다)
*/
@Injectable()
export class AppReleasesService implements BeforeApplicationShutdown {
private readonly logger = new Logger(AppReleasesService.name);
/** 지금 흘려보내는 파일 응답 — 서버 종료 때 끊는다 */
private readonly streaming = new Set<Response>();
private shuttingDown = false;
constructor(
private readonly repository: AppReleasesRepository,
private readonly storage: AppReleaseStorage,
) {}
/**
* 서버 종료(HTTP 서버를 닫기 전) — 흘려보내는 파일 응답을 끊는다. 느린 내려받기가 종료 유예(30초)를 다 쓰지 않게 —
* 브라우저는 Range 로 이어 받는다(대용량 첨부 받기와 같은 결). 그 뒤에 온 받기는 시작하지 않는다
*/
beforeApplicationShutdown(): void {
this.shuttingDown = true;
for (const res of this.streaming) res.destroy();
this.streaming.clear();
}
// ── 직원 (로그인 필요 — app-releases.controller.ts) ────────────────────────────
/** 최신 판 정보 — 올린 판이 없으면 404 RES_001 */
async latestInfo(platform: AppReleasePlatform): Promise<AppReleaseInfo> {
const release = await this.requireLatest(platform);
return {
version: release.version,
sizeBytes: release.sizeBytes,
releasedAt: release.createdAt.toISOString(),
};
}
/**
* 최신 판 파일을 내려준다 (GET · HEAD). 실패는 머리글을 쓰기 **전에** 던진다(전역 필터가 표준 실패 형식으로):
* 올린 판이 없음 · 파일이 없거나 표의 크기와 다름 404 RES_001.
* 범위: 없으면 200 전체, 하나면 206, 처음이 크기 이상이면 416(본문 없음 — 래핑 예외). HEAD 는 머리글만
*/
async serveLatestFile(
req: Request,
res: Response,
platform: AppReleasePlatform,
): Promise<void> {
const release = await this.requireLatest(platform);
const file = await this.openFileOf(release);
let handedOver = false;
try {
const size = release.sizeBytes;
const etag = `"${release.id}"`;
const decision = decideRange(
firstHeader(req.headers.range),
firstHeader(req.headers['if-range']),
size,
{ etag, lastModified: release.createdAt },
);
this.writeFileHeaders(res, release, etag);
if (decision.kind === 'unsatisfiable') {
res.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
res.setHeader('Content-Range', `bytes */${size}`);
res.setHeader('Content-Length', '0');
res.end();
return;
}
const range =
decision.kind === 'partial'
? { start: decision.start, end: decision.end }
: undefined;
res.status(range ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK);
res.setHeader(
'Content-Length',
String(range ? range.end - range.start + 1 : size),
);
if (range) {
res.setHeader(
'Content-Range',
`bytes ${range.start}-${range.end}/${size}`,
);
}
if (req.method === 'HEAD') {
res.end();
return;
}
if (this.shuttingDown) {
// 종료 중 — 시작하지 않는다. 연결을 끊으면 받는 쪽이 다시 시도한다
res.destroy();
return;
}
// 흐름이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값)
const stream = file.handle.createReadStream(
range ? { start: range.start, end: range.end } : {},
);
handedOver = true;
await this.stream(stream, res, release);
} finally {
if (!handedOver) await file.handle.close();
}
}
// ── 운영 도구 (modules/ops/app-release-commands.ts) ─────────────────────────────
/** 운영자가 넣은 경로의 APK 를 검사한다 — 통과하면 연 파일째(부르는 쪽이 닫는다) */
inspect(path: string): Promise<ApkInspection> {
return inspectApk(path);
}
/** 그 플랫폼의 최신 판 — 없으면 null */
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
return this.repository.latest(platform);
}
/** 늦게 올린 것부터 `limit` 개 — 디스크에 파일이 있는지 함께 */
async listRecent(
platform: AppReleasePlatform,
limit: number,
): Promise<AppReleaseListing[]> {
const releases = await this.repository.recent(platform, limit);
return Promise.all(
releases.map(async (release) => ({
release,
hasFile: await this.storage.exists(release.fileKey),
})),
);
}
/**
* 판을 올린다 — 검사한 APK 를 볼륨에 들이고(임시 이름 → 디스크에 내려쓰기 → 이름 바꾸기) 표에 한 줄 넣는다. 넣은 순간 최신이 된다.
* 들이는 동안 원본이 바뀌었으면 AppReleaseFileChangedError(파일도 표도 그대로). 표에 넣지 못하면 들인 파일을 지우고 던진다 —
* 반쯤 올린 판을 남기지 않는다. 시작할 때 멈춘 올리기가 남긴 오래된 임시 파일을 지운다
*/
async publish(
apk: InspectedApk,
input: AppReleasePublish,
now: Date = new Date(),
): Promise<AppReleaseEntity> {
if (!isAppVersion(input.version)) {
// 부르는 쪽(운영 도구)이 먼저 본다 — 여기까지 오면 버그
throw new Error('휴대폰 앱 버전 형식이 올바르지 않습니다.');
}
await this.removeStaleParts(now);
const release = Object.assign(new AppReleaseEntity(), {
id: randomUUID(),
platform: input.platform,
version: input.version,
sizeBytes: apk.sizeBytes,
sha256: apk.sha256,
fileKey: randomUUID(),
uploadedBy: input.uploadedBy,
createdAt: now,
} satisfies AppReleaseEntity);
await this.storage.importFile(apk.handle, release.fileKey, {
sizeBytes: apk.sizeBytes,
sha256: apk.sha256,
});
try {
await this.repository.insert(release);
} catch (error) {
// 표에 없는 파일은 누구도 받지 않는다 — 지워 둔다
await this.storage
.remove(release.fileKey)
.catch((removeError: unknown) =>
this.logger.error(
`[SYS_001] 표에 넣지 못한 휴대폰 앱 파일을 지우지도 못했습니다 — 볼륨에 남습니다: file=${release.fileKey}, reason=${reasonOf(removeError)}`,
),
);
throw error;
}
this.logger.log(
`휴대폰 앱을 올렸습니다: platform=${release.platform}, version=${release.version}, release=${release.id}, size=${release.sizeBytes}`,
);
return release;
}
// ── 내부 ────────────────────────────────────────────────────────────────
private async requireLatest(
platform: AppReleasePlatform,
): Promise<AppReleaseEntity> {
const release = await this.repository.latest(platform);
if (release === null) {
throw new BusinessException('RES_001', {
context: { reason: 'app-release-none', platform },
});
}
return release;
}
/** 그 판의 파일 — 없거나 표의 크기와 다르면 error 로그 + 404 RES_001 (직원에게는 올린 판이 없는 것과 같다) */
private async openFileOf(
release: AppReleaseEntity,
): Promise<OpenedAppReleaseFile> {
const file = await this.storage.open(release.fileKey);
if (file !== null && file.sizeBytes === release.sizeBytes) return file;
if (file !== null) await file.handle.close();
const missing = file === null;
this.logger.error(
`[RES_001]${traceTag()} 휴대폰 앱 파일을 내려줄 수 없습니다(${missing ? '파일 없음' : '크기가 표와 다름'}) — 운영 도구로 다시 올려 주세요: release=${release.id}, platform=${release.platform}, version=${release.version}`,
);
throw new BusinessException('RES_001', {
context: {
reason: missing
? 'app-release-file-missing'
: 'app-release-file-size-mismatch',
release: release.id,
},
});
}
/** 파일 응답의 머리글 — 대용량 첨부 받기와 같은 꼴이고 형식만 APK 다 (shared/utils/download-headers.ts) */
private writeFileHeaders(
res: Response,
release: AppReleaseEntity,
etag: string,
): void {
const fileName = isAppVersion(release.version)
? appReleaseFileName(release.version)
: FALLBACK_FILE_NAME;
for (const [header, value] of downloadHeaders({
contentType: APK_CONTENT_TYPE,
// 이름은 검사한 버전으로 만든 ASCII 뿐이다 — RFC 5987 이름(filename*)이 필요 없다
contentDisposition: `attachment; filename="${fileName}"`,
etag,
lastModified: release.createdAt,
})) {
res.setHeader(header, value);
}
}
private async stream(
stream: Readable,
res: Response,
release: AppReleaseEntity,
): Promise<void> {
// 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 ID 를 지금 잡아 둔다
const traceId = currentRequestId() ?? 'unknown';
this.streaming.add(res);
try {
// 파일을 여는 사이 종료가 시작됐으면 흘려보내지 않는다 (종료 때 끊을 목록에 들기 전이었다)
if (this.shuttingDown) res.destroy();
await pipeline(stream, res);
} catch (error) {
// 받는 쪽이 끊었거나 종료로 끊었다 — 장애가 아니다. 디스크 오류 등만 남긴다
if (!isStreamAbortError(error)) {
this.logger.error(
`[SYS_001][${traceId}] 휴대폰 앱 전송이 멈췄습니다: release=${release.id}, reason=${reasonOf(error)}`,
);
}
if (!res.destroyed) res.destroy();
} finally {
this.streaming.delete(res);
}
}
/** 멈춘 올리기가 남긴 오래된 임시 파일 — 정리하지 못해도 올리기는 이어 한다 */
private async removeStaleParts(now: Date): Promise<void> {
try {
const removed = await this.storage.removeStaleParts(
new Date(now.getTime() - APP_RELEASE_STALE_PART_MS),
);
if (removed > 0) {
this.logger.warn(
`멈춘 휴대폰 앱 올리기가 남긴 임시 파일 ${removed}개를 지웠습니다.`,
);
}
} catch (error) {
this.logger.warn(
`멈춘 휴대폰 앱 올리기의 임시 파일을 정리하지 못했습니다 — 올리기는 이어 합니다: reason=${reasonOf(error)}`,
);
}
}
}
@@ -0,0 +1,24 @@
import type { AppReleaseEntity } from './app-release.entity';
import type { AppReleasePlatform } from './app-releases.constants';
/** 최신 판 정보 — `GET /api/app-releases/android/latest` 의 data (계약 1절 "휴대폰 앱 받기") */
export interface AppReleaseInfo {
readonly version: string;
readonly sizeBytes: number;
/** 올린 때 — ISO 8601 UTC */
readonly releasedAt: string;
}
/** 운영 도구가 올리는 판 */
export interface AppReleasePublish {
readonly platform: AppReleasePlatform;
readonly version: string;
/** 운영자 이름 — 운영 도구를 시작할 때 넣은 이름 */
readonly uploadedBy: string;
}
/** 운영 도구 「휴대폰 앱 목록」의 한 줄 — 디스크에 파일이 있는지 함께 */
export interface AppReleaseListing {
readonly release: AppReleaseEntity;
readonly hasFile: boolean;
}
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import type { AppReleaseInfo } from '../app-releases.types';
/** GET /api/app-releases/android/latest 의 응답 (계약 1절 "휴대폰 앱 받기") */
export class AppReleaseDto implements AppReleaseInfo {
@ApiProperty({
description:
'버전 — 앱의 versionName(`숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 내려받는 파일 이름은 `ARA-SYSTEM-<버전>.apk`',
example: '1.0.0',
})
version!: string;
@ApiProperty({
description: 'APK 파일 크기(바이트)',
example: 32_505_856,
})
sizeBytes!: number;
@ApiProperty({
description:
'올린 때 (ISO 8601 UTC) — 화면의 "올린 날". 최신 = 가장 늦게 올린 판(버전 번호가 아니다)',
example: '2026-11-18T01:23:45.000Z',
})
releasedAt!: string;
}
@@ -21,6 +21,7 @@ import {
import type { StoredFileView } from '@/modules/file-storage/file-storage.types';
import { currentRequestId, traceTag } from '@/shared/logger/request-context';
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
import { downloadHeaders } from '@/shared/utils/download-headers';
import { decideRange } from '@/shared/utils/http-range';
import { kstEndOfDayAfter } from '@/shared/utils/kst-date';
import { maskEmail } from '@/shared/utils/mask';
@@ -67,10 +68,8 @@ import {
/** 보낸 링크 목록의 커서 범위 (shared/utils/offset-cursor.ts) */
const SHARE_LINKS_SCOPE = 'share-links';
/** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식은 서버가 정한다(늘 내려받기) */
/** 파일 응답의 형식 (계약 4절 "파일 응답") — 서버가 정한다(늘 내려받기). 나머지 머리글은 shared/utils/download-headers.ts */
const FILE_CONTENT_TYPE = 'application/octet-stream';
const FILE_CSP = "default-src 'none'; sandbox";
const FILE_CACHE_CONTROL = 'private, no-store';
/** 내려받기 — 누가 받나 */
export interface DownloadOptions {
@@ -634,25 +633,24 @@ export class FileSharingService implements BeforeApplicationShutdown {
});
}
/** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식 · 이름 · 검증자 · 캐시 · 버퍼링 */
/**
* 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식 · 이름 · 검증자 · 캐시 · 버퍼링.
* 휴대폰 앱 받기와 같은 꼴이다(shared/utils/download-headers.ts — 형식만 다르다)
*/
private writeFileHeaders(
res: Response,
name: string,
etag: string,
lastModified: Date,
): void {
res.setHeader('Content-Type', FILE_CONTENT_TYPE);
res.setHeader('Content-Disposition', buildAttachmentDisposition(name));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', FILE_CSP);
res.setHeader('Cache-Control', FILE_CACHE_CONTROL);
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-Robots-Tag', 'noindex');
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', lastModified.toUTCString());
// 앞단 nginx · 외부 프록시가 이 응답을 임시 파일에 받아 두지 않게 (느린 받는 사람마다 1GB 까지)
res.setHeader('X-Accel-Buffering', 'no');
for (const [header, value] of downloadHeaders({
contentType: FILE_CONTENT_TYPE,
contentDisposition: buildAttachmentDisposition(name),
etag,
lastModified,
})) {
res.setHeader(header, value);
}
}
private writeLength(
@@ -0,0 +1,450 @@
import { Logger } from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
import type { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import { APK_ZIP_SIGNATURE } from '@/modules/app-releases/app-releases.constants';
import type { AppReleasesRepository } from '@/modules/app-releases/app-releases.repository';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import {
APP_VERSION_RULE_TEXT,
AppReleaseCommands,
apkProblemText,
} from './app-release-commands';
import type { OpsAuditEntry, OpsAuditService } from './ops-audit.service';
import { OpsInputClosed, type OpsIo } from './ops-io';
const OPERATOR = '운영자 김';
/** 답 — 글이면 그대로, 함수면 부른 값(묻는 사이에 무언가를 바꿔 본다) */
type Answer = string | (() => string);
/** 줄 입력 흉내 — 답을 차례로 주고, 보인 줄을 모은다. 답이 떨어지면 입력이 끝난 것으로 */
class ScriptedIo implements OpsIo {
readonly interactive = true;
readonly printed: string[] = [];
readonly asked: string[] = [];
constructor(private readonly answers: Answer[]) {}
print(line = ''): void {
this.printed.push(line);
}
ask(question: string): Promise<string> {
this.asked.push(question);
return this.next().then((answer) => answer.trim());
}
askSecret(question: string): Promise<string> {
this.asked.push(question);
return this.next();
}
async confirm(question: string): Promise<boolean> {
this.asked.push(question);
return ['y', 'yes', '예'].includes((await this.next()).toLowerCase());
}
close(): void {}
get output(): string {
return this.printed.join('\n');
}
private next(): Promise<string> {
const answer = this.answers.shift();
if (answer === undefined) return Promise.reject(new OpsInputClosed());
return Promise.resolve(typeof answer === 'function' ? answer() : answer);
}
}
/** 표 대역 — 늦게 올린 것부터 */
class FakeRepository {
readonly rows: AppReleaseEntity[] = [];
insertFailure: Error | null = null;
private newestFirst(): AppReleaseEntity[] {
return [...this.rows].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
}
latest() {
return Promise.resolve(this.newestFirst()[0] ?? null);
}
recent(_platform: string, limit: number) {
return Promise.resolve(this.newestFirst().slice(0, limit));
}
insert(release: AppReleaseEntity) {
if (this.insertFailure) return Promise.reject(this.insertFailure);
this.rows.push(release);
return Promise.resolve();
}
}
const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');
describe('운영 도구 — 휴대폰 앱 올리기 · 목록 (사내 배포)', () => {
let root: string;
/** 운영자가 docker cp 로 넣은 자리 (컨테이너의 /tmp 흉내) */
let incoming: string;
/** 업로드 볼륨의 app-releases/ 흉내 */
let releaseDir: string;
let repository: FakeRepository;
let service: AppReleasesService;
let audit: { entries: OpsAuditEntry[]; record: jest.Mock };
let commands: AppReleaseCommands;
let errorLogs: string[];
const apkBytes = () =>
Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(8 * 1024)]);
/** 운영자가 넣은 APK — 경로를 준다 */
const dropApk = (name: string, data: Buffer = apkBytes()) => {
const path = join(incoming, name);
writeFileSync(path, data);
return path;
};
/** 볼륨에 남은 것 (없으면 빈 목록) */
const stored = () => (existsSync(releaseDir) ? readdirSync(releaseDir) : []);
/** 이미 올린 판 하나 (표 · 볼륨) */
const existing = async (version: string, at: Date) => {
const inspection = await service.inspect(dropApk(`old-${version}.apk`));
if (!inspection.ok) throw new Error(inspection.problem);
try {
return await service.publish(
inspection.apk,
{ platform: 'android', version, uploadedBy: '먼저 올린 사람' },
at,
);
} finally {
await inspection.apk.close();
}
};
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'ops-app-release-'));
incoming = join(root, 'tmp');
mkdirSync(incoming);
releaseDir = join(root, 'uploads', 'app-releases');
repository = new FakeRepository();
service = new AppReleasesService(
repository as unknown as AppReleasesRepository,
new AppReleaseStorage(releaseDir),
);
const entries: OpsAuditEntry[] = [];
audit = {
entries,
record: jest.fn((entry: OpsAuditEntry) => {
entries.push(entry);
return Promise.resolve(true);
}),
};
commands = new AppReleaseCommands(
service,
audit as unknown as OpsAuditService,
);
errorLogs = [];
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(
(...args: unknown[]) => void errorLogs.push(args.map(String).join(' ')),
);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
describe('올리기', () => {
it('경로 → 버전(파일 이름의 버전이 기본값) → 바뀔 것(버전 · 크기 · SHA-256) y → 볼륨 · 표 · 실행 기록, 끝에 SHA-256 을 한 번 더', async () => {
const data = apkBytes();
const path = dropApk('ARA-SYSTEM-1.0.0.apk', data);
const io = new ScriptedIo([path, '', 'y']);
await commands.upload(io, OPERATOR);
expect(io.asked).toContain('버전 (빈 값이면 파일 이름의 1.0.0): ');
expect(io.output).toContain(' 휴대폰 앱 올리기 — 바뀔 것');
expect(io.output).toContain(` 파일 ${path}`);
expect(io.output).toContain(' 버전 1.0.0 (처음 올리는 앱)');
expect(io.output).toContain(
`(${data.length.toLocaleString('en-US')}바이트)`,
);
expect(io.output).toContain(` SHA-256 ${sha256(data)}`);
expect(io.output).toContain(' 받는 이름 ARA-SYSTEM-1.0.0.apk');
expect(io.output).toContain(
' [완료] 올렸습니다 — 버전 1.0.0 · 8 KB. 직원은 지금부터 이 버전을 받습니다.',
);
// 끝에 한 번 더 — 빌드한 PC 의 값과 견준다
expect(io.printed).toContain(` SHA-256 ${sha256(data)}`);
expect(io.output).toContain(
'certutil -hashfile ARA-SYSTEM-1.0.0.apk SHA256',
);
const [release] = repository.rows;
expect(release).toMatchObject({
platform: 'android',
version: '1.0.0',
sizeBytes: data.length,
sha256: sha256(data),
uploadedBy: OPERATOR,
});
expect(stored()).toEqual([`${release.fileKey}.apk`]);
expect(audit.entries).toEqual([
{
operator: OPERATOR,
command: 'app-release-upload',
target: 'android 1.0.0',
result: 'success',
detail: `id=${release.id} · ${data.length}바이트 · sha256=${sha256(data)} · 파일 ${path}`,
},
]);
});
it('검사한 연 파일은 성공 · 실패 모두 닫는다', async () => {
const closes: string[] = [];
const inspect = service.inspect.bind(service);
jest.spyOn(service, 'inspect').mockImplementation(async (path) => {
const inspection = await inspect(path);
if (!inspection.ok) return inspection;
const { apk } = inspection;
return {
ok: true,
apk: {
...apk,
close: () => {
closes.push(apk.fileName);
return apk.close();
},
},
};
});
await commands.upload(
new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']),
OPERATOR,
);
await commands.upload(
new ScriptedIo([dropApk('ARA-SYSTEM-1.0.1.apk'), 'x']),
OPERATOR,
);
expect(closes).toEqual(['ARA-SYSTEM-1.0.0.apk', 'ARA-SYSTEM-1.0.1.apk']);
});
it('바뀔 것에서 N 이면 아무것도 바꾸지 않는다 (실행 기록도 없다)', async () => {
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']);
await commands.upload(io, OPERATOR);
expect(io.output).toContain('하지 않았습니다.');
expect(repository.rows).toEqual([]);
expect(stored()).toEqual([]);
expect(audit.entries).toEqual([]);
});
it('버전 — 파일 이름에 없으면 예시로 묻고, 규칙에 맞지 않으면 알리고 멈춘다', async () => {
const noVersion = new ScriptedIo([dropApk('app-release.apk'), '']);
await commands.upload(noVersion, OPERATOR);
expect(noVersion.asked).toContain('버전 (예: 1.0.0): ');
expect(noVersion.output).toContain(APP_VERSION_RULE_TEXT);
for (const version of ['1.0', 'v1.0.0', '01.0.0', '1.0.0-beta']) {
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), version]);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(APP_VERSION_RULE_TEXT);
}
expect(repository.rows).toEqual([]);
expect(audit.entries).toEqual([]);
});
it('넣은 버전이 기본값과 다르면 넣은 것 — 파일 이름보다 운영자의 답이 먼저다', async () => {
const io = new ScriptedIo([
dropApk('ARA-SYSTEM-1.0.0.apk'),
'1.0.2',
'y',
]);
await commands.upload(io, OPERATOR);
expect(repository.rows[0]?.version).toBe('1.0.2');
expect(io.output).toContain(' 받는 이름 ARA-SYSTEM-1.0.2.apk');
});
it('서버가 받는 가장 낮은 버전(app-config)보다 낮으면 올리지 않는다', async () => {
const io = new ScriptedIo([dropApk('ARA-SYSTEM-0.9.0.apk'), '']);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(
'서버가 받는 가장 낮은 버전은 1.0.0 입니다 — 그보다 낮은 버전은 올리지 않습니다',
);
expect(repository.rows).toEqual([]);
expect(io.asked).not.toContain('그래도 올릴까요?');
});
it('지금 최신보다 낮으면 알리고 묻는다 — N 이면 그대로, y 면 바뀔 것을 거쳐 올린다(되돌리기 길)', async () => {
// 이미 올린 판은 지난 날 — 지금 올리는 판(지금 시각)이 더 늦게 올린 것이 된다
await existing('1.2.0', new Date('2026-11-01T01:00:00.000Z'));
const declined = new ScriptedIo([
dropApk('ARA-SYSTEM-1.1.0.apk'),
'',
'n',
]);
await commands.upload(declined, OPERATOR);
expect(declined.output).toContain(
'주의: 넣은 버전(1.1.0)이 지금 직원이 받는 버전(1.2.0 · 2026년 11월 1일 (일) 오전 10:00 올림)보다 낮습니다.',
);
expect(declined.output).toContain('앱을 지우고 다시 깔아야 합니다');
expect(declined.output).toContain('하지 않았습니다.');
expect(repository.rows).toHaveLength(1);
const accepted = new ScriptedIo([
dropApk('ARA-SYSTEM-1.1.0.apk'),
'',
'y',
'y',
]);
await commands.upload(accepted, OPERATOR);
expect(accepted.output).toContain(
' 버전 1.1.0 (지금 직원이 받는 버전 1.2.0)',
);
expect(repository.rows).toHaveLength(2);
await expect(service.latestInfo('android')).resolves.toMatchObject({
version: '1.1.0',
});
});
it('지금 최신과 같으면 같다고 알리고 묻는다 · 높으면 묻지 않는다', async () => {
await existing('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
const same = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'n']);
await commands.upload(same, OPERATOR);
expect(same.output).toContain('과 같습니다.');
const higher = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.1.apk'), '', 'y']);
await commands.upload(higher, OPERATOR);
expect(higher.asked).not.toContain('그래도 올릴까요?');
expect(repository.rows).toHaveLength(2);
});
it('파일 검사에 걸리면 까닭을 알리고 버전을 묻지 않는다', async () => {
const cases: [string, string][] = [
[join(incoming, 'none.apk'), apkProblemText('missing')],
[dropApk('page.apk', Buffer.alloc(4096)), apkProblemText('not-zip')],
[dropApk('tiny.apk', APK_ZIP_SIGNATURE), apkProblemText('too-small')],
[dropApk('ARA-SYSTEM-1.0.0.zip'), apkProblemText('not-apk-name')],
];
for (const [path, text] of cases) {
const io = new ScriptedIo([path]);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(text);
expect(io.asked).toHaveLength(1);
}
const empty = new ScriptedIo(['']);
await commands.upload(empty, OPERATOR);
expect(empty.output).toContain('경로를 넣어 주세요.');
expect(audit.entries).toEqual([]);
});
it('검사한 뒤 원본이 바뀌면(묻는 사이 다시 복사) 올리지 않고 반쯤 쓴 파일도 남기지 않는다 — 실패를 실행 기록에', async () => {
const path = dropApk('ARA-SYSTEM-1.0.0.apk');
const io = new ScriptedIo([
path,
'',
() => {
// y/N 을 묻는 사이 같은 자리에 다른 내용을 쓴다
writeFileSync(path, apkBytes());
return 'y';
},
]);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(
' [못 함] 파일이 검사한 뒤 바뀌었습니다(SHA-256 · 크기가 다릅니다) — 올리지 않았습니다.',
);
expect(repository.rows).toEqual([]);
expect(stored()).toEqual([]);
expect(audit.entries).toEqual([
expect.objectContaining({
command: 'app-release-upload',
target: 'android 1.0.0',
result: 'failure',
detail: expect.stringMatching(
/^AppReleaseFileChangedError · \d+바이트 · sha256=[0-9a-f]{64} · 파일 /,
) as unknown,
}),
]);
});
it('표에 넣지 못하면 볼륨에 아무것도 남기지 않고 실패를 알린다(모르는 오류는 오류 로그에)', async () => {
repository.insertFailure = new TypeError('db down');
const io = new ScriptedIo([dropApk('ARA-SYSTEM-1.0.0.apk'), '', 'y']);
await commands.upload(io, OPERATOR);
expect(io.output).toContain(
' [못 함] 처리하지 못했습니다 — 바로 위의 오류 로그(ERROR 줄)를 확인하세요.',
);
expect(stored()).toEqual([]);
expect(errorLogs.join('\n')).toContain('[SYS_001]');
expect(audit.entries).toEqual([
expect.objectContaining({
result: 'failure',
detail: expect.stringContaining('TypeError · ') as unknown,
}),
]);
});
});
describe('목록', () => {
it('올린 것이 없으면 알린다', async () => {
const io = new ScriptedIo([]);
await commands.list(io);
expect(io.output).toContain('올린 휴대폰 앱이 없습니다.');
});
it('최근 판 — 버전 · 크기 · 올린 때 · 올린 이 · SHA-256 앞부분, 맨 위(▶)가 직원이 받는 판 · 파일이 없으면 알린다 (기록하지 않는다)', async () => {
const first = await existing(
'1.0.0',
new Date('2026-10-20T06:20:00.000Z'),
);
const second = await existing(
'1.0.1',
new Date('2026-11-02T01:23:00.000Z'),
);
rmSync(join(releaseDir, `${first.fileKey}.apk`));
const io = new ScriptedIo([]);
await commands.list(io);
expect(io.output).toContain(
' 휴대폰 앱 (안드로이드) — 최근 2개, ▶ 가 직원이 받는 버전(가장 늦게 올린 것)',
);
const lines = io.printed.filter((line) => line.includes('SHA-256'));
// 시각은 한국 시각으로 보인다 (시험을 도는 기계의 시간대와 상관없다)
expect(lines).toEqual([
`▶ 1.0.1 · 8 KB · 2026년 11월 2일 (월) 오전 10:23 · 먼저 올린 사람 · SHA-256 ${second.sha256.slice(0, 12)}…`,
` 1.0.0 · 8 KB · 2026년 10월 20일 (화) 오후 3:20 · 먼저 올린 사람 · SHA-256 ${first.sha256.slice(0, 12)}… · 파일 없음 — 다시 올리세요`,
]);
expect(audit.entries).toEqual([]);
});
it('읽지 못하면 알린다(모르는 오류는 오류 로그에)', async () => {
jest
.spyOn(service, 'listRecent')
.mockRejectedValueOnce(new TypeError('db down'));
const io = new ScriptedIo([]);
await commands.list(io);
expect(io.output).toContain('오류 로그');
expect(errorLogs.join('\n')).toContain('[SYS_001]');
});
});
});
@@ -0,0 +1,294 @@
import { Injectable } from '@nestjs/common';
import { MIN_SUPPORTED_APP_VERSION } from '@/modules/app-config/app-config.constants';
import type {
ApkInspection,
ApkProblem,
InspectedApk,
} from '@/modules/app-releases/apk-file';
import { AppReleaseFileChangedError } from '@/modules/app-releases/app-release-storage';
import {
appReleaseFileName,
compareAppVersions,
isAppVersion,
versionInFileName,
} from '@/modules/app-releases/app-release-version';
import type { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import {
APK_MAX_BYTES,
APK_MIN_BYTES,
APP_RELEASE_LIST_LIMIT,
type AppReleasePlatform,
} from '@/modules/app-releases/app-releases.constants';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import { OpsAuditService } from './ops-audit.service';
import type { OpsIo } from './ops-io';
import { RULE, confirmPlan, failureLine, step } from './ops-prompts';
import { bytesText, failureDetail, timeText } from './ops-text';
/** 올리는 플랫폼 — 지금은 안드로이드만 */
const PLATFORM: AppReleasePlatform = 'android';
/** 목록 줄의 SHA-256 앞부분 길이 */
const SHA256_PREFIX_LENGTH = 12;
/** 천 단위 쉼표 — 기계 설정과 상관없이 같은 글 */
const COUNT_FORMAT = new Intl.NumberFormat('en-US');
/** 버전 규칙 안내 */
export const APP_VERSION_RULE_TEXT =
'버전은 1.0.0 처럼 숫자 셋을 점으로 이어 넣어 주세요(칸마다 1~4자리 · 앞자리 0 없이).';
/** 검사에 걸린 까닭 → 운영자에게 보일 글 (무엇이 왜 안 되는지와 다음에 할 일) */
export function apkProblemText(problem: ApkProblem): string {
switch (problem) {
case 'not-apk-name':
return '파일 이름이 .apk 로 끝나지 않습니다.';
case 'missing':
return '그 경로에 파일이 없습니다 — sudo docker cp 로 이 컨테이너 안에 넣었는지, 경로가 맞는지 확인하세요.';
case 'symlink':
return '바로가기(심볼릭 링크)는 따라가지 않습니다 — 파일 자체를 docker cp 로 넣어 주세요.';
case 'not-file':
return '일반 파일이 아닙니다(폴더 · 장치 등).';
case 'unreadable':
return '파일을 읽을 권한이 없습니다 — 서버에서 chmod 644 로 바꾼 뒤 다시 docker cp 하세요.';
case 'too-small':
return `파일이 너무 작습니다(${bytesText(APK_MIN_BYTES)} 미만) — APK 가 아닙니다.`;
case 'too-large':
return `파일이 너무 큽니다(${bytesText(APK_MAX_BYTES)} 초과) — 올리지 않습니다.`;
case 'not-zip':
return 'APK 가 아닙니다 — 파일 머리가 zip(PK) 이 아닙니다.';
case 'changed':
return '읽는 동안 파일 크기가 바뀌었습니다 — 복사가 끝난 뒤 다시 하세요.';
}
}
/** 바이트 수 — "32,505,856바이트" */
function byteCount(bytes: number): string {
return `${COUNT_FORMAT.format(bytes)}바이트`;
}
/** 실행 기록의 덧붙이는 말 — 판 id · 크기 · SHA-256 을 앞에(길면 넣은 경로 쪽이 잘린다). 비밀은 없다 */
function uploadDetail(
apk: InspectedApk,
release: AppReleaseEntity | null,
failure: string | null,
): string {
return [
...(failure === null ? [] : [failure]),
...(release === null ? [] : [`id=${release.id}`]),
`${apk.sizeBytes}바이트`,
`sha256=${apk.sha256}`,
`파일 ${apk.path}`,
].join(' · ');
}
/** 목록의 한 줄 */
export function releaseLine(
release: AppReleaseEntity,
hasFile: boolean,
isCurrent: boolean,
): string {
return [
`${isCurrent ? '▶' : ' '} ${release.version}`,
bytesText(release.sizeBytes),
timeText(release.createdAt),
release.uploadedBy,
`SHA-256 ${release.sha256.slice(0, SHA256_PREFIX_LENGTH)}…`,
...(hasFile ? [] : ['파일 없음 — 다시 올리세요']),
].join(' · ');
}
/**
* 운영 도구 — 휴대폰 앱 (사내 배포 — 계약 1절 "휴대폰 앱 받기" · 6절 "운영 도구"): 올리기 · 목록.
* 올리기는 운영자가 `sudo docker cp` 로 컨테이너 안에 넣은 APK 를 검사해 업로드 볼륨과 표에 들이고 실행 기록을 남긴다(넣은 경로는 감사용으로 남긴다).
* 목록은 읽기만 한다(기록하지 않는다). 비밀은 다루지 않는다
*/
@Injectable()
export class AppReleaseCommands {
constructor(
private readonly releases: AppReleasesService,
private readonly audit: OpsAuditService,
) {}
/**
* 휴대폰 앱 올리기 — 경로 → 검사(일반 파일 · .apk · zip 머리 · 1KB~200MB · SHA-256) → 버전(파일 이름의 x.y.z 를 기본값으로) →
* 서버가 받는 가장 낮은 버전보다 낮으면 하지 않음 · 지금 최신보다 낮거나 같으면 y/N → 바뀔 것(버전 · 크기 · SHA-256) y/N → 들이기 → 실행 기록.
* 끝에 SHA-256 을 한 번 더 보여 빌드한 파일과 견주게 한다
*/
async upload(io: OpsIo, operator: string): Promise<void> {
io.print(
'서버에서 APK 를 먼저 이 컨테이너 안에 넣어 두세요 — sudo docker cp <파일> <PROJECT_NAME>-backend:/tmp/',
);
const path = await io.ask(
'컨테이너 안 APK 경로 (예: /tmp/ARA-SYSTEM-1.0.0.apk): ',
);
if (path === '') {
io.print('경로를 넣어 주세요.');
return;
}
let inspection: ApkInspection;
try {
inspection = await this.releases.inspect(path);
} catch (error) {
io.print(failureLine(error));
return;
}
if (!inspection.ok) {
io.print(apkProblemText(inspection.problem));
return;
}
const { apk } = inspection;
try {
await this.uploadInspected(io, operator, apk);
} finally {
await apk.close();
}
}
/** 휴대폰 앱 목록 — 최근 10개, 맨 위가 직원이 받는 버전 (읽기만 — 기록하지 않는다) */
async list(io: OpsIo): Promise<void> {
try {
const listings = await this.releases.listRecent(
PLATFORM,
APP_RELEASE_LIST_LIMIT,
);
if (listings.length === 0) {
io.print('올린 휴대폰 앱이 없습니다.');
return;
}
io.print('');
io.print(RULE);
io.print(
` 휴대폰 앱 (안드로이드) — 최근 ${listings.length}개, ▶ 가 직원이 받는 버전(가장 늦게 올린 것)`,
);
io.print(RULE);
listings.forEach(({ release, hasFile }, index) =>
io.print(releaseLine(release, hasFile, index === 0)),
);
io.print(RULE);
} catch (error) {
io.print(failureLine(error));
}
}
private async uploadInspected(
io: OpsIo,
operator: string,
apk: InspectedApk,
): Promise<void> {
const version = await this.askVersion(io, apk.fileName);
if (version === null) return;
const minimum = MIN_SUPPORTED_APP_VERSION[PLATFORM];
if (compareAppVersions(version, minimum) < 0) {
io.print(
`서버가 받는 가장 낮은 버전은 ${minimum} 입니다 — 그보다 낮은 버전은 올리지 않습니다(깔자마자 업데이트 화면이 뜹니다).`,
);
return;
}
const latest = await this.releases.latest(PLATFORM);
if (latest !== null && !(await this.confirmNotNewer(io, version, latest))) {
return;
}
const lines = [
`파일 ${apk.path}`,
`버전 ${version}${latest === null ? ' (처음 올리는 앱)' : ` (지금 직원이 받는 버전 ${latest.version})`}`,
`크기 ${bytesText(apk.sizeBytes)} (${byteCount(apk.sizeBytes)})`,
`SHA-256 ${apk.sha256}`,
`받는 이름 ${appReleaseFileName(version)}`,
'올리면 바로 직원이 받는 버전이 된다 — 웹메일 설정 › 휴대폰 앱 · 휴대폰의 <포털>/app',
];
if (!(await confirmPlan(io, '휴대폰 앱 올리기', lines))) return;
try {
const release = await this.releases.publish(apk, {
platform: PLATFORM,
version,
uploadedBy: operator,
});
step(
io,
true,
`올렸습니다 — 버전 ${version} · ${bytesText(apk.sizeBytes)}. 직원은 지금부터 이 버전을 받습니다.`,
);
this.showChecksum(io, version, apk.sha256);
await this.audit.record({
operator,
command: 'app-release-upload',
target: `${PLATFORM} ${version}`,
result: 'success',
detail: uploadDetail(apk, release, null),
});
} catch (error) {
step(
io,
false,
error instanceof AppReleaseFileChangedError
? '파일이 검사한 뒤 바뀌었습니다(SHA-256 · 크기가 다릅니다) — 올리지 않았습니다. 복사가 끝난 뒤 다시 하세요.'
: failureLine(error),
);
await this.audit.record({
operator,
command: 'app-release-upload',
target: `${PLATFORM} ${version}`,
result: 'failure',
detail: uploadDetail(apk, null, failureDetail(error)),
});
}
}
/** 버전을 묻는다 — 빈 값이면 파일 이름의 버전. 규칙에 맞지 않으면 알리고 null */
private async askVersion(
io: OpsIo,
fileName: string,
): Promise<string | null> {
const suggested = versionInFileName(fileName);
const answer = await io.ask(
suggested === null
? '버전 (예: 1.0.0): '
: `버전 (빈 값이면 파일 이름의 ${suggested}): `,
);
const version = answer === '' ? suggested : answer;
if (version === null || !isAppVersion(version)) {
io.print(APP_VERSION_RULE_TEXT);
return null;
}
return version;
}
/** 넣은 버전이 지금 최신보다 높지 않으면 알리고 묻는다 — 높으면 묻지 않고 true */
private async confirmNotNewer(
io: OpsIo,
version: string,
latest: AppReleaseEntity,
): Promise<boolean> {
const order = compareAppVersions(version, latest.version);
if (order > 0) return true;
io.print(
`주의: 넣은 버전(${version})이 지금 직원이 받는 버전(${latest.version} · ${timeText(latest.createdAt)} 올림)${order === 0 ? '과 같습니다' : '보다 낮습니다'}.`,
);
io.print(
order === 0
? ' 올리면 이 파일이 직원이 받는 파일이 됩니다(같은 버전을 고쳐 다시 빌드했을 때).'
: ' 올리면 이 파일이 직원이 받는 버전이 됩니다(되돌릴 때 쓰는 길). 이미 더 높은 버전을 깐 휴대폰은 안드로이드가 낮은 버전으로 덮어 깔지 않아, 앱을 지우고 다시 깔아야 합니다(로그인도 다시).',
);
const yes = await io.confirm('그래도 올릴까요?');
if (!yes) io.print('하지 않았습니다.');
return yes;
}
/** 끝에 SHA-256 을 한 번 더 — 빌드한 PC 의 값과 견주게 한다 */
private showChecksum(io: OpsIo, version: string, sha256: string): void {
const fileName = appReleaseFileName(version);
io.print('');
io.print(RULE);
io.print(` SHA-256 ${sha256}`);
io.print(' 빌드한 PC 에서 같은 값인지 견주세요.');
io.print(` Windows certutil -hashfile ${fileName} SHA256`);
io.print(` macOS · 리눅스 shasum -a 256 ${fileName}`);
io.print(
' 컨테이너에 넣은 파일은 이제 지워도 됩니다 — 볼륨에 사본이 있습니다(mail/README "휴대폰 앱 올리기").',
);
io.print(RULE);
}
}
@@ -31,7 +31,9 @@ export type OpsCommand =
/** 메일 이전 › 이전용 비밀번호 다시 만들기 */
| 'migration-password'
/** 메일 이전 › 이전 마치기 — 직원용 임시 비밀번호 */
| 'migration-finish';
| 'migration-finish'
/** 휴대폰 앱 올리기 (사내 배포 — 2026-11-08) — 대상은 `android <버전>`, detail 에 판 id · 크기 · SHA-256 · 넣은 경로 */
| 'app-release-upload';
/**
* 비밀번호를 거는 명령 — 메일 이전 중 판정이 그 주소의 마지막 기록(성공 · 이전용 명령의 실패 — ops-data.service.ts 의 countsForMigration)을 본다.
@@ -75,7 +77,7 @@ export class OpsAuditLogEntity {
@Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH })
operator!: string;
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish) */
/** 명령 — OpsCommand (hire · hire-migration · password-reset · … · migration-password · migration-finish · app-release-upload) */
@Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH })
command!: string;
+9 -1
View File
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { AppReleaseCommands } from './app-release-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
@@ -21,7 +22,7 @@ const OPERATOR_CONTROL = /[\u0000-\u001f]/;
/** 관리자 비밀번호를 다시 묻는 횟수 */
const SIGN_IN_ATTEMPTS = 3;
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열한 개와 차례 그대로) */
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열세 개와 차례 그대로) */
export const OPS_MENU = [
['1', '입사'],
['2', '비밀번호 재설정'],
@@ -34,6 +35,8 @@ export const OPS_MENU = [
['9', '현황'],
['10', '정책 · 설정'],
['11', '메일 이전'],
['12', '휴대폰 앱 올리기'],
['13', '휴대폰 앱 목록'],
['0', '끝내기'],
] as const;
@@ -51,6 +54,7 @@ export class OpsCli {
private readonly admin: AdminCommands,
private readonly migration: MigrationCommands,
private readonly migrationPassword: MigrationPasswordSession,
private readonly appReleases: AppReleaseCommands,
) {}
/** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */
@@ -207,6 +211,10 @@ export class OpsCli {
return this.admin.policy(io, operator);
case '11':
return this.migration.menu(io, operator);
case '12':
return this.appReleases.upload(io, operator);
case '13':
return this.appReleases.list(io);
default:
io.print('메뉴의 번호를 넣어 주세요.');
}
+28 -1
View File
@@ -1,5 +1,6 @@
import { Logger } from '@nestjs/common';
import type { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type { SystemMailService } from '@/modules/system-mail/system-mail.service';
@@ -11,6 +12,7 @@ import {
import { kstDateString } from '@/shared/utils/kst-date';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { AppReleaseCommands } from './app-release-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
@@ -337,13 +339,24 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
let locks: LockCommands;
let admin: AdminCommands;
let migration: MigrationCommands;
/** 휴대폰 앱 — 올리기 · 목록의 자세한 시험은 app-release-commands.spec.ts (여기서는 메뉴만) */
let appReleaseService: { listRecent: jest.Mock };
let appReleases: AppReleaseCommands;
/** 엔진 · 서비스가 불린 차례 (한 줄로) */
const order = (): string[] => [...engine.calls];
/** 도구 한 번 실행 */
const cli = () =>
new OpsCli(engine, accounts, locks, admin, migration, migrationPassword);
new OpsCli(
engine,
accounts,
locks,
admin,
migration,
migrationPassword,
appReleases,
);
let errorLogs: string[];
let warnLogs: string[];
@@ -509,6 +522,11 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
audit as unknown as OpsAuditService,
migrationPassword,
);
appReleaseService = { listRecent: jest.fn().mockResolvedValue([]) };
appReleases = new AppReleaseCommands(
appReleaseService as unknown as AppReleasesService,
audit as unknown as OpsAuditService,
);
});
afterEach(() => jest.restoreAllMocks());
@@ -2612,6 +2630,15 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
expect(io.output).toContain('2단계 인증이 켜져 있어');
});
it('메뉴 12 · 13 — 휴대폰 앱 올리기 · 목록 (메뉴는 명령 열셋 + 끝내기)', async () => {
const io = new ScriptedIo([OPERATOR, 'pw', '13', '0']);
await expect(cli().run(io)).resolves.toBe(0);
expect(io.output).toContain('12. 휴대폰 앱 올리기');
expect(io.output).toContain('13. 휴대폰 앱 목록');
expect(io.output).toContain('올린 휴대폰 앱이 없습니다.');
expect(appReleaseService.listRecent).toHaveBeenCalledWith('android', 10);
});
it('입력이 끝나면(Ctrl+D) 조용히 끝낸다', async () => {
await expect(cli().run(new ScriptedIo([OPERATOR, 'pw']))).resolves.toBe(
0,
+14
View File
@@ -3,6 +3,10 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
import { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import { AppReleasesRepository } from '@/modules/app-releases/app-releases.repository';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import { AuthSessionModule } from '@/modules/auth/auth-session.module';
import { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository';
import { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
@@ -39,6 +43,7 @@ import { buildDatabaseOptions } from '@/shared/config/database.config';
import { validateEnv } from '@/shared/config/env.validation';
import { AccountCommands } from './account-commands';
import { AdminCommands } from './admin-commands';
import { AppReleaseCommands } from './app-release-commands';
import { LockCommands } from './lock-commands';
import { MigrationCommands } from './migration-commands';
import { MigrationPasswordSession } from './migration-password';
@@ -85,6 +90,7 @@ const OPS_DB_POOL = {
StoredFileChunkEntity,
ShareLinkEntity,
ShareLinkFileEntity,
AppReleaseEntity,
]),
// JmapClient 가 쓰는 캐시 (세션 기억)
CacheModule.register({ isGlobal: true }),
@@ -132,6 +138,13 @@ const OPS_DB_POOL = {
provide: SignatureImageStorage,
useFactory: () => new SignatureImageStorage(),
},
// 휴대폰 앱 — 올리기 · 목록 (서버와 같은 표 · 같은 업로드 볼륨 폴더)
AppReleasesRepository,
{
provide: AppReleaseStorage,
useFactory: () => new AppReleaseStorage(),
},
AppReleasesService,
OpsDataService,
OpsAuditService,
// 이번 실행의 이전용 비밀번호 — 이 프로세스의 메모리에만(한 실행 = 한 프로세스), 끝낼 때 OpsCli 가 지운다
@@ -140,6 +153,7 @@ const OPS_DB_POOL = {
LockCommands,
AdminCommands,
MigrationCommands,
AppReleaseCommands,
OpsCli,
],
})
@@ -0,0 +1,27 @@
import { downloadHeaders } from './download-headers';
describe('downloadHeaders — 내려받기 응답의 머리글 (대용량 첨부 · 휴대폰 앱 공용)', () => {
it('형식 · 이름 · 검증자만 부르는 쪽이 정하고, 나머지는 늘 같다 (쓰는 차례 그대로)', () => {
const lastModified = new Date('2026-11-18T01:23:45.000Z');
expect(
downloadHeaders({
contentType: 'application/vnd.android.package-archive',
contentDisposition: 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
etag: '"3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d"',
lastModified,
}),
).toEqual([
['Content-Type', 'application/vnd.android.package-archive'],
['Content-Disposition', 'attachment; filename="ARA-SYSTEM-1.0.0.apk"'],
['X-Content-Type-Options', 'nosniff'],
['Content-Security-Policy', "default-src 'none'; sandbox"],
['Cache-Control', 'private, no-store'],
['Referrer-Policy', 'no-referrer'],
['X-Robots-Tag', 'noindex'],
['Accept-Ranges', 'bytes'],
['ETag', '"3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d"'],
['Last-Modified', 'Wed, 18 Nov 2026 01:23:45 GMT'],
['X-Accel-Buffering', 'no'],
]);
});
});
@@ -0,0 +1,44 @@
/**
* 파일 내려받기 응답의 머리글 (순수 함수) — 대용량 첨부 받기(11단계 — 계약 4절 "파일 응답")와 휴대폰 앱 받기(계약 1절 "휴대폰 앱 받기")가 함께 쓴다.
* 두 받기가 같은 꼴이어야 한다 — 한쪽만 고치지 않게 여기 한 곳에 둔다.
*
* 늘 내려받기(attachment) · 형식 짐작 막기(nosniff) · 열어도 아무것도 하지 못하게(sandbox CSP) · 남기지 않음(private, no-store) ·
* 나가는 요청에 주소를 싣지 않음 · 검색에 걸리지 않음 · 범위 요청을 받음 · 앞단 nginx · 외부 프록시가 임시 파일에 받아 두지 않음.
* 형식 · 이름 · 검증자(ETag · Last-Modified)만 부르는 쪽이 정한다 — 이름은 서버가 만든 값만 넣는다
*/
/** 파일을 열어도 스크립트 · 하위 요청이 돌지 않게 */
export const DOWNLOAD_CSP = "default-src 'none'; sandbox";
/** 공유 캐시 · 브라우저에 남기지 않는다 */
export const DOWNLOAD_CACHE_CONTROL = 'private, no-store';
/** 내려줄 파일 */
export interface DownloadDescriptor {
/** 형식 — 서버가 정한다(받은 값 · 파일 이름으로 짐작하지 않는다) */
readonly contentType: string;
/** `attachment; …` — 늘 내려받기 */
readonly contentDisposition: string;
/** 강한 ETag (따옴표 포함 — `"<id>"`) — 이어 받기의 If-Range 와 견준다 */
readonly etag: string;
readonly lastModified: Date;
}
/** 머리글 이름 · 값 — 쓰는 차례 그대로 (`res.setHeader` 로 하나씩 쓴다) */
export function downloadHeaders(
file: DownloadDescriptor,
): readonly (readonly [string, string])[] {
return [
['Content-Type', file.contentType],
['Content-Disposition', file.contentDisposition],
['X-Content-Type-Options', 'nosniff'],
['Content-Security-Policy', DOWNLOAD_CSP],
['Cache-Control', DOWNLOAD_CACHE_CONTROL],
['Referrer-Policy', 'no-referrer'],
['X-Robots-Tag', 'noindex'],
['Accept-Ranges', 'bytes'],
['ETag', file.etag],
['Last-Modified', file.lastModified.toUTCString()],
// 앞단 nginx · 외부 프록시가 이 응답을 임시 파일에 받아 두지 않게 (느린 받는 사람마다 파일 크기만큼)
['X-Accel-Buffering', 'no'],
];
}
+186 -1
View File
@@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
import { existsSync, mkdtempSync, rmSync, unlinkSync } from 'fs';
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { connect } from 'net';
import { tmpdir } from 'os';
@@ -29,6 +29,10 @@ import {
ACCOUNT_SECURITY_SETTINGS,
type AccountSecuritySettings,
} from '@/modules/account-security/account-security.settings';
import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage';
import { AppReleaseEntity } from '@/modules/app-releases/app-release.entity';
import { APK_ZIP_SIGNATURE } from '@/modules/app-releases/app-releases.constants';
import { AppReleasesService } from '@/modules/app-releases/app-releases.service';
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
@@ -1930,6 +1934,9 @@ describe('응답 계약 (e2e)', () => {
// 앱 1단계 — 앱 알림은 휴대폰으로 보내지 않고 기록만 (FCM 키 · 바깥 연결 없이)
.overrideProvider(PUSH_SENDER)
.useValue(fakePushSender)
// 휴대폰 앱 받기 — APK 파일은 이 시험의 임시 폴더에 (개발 업로드 볼륨에 남기지 않는다)
.overrideProvider(AppReleaseStorage)
.useValue(new AppReleaseStorage(join(fileDir, 'app-releases')))
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -10934,6 +10941,184 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기"). 판은 운영 도구처럼 서비스로 올린다(검사 → 볼륨 · 표 — 파일은 이 시험의 임시 폴더).
* 표 app_releases 는 직원 주소로 나뉘지 않는다 — 시작할 때 있던 줄(컨테이너 안에서 돌리면 개발 DB)을 받아 두고 시험마다 비운 뒤, 끝나면 되돌린다
*/
describe('휴대폰 앱 받기 — 사내 배포 (/api/app-releases/android/latest · /file)', () => {
const ME = 'app.release@e2e.arasystem.test';
const INFO = '/api/app-releases/android/latest';
const FILE = '/api/app-releases/android/latest/file';
const APK_TYPE = 'application/vnd.android.package-archive';
const content = Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(64 * 1024)]);
let releases: AppReleasesService;
/** 시작할 때 있던 줄 — 끝나면 되돌린다. 준비가 실패했으면 없다 */
let saved: AppReleaseEntity[] | undefined;
/** 운영자가 docker cp 로 넣는 자리 흉내 */
let incoming = '';
const table = () => dataSource.getRepository(AppReleaseEntity);
const clearTable = () => dataSource.query('DELETE FROM app_releases');
const as = (test: HttpTest) => test.set('Cookie', cookieOf(ME));
const download = (range?: string, ifRange?: string) => {
const test = as(request(server).get(FILE)).responseType('blob');
if (range) void test.set('Range', range);
if (ifRange) void test.set('If-Range', ifRange);
return test;
};
/** 운영 도구처럼 올린다 — 넣은 APK 를 검사해(연 파일) 볼륨 · 표에 */
const publish = async (version: string, at: Date) => {
const path = join(incoming, `ARA-SYSTEM-${version}-${randomUUID()}.apk`);
writeFileSync(path, content);
const inspection = await releases.inspect(path);
if (!inspection.ok) throw new Error(inspection.problem);
try {
return await releases.publish(
inspection.apk,
{ platform: 'android', version, uploadedBy: 'e2e 운영자' },
at,
);
} finally {
await inspection.apk.close();
}
};
beforeAll(async () => {
releases = app.get(AppReleasesService, { strict: false });
saved = await table().find();
incoming = mkdtempSync(join(tmpdir(), 'arasystem-e2e-apk-'));
});
beforeEach(clearTable);
afterAll(async () => {
if (saved) {
await clearTable();
if (saved.length > 0) await table().insert(saved);
}
if (incoming) rmSync(incoming, { recursive: true, force: true });
});
it('로그인하지 않으면 401 AUTH_001 (정보 · 파일)', async () => {
await publish('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
for (const path of [INFO, FILE]) {
const res = await request(server).get(path).expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('올린 판이 없으면 404 RES_001 (정보 · 파일 — 파일도 표준 실패 형식)', async () => {
for (const path of [INFO, FILE]) {
const res = await as(request(server).get(path)).expect(404);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('RES_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
});
it('정보 200 { version, sizeBytes, releasedAt } · private, no-store — 최신 = 가장 늦게 올린 판(버전 번호가 아니다)', async () => {
await publish('1.2.0', new Date('2026-11-01T01:00:00.000Z'));
await publish('1.1.0', new Date('2026-11-02T01:23:45.000Z'));
const res = await as(request(server).get(INFO)).expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(body(res).data).toEqual({
version: '1.1.0',
sizeBytes: content.length,
releasedAt: '2026-11-02T01:23:45.000Z',
});
});
it('파일 200 — APK 형식 · ARA-SYSTEM-<버전>.apk · 대용량 받기와 같은 머리글, 바이트 그대로 · HEAD 는 머리글만', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-01T01:00:00.000Z'),
);
const res = await download().expect(200);
expect(res.headers).toMatchObject({
'content-type': APK_TYPE,
'content-disposition': 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
'content-length': String(content.length),
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
'referrer-policy': 'no-referrer',
'x-robots-tag': 'noindex',
'accept-ranges': 'bytes',
etag: `"${release.id}"`,
'last-modified': release.createdAt.toUTCString(),
'x-accel-buffering': 'no',
});
expect((res.body as Buffer).equals(content)).toBe(true);
const head = await as(request(server).head(FILE)).expect(200);
expect(head.headers['content-type']).toBe(APK_TYPE);
expect(head.headers['content-length']).toBe(String(content.length));
});
it('범위 — 하나면 206 · 끝 N 바이트 · 처음이 크기 이상이면 416(본문 없음) · If-Range 가 다르면 200 전체 · 같으면 이어 받기', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-01T01:00:00.000Z'),
);
const part = await download('bytes=0-99').expect(206);
expect(part.headers['content-range']).toBe(
`bytes 0-99/${content.length}`,
);
expect(part.headers['content-length']).toBe('100');
expect((part.body as Buffer).equals(content.subarray(0, 100))).toBe(true);
const tail = await download('bytes=-16').expect(206);
expect((tail.body as Buffer).equals(content.subarray(-16))).toBe(true);
const beyond = await download(`bytes=${content.length}-`).expect(416);
expect(beyond.headers['content-range']).toBe(`bytes */${content.length}`);
expect(beyond.headers['content-length']).toBe('0');
const stale = await download('bytes=0-99', '"other"').expect(200);
expect((stale.body as Buffer).length).toBe(content.length);
const resumed = await download('bytes=100-', `"${release.id}"`).expect(
206,
);
expect((resumed.body as Buffer).equals(content.subarray(100))).toBe(true);
});
it('표에는 있는데 볼륨에 파일이 없으면 파일은 404 RES_001 (정보는 그대로 — 운영자가 다시 올린다)', async () => {
const release = await publish(
'1.0.0',
new Date('2026-11-01T01:00:00.000Z'),
);
unlinkSync(join(fileDir, 'app-releases', `${release.fileKey}.apk`));
const res = await as(request(server).get(FILE)).expect(404);
expect(body(res).error?.code).toBe('RES_001');
await as(request(server).get(INFO)).expect(200);
});
it('제한된 세션(새 비밀번호를 정하기 전)은 403 AUTH_010 — 허용 목록에 넣지 않는다', async () => {
await publish('1.0.0', new Date('2026-11-01T01:00:00.000Z'));
const RESTRICTED = 'app.release.expired@e2e.arasystem.test';
expiredPasswords.add(RESTRICTED);
try {
const login = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.40.1')
.send({ email: RESTRICTED, password: CORRECT_PASSWORD })
.expect(200);
expect(body(login).data).toMatchObject({
restriction: 'passwordChange',
});
const cookie = `access_token=${cookieValue(setCookie(login, 'access_token'))}`;
for (const path of [INFO, FILE]) {
const res = await request(server)
.get(path)
.set('Cookie', cookie)
.expect(403);
expect(body(res).error?.code).toBe('AUTH_010');
}
} finally {
expiredPasswords.delete(RESTRICTED);
}
});
});
describe('출처 검사 (상태 변경 요청)', () => {
const EVIL_ORIGIN = 'https://evil.example';
+13 -6
View File
@@ -5,7 +5,8 @@
## 앱 1단계 (2026-11-05 확정 — `App1Login` · `App1MailList` · `App1Reader` · `App1Compose` · `App1Shell`)
첫 판은 안드로이드 먼저(Play 내부 테스트), 로그인 전체 + 메일 기본 + 새 메일 알림이다. iOS 는 맥 · Apple 개발자 계정을 준비한 뒤.
첫 판은 안드로이드 먼저, 로그인 전체 + 메일 기본 + 새 메일 알림이다. iOS 는 맥 · Apple 개발자 계정을 준비한 뒤.
나눠 주는 길은 사내 직접 배포다(2026-11-08 — 스토어에 올리지 않는다): 우리 키로 서명한 APK 를 웹메일에 로그인한 직원이 포털의 휴대폰 앱 받기 화면(`<포털>/app` — 웹 보드 `SettingsMobileApp`)에서 받는다. 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포".
### 크기 · 글꼴 (웹과 다른 점)
- 누르는 곳 48(웹 44). 예외는 받는 사람 칩의 ✕ 40 하나(밀도 예외 — 사용자 확정).
@@ -19,7 +20,8 @@
- 탭마다 이동 기록을 따로 지킨다(메일에서 읽던 화면이 설정 탭에 다녀와도 남는다). 읽기 · 쓰기는 탭 막대 없는 전체 화면이다.
- 여러 통 고르기 중에는 탭 막대를 숨긴다(잠깐의 상태이고, 다른 탭으로 가면 고른 것을 잃는다). ✕ · 뒤로 가기로 나오면 다시 보인다.
- 화면 낭독기 이름은 "앱 메뉴".
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
[새 버전 받기](2026-11-08 — `App1Push` ④ 앱 정보 절)는 끝 아이콘이 바깥으로 여는 아이콘이고, 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 브라우저로 연다(같은 실패 알림).
[로그아웃]은 데이터를 지우는 일이 아니라 회색 테두리 단추이고, 누르면 확인 창(처음 초점 [취소]) — 로그아웃하면 이 휴대폰의 새 메일 알림도 오지 않는다고 알린다.
### 로그인 (`App1Login`) — 네 단계
@@ -248,7 +250,7 @@
(2차) 자판과 함께 시트가 올라가고 초점 칸이 보이게 스크롤되는지 · 가로 화면 + 자판, 기간 메뉴가 뜨는 자리, 날짜 창의 한국어 머리와 연필(입력) 모드에서 오늘 뒤 날짜를 막는지,
뒤로 가기 · 끌어 닫기 · 가림막에서 고친 값을 버리는지와 닫은 뒤 자판, TalkBack([조건] 펼침 · 기간 단추 · 날짜 칸 값과 오류 · 단추 줄 위 알림 · 닫은 뒤 초점), 큰 글자의 날짜 칸 · 여러 줄 조건 줄,
바뀐 가림막 색(옮기기 시트 포함), 14단계 서버에서 조건만 · 검색어 + 조건(시간대 · 첨부) · from/to 를 실은 강조.
### 새 메일 알림 · 업데이트 · 앱 아이콘 (`App1Push` — 2026-11-07 확정)
### 새 메일 알림 · 업데이트 · 앱 아이콘 (`App1Push` — 2026-11-07 확정, ⑩ 업데이트 화면 · ④ 앱 정보 절은 2026-11-08 사내 직접 배포로 고쳐 확정 — 캔버스 v68)
- **알림 글은 "새 메일 N통"뿐**이다 — 보낸 사람 · 제목은 Google 서버를 거치지 않게 싣지 않는다(잠금 화면도 수만). 새 메일이 더 오면 같은 알림의 수만 바뀌고 쌓이지 않는다.
앱을 보고 있을 때는 띄우지 않는다 — 받은편지함이 보이면 그 자리에서 바로 새 메일을 확인한다. 앱으로 돌아오면 알림창의 새 메일 알림을 지운다.
누르면 늘 메일 탭의 받은편지함이다(읽기 · 쓰기 화면 위에 있었어도 — 쓰던 메일은 임시보관함에 마저 저장하고 알린다). 알림 데이터에는 모듈 이름과 종류만 싣는다.
@@ -279,20 +281,25 @@
- **앱 아이콘**(⑪ — 나안): 남색 #0B3640 바탕 + 흰 ARA 마크. 적응형(배경색 · 앞면 마크 · 안드로이드 13 테마 아이콘용 마크만 담은 그림 한 장 더)이고 마크는 잘리지 않는 가운데 칸 안에 둔다.
안드로이드 7(적응형 전)의 아이콘도 같은 모양이다. 삼성 휴대폰은 알림창에도 앱 아이콘을 쓴다.
- **업데이트 화면**(⑩): 이 앱의 판이 서버가 정한 최소 판보다 낮으면 로그인 전이든 메일을 보던 중이든 이 화면으로 온다(쓰던 메일은 임시보관함에 저장한다). 뒤로 가기는 앱을 닫는다.
[Play 스토어 열기]는 Play 스토어 앱부터, 열리지 않으면 웹 주소. 문의 줄은 로그인 화면과 같은 연락처다. 서버가 최소 판을 내리면 다음 확인(앱으로 돌아올 때 — 1시간마다)에서 원래 화면으로 돌아간다.
[새 버전 받기](2026-11-08 사내 직접 배포로 [Play 스토어 열기]를 바꿨다 — 캔버스 v68)는 포털의 휴대폰 앱 받기 화면 `<포털 출처>/app` 을 휴대폰의 브라우저로 연다.
포털 출처는 앱의 API 주소(`.env` 의 `API_URL`)에서 `/api` 를 뗀 출처다(같은 주소 배포 — 앱 설정 계약은 받는 곳 주소를 싣지 않는다). 받기 화면은 브라우저의 웹메일 로그인을 쓴다(앱의 토큰은 넘기지 않는다).
브라우저를 열지 못하면 "브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 {주소}에 들어가 새 버전을 받아 주세요."(주소는 스킴 없이 — `portal.arasystem.it.kr/app`). 설정 › 앱 정보의 [새 버전 받기]도 같은 화면 · 같은 알림이다.
문의 줄은 로그인 화면과 같은 연락처다("새 버전을 받거나 설치하기 어려우신가요?"). 서버가 최소 판을 내리면 다음 확인(앱으로 돌아올 때 — 1시간마다)에서 원래 화면으로 돌아간다.
- **로그아웃**: 확인 창이 이 휴대폰에 새 메일 알림도 오지 않는다고 알린다(`App1Shell`). 토큰을 지운 뒤 서버 logout 과 함께 이 휴대폰의 알림을 멈춘다(알림창 지우기 · FCM 등록 풀기 — 서로 기다리지 않는다).
서버 줄은 서버가 세션과 함께 지운다. 끊긴 망이라 FCM 등록을 풀지 못했으면 다음 실행 · 다음에 앱으로 돌아올 때 다시 풀고, 그사이 같은 휴대폰에서 다른 직원이 로그인하면 그 등록이 이어받는다(같은 FID 로 올려 서버의 앞 직원 줄이 지워진다).
- 글자는 테마 크기로 맞췄다(15.5 → 16 · 14 → 15 · 13.5 → 13 · 13 → 13 — 위 「쓰기」와 같은 정리).
- **구현하며 정한 것**(2026-11-07 — 보드에 없는 글 · 경우)
- 권하기 줄의 [알림 켜기]를 눌렀는데 새 메일 카테고리만 꺼져 있으면: "휴대폰 설정에서 새 메일 알림이 꺼져 있습니다." + [설정 열기](막혔을 때와 같은 꼴).
- 끄는 중의 화면 낭독기 글 "끄는 중"(보드에는 켜는 중만 있다).
- 문의 연락처를 정하지 않았거나 받지 못했을 때: 설정 › 문의 "앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요." · 업데이트 화면 "Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요."(로그인 화면의 문의 줄과 같은 꼴).
- 문의 연락처를 정하지 않았거나 받지 못했을 때: 설정 › 문의 "앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요." · 업데이트 화면 "새 버전을 받거나 설치하기 어려우신가요? 관리자에게 문의해 주세요."(로그인 화면의 문의 줄과 같은 꼴 — 2026-11-08 사내 직접 배포 글로).
- 받기 화면 주소를 만들지 못했을 때(앱의 API 주소가 http · https 주소가 아니다 — 그런 판은 서버에 닿지 못해 이 두 화면에 오지 않는다): [새 버전 받기]는 브라우저를 열지 않고 "브라우저를 열지 못했습니다. 관리자에게 문의해 주세요."(2026-11-08).
- 서버가 보낼 수 있는지 아직 모르면(앱 설정을 받기 전 · 받지 못함) 쓸 수 있는 것으로 본다 — 막지 않는다. 앱 설정은 앱을 띄우자마자 세션 되살리기와 나란히 묻는다.
- 알림의 N 은 앱을 마지막으로 본 때부터 센다 — 앱으로 돌아올 때 · 앱을 보는 중 새 메일 알림을 받을 때 "봤음"을 보낸다(30초에 한 번 · 기다리는 동안 뒤로 가면 그 자리에서). 보는 중에 받은 알림 뒤의 봤음은 받은 때부터 1.5초가 지나기 전에는 보내지 않는다(30초 간격 끝이 더 늦으면 그때 — 그사이 뒤로 가면 그 자리에서 보내 같은 초가 될 수 있다, 받아들였다)(2026-11-08 실기기 — 서버는 봤음의 초와 같은 초에 받은 메일도 세는데 받은 시각이 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어져 한 통이 더 많았다).
- **실기기 · 실제 Firebase 에서 볼 것**: FID 등록 소식(`onRegistered`)이 자동 초기화를 끈 채 오는지 · 앱 데이터를 지운 뒤 새 FID, 같은 알림을 바꿔 쓰는지(태그 `new-mail`)와 앱으로 돌아올 때 지워지는지,
앱을 보는 중에는 시스템 알림이 뜨지 않는지, 뒤에 있을 때 · 꺼져 있을 때 누른 알림이 받은편지함을 여는지, 두 번 거절한 뒤 스위치가 [설정 열기] 쪽으로 바뀌는지,
카테고리만 끈 상태를 읽는지 · [설정 열기]가 그 카테고리를 여는지(삼성 · Pixel), 상태 표시줄의 작은 아이콘 굵기 · 삼성 알림창의 앱 아이콘 · 테마 아이콘, 절전(Doze) 중 도착 시간,
Play 스토어가 없는 휴대폰의 [Play 스토어 열기], TalkBack(권하기 줄 묶음 · 스위치 이름과 설명 · 켜는 중 알림).
[새 버전 받기]가 여는 브라우저(삼성 인터넷 · Chrome — 웹메일에 로그인하지 않은 브라우저면 로그인 뒤 받기 화면으로 돌아오는지)와 받은 APK 를 쓰던 앱 위에 설치했을 때 로그인 · 알림 설정이 남는지,
TalkBack(권하기 줄 묶음 · 스위치 이름과 설명 · 켜는 중 알림).
- 2026-11-08 갤럭시 S23(Android 16)에서 본 것: 등록 소식(자동 초기화를 끈 채) · 같은 알림 바꿔 쓰기와 앱으로 돌아올 때 지우기 · 보는 중에는 그리지 않음 · 뒤에 있을 때와 꺼져 있을 때 누르면 받은편지함 · 작은 아이콘(잘 보인다) · 홈 화면 배지 · 바꿔 쓴 알림도 다시 소리를 냄 · 로그아웃 때 FCM 풀기(서버 줄은 서버가 지운다). 서버 쪽은 계약 1절 "실기기 · 실제 키로 확인할 것".
- 알려진 한계(2026-11-08): FID 로 등록하는 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그보다 옛 휴대폰은 SDK(firebase-messaging 26.0.1)가 옛 토큰 길로 돌아가 등록 소식(`onRegistered`)의 값이 FID 가 아닐 수 있고, 서버는 FID 모양만 받으므로 켜기가 실패할 수 있다(아직 고치지 않았다 — 등록에 드는 매니페스트 표시는 flutter/README "새 메일 알림").
+17 -5
View File
@@ -199,7 +199,7 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
<div style="flex: 0 0 828px; display: flex; flex-direction: column; gap: 12px">
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">4. 설정 › 새 메일 알림 줄</h3>
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">설정 탭의 다른 칸과 로그아웃 확인 창은 App1Shell 그대로입니다. 알림 줄만 상태마다 그렸습니다. 왼쪽은 스위치를 누를 수 있는 상태, 오른쪽은 누를 수 없는 상태입니다.</p>
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">설정 탭의 다른 칸과 로그아웃 확인 창은 App1Shell 그대로이고, 앱 정보 절에만 [새 버전 받기] 줄을 더했습니다. 알림 줄만 상태마다 그렸습니다. 왼쪽은 스위치를 누를 수 있는 상태, 오른쪽은 누를 수 없는 상태입니다.</p>
</div>
<div style="display: flex; gap: 48px; align-items: flex-start">
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 18px">
@@ -234,6 +234,18 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #667085; word-break: keep-all"><b style="font-weight: 700; color: #344054">설정을 열지 못했을 때</b> · [설정 열기]를 누른 뒤 뜨는 알림</p>
<div role="status" style="margin: 0 12px; display: flex; align-items: center; min-height: 52px; box-sizing: border-box; padding: 12px 16px; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; line-height: 1.5; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22); word-break: keep-all">휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #667085; word-break: keep-all"><b style="font-weight: 700; color: #344054">앱 정보 절</b> · 새로 더한 [새 버전 받기]는 업데이트 화면의 단추처럼 브라우저로 포털의 휴대폰 받기 화면을 엽니다</p>
<section aria-labelledby="about-h" style="padding-bottom: 8px; border-radius: 16px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 4px 12px rgba(16, 24, 28, 0.06)">
<h4 id="about-h" style="margin: 0; padding: 18px 20px 6px; font-size: 13px; font-weight: 600; color: #667085">앱 정보</h4>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 0 20px">
<span style="font-size: 15.5px">앱 버전</span>
<span style="font-size: 14px; color: #667085; font-variant-numeric: tabular-nums">1.0.0</span>
</div>
<button type="button" style="width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 0 12px 0 20px; border: 0; background: transparent; font: inherit; font-size: 15.5px; color: #171C26; text-align: left; cursor: pointer"><span>새 버전 받기</span><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; color: #667085"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></span></button>
<button type="button" style="width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 0 12px 0 20px; border: 0; background: transparent; font: inherit; font-size: 15.5px; color: #171C26; text-align: left; cursor: pointer"><span>오픈소스 라이선스</span><span aria-hidden="true" style="display: inline-flex; color: #667085"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg></span></button>
</section>
</div>
</div>
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 18px">
<sc-for list="{{ notifyRight }}" as="c" hint-placeholder-count="4">
@@ -542,15 +554,15 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
<main style="display: flex; flex-direction: column; gap: 20px; padding: 32px 20px 32px">
<div style="display: flex; flex-direction: column; gap: 10px">
<h1 style="margin: 0; font-size: 22px; line-height: 1.35; font-weight: 700; letter-spacing: -0.02em">앱을 업데이트해 주세요</h1>
<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #4A5363; word-break: keep-all">지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.</p>
<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #4A5363; word-break: keep-all">지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. 새 버전을 받아 설치한 뒤 다시 열어 주세요.</p>
</div>
<button type="button" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; border: 0; border-radius: 10px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 15.5px; font-weight: 600; cursor: pointer"><span>Play 스토어 열기</span><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></button>
<button type="button" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; border: 0; border-radius: 10px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 15.5px; font-weight: 600; cursor: pointer"><span>새 버전 받기</span><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></button>
<p style="margin: 0; font-size: 13.5px; color: #667085; font-variant-numeric: tabular-nums">지금 쓰는 버전 1.0.0</p>
<p style="margin: 0; padding-top: 16px; border-top: 1px solid #EEF0F3; font-size: 13.5px; line-height: 1.55; color: #667085; word-break: keep-all">Play 스토어에 업데이트가 보이지 않나요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></p>
<p style="margin: 0; padding-top: 16px; border-top: 1px solid #EEF0F3; font-size: 13.5px; line-height: 1.55; color: #667085; word-break: keep-all">새 버전을 받거나 설치하기 어려우신가요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></p>
</main>
</div>
<sc-if value="{{ updateFail }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position: absolute; left: 12px; right: 12px; bottom: 16px; display: flex; align-items: center; min-height: 52px; box-sizing: border-box; padding: 12px 16px; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; line-height: 1.5; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22); word-break: keep-all">Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.</div>
<div role="status" style="position: absolute; left: 12px; right: 12px; bottom: 16px; display: flex; align-items: center; min-height: 52px; box-sizing: border-box; padding: 12px 16px; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; line-height: 1.5; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22); word-break: keep-all">브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.</div>
</sc-if>
</div>
</div>
+1
View File
@@ -164,6 +164,7 @@ input::placeholder,textarea::placeholder{color:#8A94A3}
<span style="font-size: 15.5px">앱 버전</span>
<span style="font-size: 14px; color: #667085; font-variant-numeric: tabular-nums">1.0.0</span>
</div>
<button type="button" style="width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 0 12px 0 20px; border: 0; background: transparent; font: inherit; font-size: 15.5px; color: #171C26; text-align: left; cursor: pointer"><span>새 버전 받기</span><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; color: #667085"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></span></button>
<button type="button" style="width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 0 12px 0 20px; border: 0; background: transparent; font: inherit; font-size: 15.5px; color: #171C26; text-align: left; cursor: pointer"><span>오픈소스 라이선스</span><span aria-hidden="true" style="display: inline-flex; color: #667085"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg></span></button>
</section>
<div aria-hidden="true" style="height: 1px; margin: 8px 0 0; background: #EEF0F3"></div>
+87
View File
@@ -1091,6 +1091,79 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
같은 대화 칸 머리의 [모두 펼치기]와 펼친 줄 머리의 [답장]도 다음 후보로 둔다(2026-11-07 사용자 관점 점검에서 나온 제안 — 사용자가 미루기로 정함). 지금은 줄마다 펼치고, [답장]은 펼친 본문 아래에만 있다.
## 휴대폰 앱 받기 (2026-11-08 확정 — `SettingsMobileApp`)
안드로이드 앱(ARA SYSTEM — `flutter/`)은 스토어(Play)에 올리지 않고, 직접 빌드해 서명한 APK 를 **웹메일에 로그인한 직원만** 받게 한다.
PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 넘기고, 휴대폰 브라우저는 앱 틀 밖의 받기 화면 `/app`(B)을 연다.
앱의 업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기]도 `/app` 을 연다(앱은 API_URL 에서 `/api` 를 뗀 출처 + `/app` — 같은 주소 배포).
데이터는 서버 두 경로다(로그인 필요 · 제한된 세션은 막힘 — 계약 1절 "휴대폰 앱 받기 — 사내 배포"): 판 정보 `GET /api/app-releases/android/latest`
(`{ version, sizeBytes, releasedAt }` — 가장 늦게 올린 판, 없으면 404 `RES_001`)와 파일 `GET /api/app-releases/android/latest/file`
(APK — `attachment; filename="ARA-SYSTEM-<버전>.apk"` · no-store · Range). 판을 올리는 것은 서버의 운영 도구다(웹에 올리기 화면은 없다).
글의 원본은 화면 상수 `frontend/src/shared/constants/mobile-app.ts` 이고 아래는 옮겨 적은 것이다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
새 크기 토큰 · 밀도 예외는 없다(글자 크기 17 하나만 토큰으로 더했다 — 문서 끝 "휴대폰 앱 받기 구현하며 정한 보완"). 뱃지 · 최신 · NEW 같은 꼬리표는 달지 않는다.
### 설정 › 계정 › 휴대폰 앱 (`SettingsMobileApp` A)
- **메뉴**: 계정 묶음의 메일 프로그램 연결 다음 · 로그인 기록 앞 「휴대폰 앱」(아이콘은 이미 있는 휴대폰 그림 `smartphone`). 주소 `/settings/account/mobile-app`, 탭 제목 "휴대폰 앱 · ARA SYSTEM".
- 머리 "휴대폰 앱" + "안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다." 콘텐츠 열은 다른 설정 화면과 같은 800.
- **받을 앱 있음(A1)** — 「안드로이드 앱」(14 굵게) 아래 테두리 칸(모서리 10):
- 앱 줄(안쪽 16 · 사이 14): 앱 그림 48 · "ARA SYSTEM"(14 · 600) / "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB"(12.5 회색 · 숫자 고정폭) ·
오른쪽 [앱 내려받기](채운 단추 44 · 아이콘 15 — 화면 낭독은 크기까지 "앱 내려받기 (31 MB)").
- 가는 선 아래 QR 줄(사이 20): QR 160(흰 상자 · 안쪽 6 · 테두리) + 「휴대폰으로 받기」(13.5 · 600) /
"휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다." /
가는 선 아래 "QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요." + 주소 "portal.arasystem.it.kr/app"(14 · 600) /
"PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다."(12.5 회색).
QR 은 2단계 인증 켜기와 같은 `QrCodeImage`(160)이고, 지금 주소의 출처에 `/app` 을 붙인 주소만 담는다 — 비밀이 아니다(그림 이름 "휴대폰에서 앱을 받는 주소의 QR 코드").
- 「설치 방법」 — 번호 줄(청록 굵은 숫자 · 동그라미 없음, 2단계 인증 「켜는 차례」와 같은 꼴). 2번의 파일 이름은 받은 판의 버전이다:
1. 휴대폰에서 [앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 이 파일을 받을지 한 번 더 물으면 그대로 받아 주세요.
2. 받은 ‘ARA-SYSTEM-1.0.0.apk’ 파일을 열어 주세요. 처음 설치할 때는 휴대폰이 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 돌아와 설치를 이어 가 주세요.
3. Play 스토어를 거치지 않은 앱이라 Play 프로텍트가 경고할 수 있습니다. 웹메일에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.
4. 설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요. 앱 비밀번호는 만들지 않으셔도 됩니다.
- 「새 버전이 나오면」 — "새 버전도 같은 곳에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다." / "꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다."
- 아이폰 안내 상자(#F3F8F8 · 안내 아이콘): **"아이폰용 앱은 아직 없습니다"** / "아이폰에서는 브라우저로 웹메일을 쓰거나, 기본 메일 앱에 회사 메일을 연결해 주세요. 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다."
— 「메일 프로그램 연결」이 그 설정 화면 링크다.
- **받을 앱 없음(A2 — 404 `RES_001`, 알림 없음)**: 「안드로이드 앱」 아래 테두리 상자에 휴대폰 아이콘 24 · "아직 받을 수 있는 앱이 없습니다"(14 · 600) /
"앱이 준비되면 이곳에서 받아 설치할 수 있습니다." + 가는 선 아래 문의 줄. QR · 설치 방법 · 새 버전 · 아이폰 안내는 모두 뺀다.
- **문의 줄**: 로그인 화면의 문의 줄과 같은 꼴이다(연락처는 13단계 `GET /api/support-contact`). 연락처가 있으면 "언제 받을 수 있는지 궁금하신가요?" 다음 줄에 연락처,
없거나 받지 못하면 대신 "언제 받을 수 있는지는 관리자에게 문의해 주세요."
- **좁은 폭(휴대폰 — 480 이하)**: 설정 메뉴가 위로 올라가고(지금 설정 셸), QR 줄은 빼며, [앱 내려받기]는 이름 아래 전체 폭으로 둔다.
### 휴대폰 받기 화면 `/app` (`SettingsMobileApp` B)
- **독립 화면**: 앱 레일 · 상단 바가 없다(인쇄 화면처럼 앱 틀 밖의 보호 화면 — `meta.requiresAuth`). 탭 제목 "휴대폰 앱 받기 · ARA SYSTEM".
- **로그인하지 않았을 때**: 지금의 로그인 화면으로 갔다가(`?redirect=/app`) 로그인을 마치면 이 화면으로 돌아온다 — 로그인 화면은 새로 그리지 않았다.
새 비밀번호 정하기나 2단계 인증 켜기 단계가 걸린 직원도 그 단계를 마친 뒤 돌아온다. 이미 로그인해 둔 휴대폰 브라우저는 바로 열린다.
- **브랜드 띠**(#0B3640 — 로그인 브랜드 패널을 줄인 꼴): 흰 로고 120 · "휴대폰 앱 받기"(20 · 흰 굵게) ·
"안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다."(14 · #A3C2C6). 내용 바탕은 #F8F9FB, 본문 14.
- **받을 앱 있음(B1)**:
- 카드(테두리 · 모서리 12 · 안쪽 16): 앱 그림 52 · "ARA SYSTEM"(17 굵게) / "안드로이드 앱"(13 회색) → 버전 · 올린 날 · 크기(이름 칸 56 · 13) →
[앱 내려받기](전체 폭 · 높이 48 · 글 15 · 아이콘 18). 휴대폰에서도 [앱 내려받기]까지 첫 화면에 들어온다.
- 「설치 방법」(15 굵게 — 줄 사이 10 · 줄 높이 1.55):
1. [앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.
2. 받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.
3. Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.
4. 설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.
- 설치 방법 아래 한 줄(13 · #4A5363) "새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다." — 앱의 [새 버전 받기]로 온 직원이 쓰던 앱을 먼저 지우지 않게.
- 아이폰 안내 상자(설정과 같다) → 가는 선 아래 문의 줄(13): 연락처가 있으면 "설치가 잘 되지 않나요?" 다음 줄에 연락처, 없으면 "설치가 잘 되지 않으면 관리자에게 문의해 주세요."
- **받을 앱 없음(B2)**: 테두리 상자에 휴대폰 아이콘 28 · "아직 받을 수 있는 앱이 없습니다"(17) / "앱이 준비되면 이 화면에서 받을 수 있습니다." + 문의 줄(설정의 받을 앱 없음과 같은 글).
설치 방법과 아이폰 안내는 뺀다.
- **넓은 화면(480 초과 — B3)**: 띠는 화면 폭 전체로 두고, 로고(150) · 제목(22) · 내용은 가운데 칸 480 에 맞춘다. 내용은 휴대폰과 같다.
### 두 화면 공통
- **[앱 내려받기]**: 첨부처럼 `<a href download>` 이동이고, 누르기 직전에 세션을 확인한다(`confirmSession`) — 세션이 끝났으면 세션 만료 창이 뜨고 받지 않는다.
받는 파일 이름은 서버가 정한다("ARA-SYSTEM-1.0.0.apk").
- **앱 정보를 받지 못하면**: 칸(카드) 자리에 사전 문구와 [다시 시도]를 둔다. 받는 동안은 자리만 둔다.
- **앱 그림**: 남색 바탕에 흰 ARA 마크 — 홈 화면 아이콘 `apple-touch-icon.png` 를 둥글게 깎은 것이라 새 그림이 들지 않는다.
- **크기**: 설정의 [앱 내려받기] 44, `/app` 의 [앱 내려받기] 48(전체 폭) — 44 보다 크니 밀도 예외가 아니다. 새 크기 토큰은 없다(48 은 `--space-48`).
### 구현 때 맞출 것 · 처음 판에서 미룬 것
- 받기와 설치 때 뜨는 창의 글은 브라우저와 제조사마다 다르다. 갤럭시 S23 에서 삼성 인터넷과 Chrome 으로 받기 경고, 알 수 없는 앱 설치 허용, Play 프로텍트 경고를
한 번씩 거쳐 보고 차례 글을 맞춘다(첫 판 구현 때는 아직 하지 않았다 — 맞춘 글은 화면 상수와 이 절을 함께 고친다).
- 서명 키가 다른 판(시험판)이 깔린 휴대폰은 새 판을 덮어 설치할 수 없다 — 그때는 앱을 지운 뒤 설치하고 로그인을 다시 한다.
- 미룬 것: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰(Ad Hoc · TestFlight), Google 개발자 인증(2027 전 세계 — 그 전에 등록할지 정한다).
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -1487,3 +1560,17 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **알려진 한계**: 여러 통인 줄의 마지막 메일만 읽으면 다음 맞추기까지 줄이 굵은 채다. 마지막 메일이 아닌, 보내기를 기다리는 메일은 풀린 뒤에도 다음 맞추기까지 고를 수 없다(계약 2절).
분할 보기 머리는 [비우기] 단추가 있는 휴지통 · 스팸메일함, 세 자리 이상 안 읽은 수, "받은편지함"보다 긴 메일함 이름, 1120~1220 폭에서는 1440 에서도 개수 글이 잘린다(title 로 다 보인다).
분할 보기 도구 줄의 "N개 선택됨"은 세 자리부터 두 줄로 넘친다(메일 목록의 "123통 선택됨"도 같다).
### 휴대폰 앱 받기 구현하며 정한 보완 (2026-11-08 — 캔버스에는 아직 없다)
데이터 · 경로는 위 "휴대폰 앱 받기" 머리말(서버와 맞춘 약속 — 계약 1절 "휴대폰 앱 받기 — 사내 배포")과 같다.
- **올린 날**은 다른 설정 화면처럼 브라우저 시간대의 날짜다("2026년 11월 18일" — 2단계 인증의 "…부터 켜짐"과 같은 표기). 대용량 첨부의 끝나는 날(한국 날짜 — `kst-date.ts`)과 달리 직원만 보는 날이다.
크기는 첨부와 같은 표기("31 MB")이고, 읽을 수 없는 값(빈 버전 · 날짜가 아님 · 음수 크기)은 그 칸째 뺀다.
- **받지 못함**은 다른 설정 화면의 불러오기 실패와 같은 모양(`MailLoadError` — 빨간 아이콘 · 사전 문구 · 문의 코드 · [다시 시도])을 칸 자리에 둔다(`/app` 은 카드 자리). 알림은 띄우지 않는다.
받는 동안은 빈 자리(aria-busy)다 — 다른 설정 화면과 같다.
- 앱 정보는 들어올 때마다 새로 받는다(운영자가 새 판을 올렸을 수 있다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 늦게 온 응답을 버리고 받아 둔 값도 지운다.
- **문의 줄**은 로그인 화면의 `LoginSupportLine` 을 그대로 쓴다. `/app` 은 본문이 14 라 문의 줄 글만 13 이다(`size="base"`).
- **앱 그림**의 모서리는 한 변의 1/4 이다(48 은 12, 52 는 13 — 보드 값).
- **폭 기준**: 설정의 QR 줄을 빼는 좁은 폭은 480 이하(공개 내려받기 화면의 좁은 폭과 같은 값), `/app` 의 넓은 화면은 그 반대(481 이상)다.
- 글자 크기 17(`/app` 의 앱 이름 · 받을 앱이 없을 때의 제목)은 토큰 `--font-size-title-md` 를 더했다(크기 · 밀도 예외 토큰은 아니다).
@@ -0,0 +1,598 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설정 · 휴대폰 앱 · 앱 받기 화면</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67;color:#FFFFFF}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
</style>
</helmet>
<div style="width: 1440px; height: 3209px; box-sizing: border-box; display: flex; flex-direction: column; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5; word-break: keep-all; overflow-wrap: anywhere">
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 0; display: flex; flex-direction: column; gap: 18px">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 1100px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">설정 · 휴대폰 앱 · 앱 받기 화면</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">안드로이드 앱은 스토어에 올리지 않고, 웹메일에 로그인한 직원만 받게 합니다. 위 A는 PC에서 여는 설정 › 계정 › 휴대폰 앱이고, 아래 B는 휴대폰 브라우저로 여는 받기 화면(/app)입니다. 단추로 상태를 바꿔 볼 수 있습니다. 예시 값은 버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB입니다. 초안이며 사용자 확인 전입니다(2026-11-08).</p>
</div>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px">
<span id="contact-modes-label" style="font-size: 13.5px; font-weight: 700; color: #344054">문의 연락처</span>
<div role="group" aria-labelledby="contact-modes-label" style="display: flex; gap: 8px">
<sc-for list="{{ contactModes }}" as="s" hint-placeholder-count="2">
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 0 0 auto; min-width: 152px; min-height: 44px; box-sizing: border-box; padding: 0 16px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
</sc-for>
</div>
<span style="font-size: 12.5px; color: #667085">A2 · B1 · B2 · B3의 문의 줄이 함께 바뀝니다. 연락처는 운영 도구로 넣는 값이고, 보드의 연락처는 자리 표시입니다.</span>
</div>
</div>
<section aria-labelledby="sec-a-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 36px 48px 16px; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="sec-a-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">A. 설정 › 계정 › 휴대폰 앱 (PC)</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">계정 묶음에서 메일 프로그램 연결 바로 다음에 둡니다. 주소는 /settings/account/mobile-app, 탭 제목은 휴대폰 앱입니다.</p>
</div>
<div style="display: flex; align-items: center; gap: 16px">
<div role="group" aria-label="설정 화면 상태 고르기" style="display: flex; gap: 8px">
<sc-for list="{{ settingsModes }}" as="s" hint-placeholder-count="2">
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 0 0 auto; min-width: 200px; min-height: 44px; box-sizing: border-box; padding: 0 16px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
</sc-for>
</div>
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">{{ aStateTitle }}</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">{{ aStateNote }}</p>
</div>
</div>
</section>
<div id="board-pc" role="region" aria-label="{{ aRegionLabel }}" style="flex: 0 0 auto; height: 900px; box-sizing: border-box; display: flex; background: #FFFFFF; border-top: 1px solid #E6E8EC; border-bottom: 1px solid #E6E8EC; overflow: hidden">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="Contacts.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; background: #155A64; color: #FFFFFF; font-weight: 600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="SettingsAccount.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span>비밀번호</span>
</a></li>
<li><a href="SettingsTwoFactor.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><polyline points="9 12 11 14 15 10"></polyline></svg>
<span>2단계 인증</span>
</a></li>
<li><a href="SettingsAppPasswords.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
<span>앱 비밀번호</span>
</a></li>
<li><a href="SettingsMailClients.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"></path><path d="M10 19v-3.96 3.15"></path><path d="M7 19h5"></path><rect x="16" y="12" width="6" height="10" rx="2"></rect></svg>
<span>메일 프로그램 연결</span>
</a></li>
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg>
<span>휴대폰 앱</span>
</a></li>
<li><a href="SettingsLoginHistory.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>
<span>로그인 기록</span>
</a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>서명</span>
</a></li>
<li><a href="SettingsVacation.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
<span>부재중 자동 응답</span>
</a></li>
<li><a href="SettingsFilter.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<span>필터</span>
</a></li>
<li><a href="SettingsSendRead.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
<span>보내기 · 읽기</span>
</a></li>
<li><a href="SettingsSendRead.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
<span>차단한 주소</span>
</a></li>
<li><a href="SettingsLargeFiles.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
<span>대용량 첨부</span>
</a></li>
<li><a href="SettingsNotify.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<span>알림</span>
</a></li>
</ul>
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div id="board-pc-column" style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">휴대폰 앱</h1>
<p style="margin: 0; color: #4A5363">안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.</p>
</div>
<sc-if value="{{ aHasApp }}" hint-placeholder-val="{{ true }}">
<div style="display: flex; flex-direction: column; gap: 22px">
<section aria-labelledby="ma-app-title" style="display: flex; flex-direction: column; gap: 8px">
<h2 id="ma-app-title" style="margin: 0; font-size: 14px; font-weight: 700">안드로이드 앱</h2>
<div style="box-sizing: border-box; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 14px; padding: 16px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 12px; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="34" height="12" style="display: block; width: 34px; height: auto"></span>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; line-height: 1.4">
<span style="font-size: 14px; font-weight: 600">ARA SYSTEM</span>
<p style="margin: 0; font-size: 12.5px; color: #667085; font-variant-numeric: tabular-nums">버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB</p>
</div>
<a href="#" class="primary" aria-label="앱 내려받기 (31 MB)" style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 16px; border-radius: 8px; font-size: 13.5px; font-weight: 600; text-decoration: none"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>앱 내려받기</a>
</div>
<div style="display: flex; align-items: flex-start; gap: 20px; padding: 16px; border-top: 1px solid #EEF0F3">
<div style="flex: 0 0 auto; padding: 6px; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF">
<svg role="img" aria-label="휴대폰에서 앱을 받는 주소의 QR 코드" width="160" height="160" viewBox="0 0 33 33" shape-rendering="crispEdges" style="display: block"><rect width="33" height="33" fill="#FFFFFF"></rect><path fill="#171C26" d="M2 2h7v1h-7zM10 2h1v1h-1zM12 2h1v1h-1zM15 2h3v1h-3zM20 2h3v1h-3zM24 2h7v1h-7zM2 3h1v1h-1zM8 3h1v1h-1zM10 3h2v1h-2zM15 3h1v1h-1zM18 3h3v1h-3zM22 3h1v1h-1zM24 3h1v1h-1zM30 3h1v1h-1zM2 4h1v1h-1zM4 4h3v1h-3zM8 4h1v1h-1zM11 4h4v1h-4zM18 4h1v1h-1zM20 4h3v1h-3zM24 4h1v1h-1zM26 4h3v1h-3zM30 4h1v1h-1zM2 5h1v1h-1zM4 5h3v1h-3zM8 5h1v1h-1zM10 5h2v1h-2zM14 5h1v1h-1zM16 5h2v1h-2zM19 5h1v1h-1zM22 5h1v1h-1zM24 5h1v1h-1zM26 5h3v1h-3zM30 5h1v1h-1zM2 6h1v1h-1zM4 6h3v1h-3zM8 6h1v1h-1zM11 6h4v1h-4zM16 6h1v1h-1zM18 6h1v1h-1zM21 6h1v1h-1zM24 6h1v1h-1zM26 6h3v1h-3zM30 6h1v1h-1zM2 7h1v1h-1zM8 7h1v1h-1zM12 7h1v1h-1zM14 7h2v1h-2zM17 7h2v1h-2zM20 7h3v1h-3zM24 7h1v1h-1zM30 7h1v1h-1zM2 8h7v1h-7zM10 8h1v1h-1zM12 8h1v1h-1zM14 8h1v1h-1zM16 8h1v1h-1zM18 8h1v1h-1zM20 8h1v1h-1zM22 8h1v1h-1zM24 8h7v1h-7zM10 9h1v1h-1zM12 9h1v1h-1zM17 9h6v1h-6zM2 10h1v1h-1zM4 10h2v1h-2zM7 10h3v1h-3zM14 10h2v1h-2zM17 10h3v1h-3zM21 10h2v1h-2zM24 10h1v1h-1zM27 10h1v1h-1zM29 10h2v1h-2zM2 11h1v1h-1zM7 11h1v1h-1zM10 11h8v1h-8zM19 11h8v1h-8zM30 11h1v1h-1zM2 12h2v1h-2zM5 12h6v1h-6zM15 12h1v1h-1zM20 12h1v1h-1zM25 12h1v1h-1zM28 12h2v1h-2zM5 13h2v1h-2zM9 13h5v1h-5zM18 13h1v1h-1zM21 13h1v1h-1zM23 13h3v1h-3zM30 13h1v1h-1zM2 14h1v1h-1zM6 14h4v1h-4zM12 14h1v1h-1zM14 14h1v1h-1zM16 14h4v1h-4zM21 14h2v1h-2zM25 14h1v1h-1zM27 14h2v1h-2zM6 15h2v1h-2zM11 15h3v1h-3zM16 15h3v1h-3zM21 15h1v1h-1zM24 15h1v1h-1zM28 15h3v1h-3zM2 16h2v1h-2zM7 16h2v1h-2zM10 16h1v1h-1zM14 16h2v1h-2zM18 16h1v1h-1zM21 16h3v1h-3zM26 16h1v1h-1zM28 16h3v1h-3zM3 17h3v1h-3zM9 17h1v1h-1zM12 17h3v1h-3zM16 17h2v1h-2zM21 17h1v1h-1zM23 17h1v1h-1zM26 17h1v1h-1zM29 17h1v1h-1zM3 18h1v1h-1zM5 18h1v1h-1zM7 18h3v1h-3zM11 18h1v1h-1zM13 18h2v1h-2zM16 18h1v1h-1zM18 18h1v1h-1zM20 18h1v1h-1zM26 18h2v1h-2zM29 18h1v1h-1zM3 19h2v1h-2zM7 19h1v1h-1zM9 19h2v1h-2zM13 19h1v1h-1zM16 19h2v1h-2zM20 19h1v1h-1zM22 19h1v1h-1zM25 19h1v1h-1zM27 19h3v1h-3zM2 20h1v1h-1zM4 20h7v1h-7zM12 20h7v1h-7zM22 20h1v1h-1zM24 20h2v1h-2zM28 20h1v1h-1zM4 21h2v1h-2zM9 21h1v1h-1zM11 21h2v1h-2zM15 21h2v1h-2zM19 21h1v1h-1zM22 21h1v1h-1zM24 21h1v1h-1zM26 21h1v1h-1zM28 21h1v1h-1zM3 22h1v1h-1zM5 22h1v1h-1zM7 22h2v1h-2zM12 22h1v1h-1zM15 22h2v1h-2zM18 22h2v1h-2zM21 22h8v1h-8zM10 23h1v1h-1zM12 23h1v1h-1zM18 23h3v1h-3zM22 23h1v1h-1zM26 23h5v1h-5zM2 24h7v1h-7zM10 24h1v1h-1zM12 24h1v1h-1zM16 24h1v1h-1zM18 24h1v1h-1zM20 24h3v1h-3zM24 24h1v1h-1zM26 24h2v1h-2zM29 24h1v1h-1zM2 25h1v1h-1zM8 25h1v1h-1zM10 25h8v1h-8zM22 25h1v1h-1zM26 25h2v1h-2zM2 26h1v1h-1zM4 26h3v1h-3zM8 26h1v1h-1zM13 26h2v1h-2zM19 26h1v1h-1zM22 26h5v1h-5zM28 26h2v1h-2zM2 27h1v1h-1zM4 27h3v1h-3zM8 27h1v1h-1zM10 27h1v1h-1zM12 27h2v1h-2zM15 27h1v1h-1zM17 27h1v1h-1zM23 27h1v1h-1zM25 27h3v1h-3zM30 27h1v1h-1zM2 28h1v1h-1zM4 28h3v1h-3zM8 28h1v1h-1zM10 28h2v1h-2zM13 28h1v1h-1zM15 28h1v1h-1zM18 28h2v1h-2zM21 28h1v1h-1zM25 28h1v1h-1zM28 28h1v1h-1zM30 28h1v1h-1zM2 29h1v1h-1zM8 29h1v1h-1zM11 29h3v1h-3zM21 29h3v1h-3zM26 29h2v1h-2zM29 29h1v1h-1zM2 30h7v1h-7zM10 30h1v1h-1zM13 30h1v1h-1zM18 30h1v1h-1zM20 30h4v1h-4zM25 30h1v1h-1zM27 30h1v1h-1zM29 30h1v1h-1z"></path></svg>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding-top: 2px">
<h3 style="margin: 0; font-size: 13.5px; font-weight: 600">휴대폰으로 받기</h3>
<p style="margin: 0; color: #344054">휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.</p>
<div style="display: flex; flex-direction: column; gap: 4px; padding-top: 10px; border-top: 1px solid #EEF0F3">
<p style="margin: 0; color: #344054">QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.</p>
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #171C26">portal.arasystem.it.kr/app</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.</p>
</div>
</div>
</div>
</section>
<section aria-labelledby="ma-steps-title" style="display: flex; flex-direction: column; gap: 10px">
<h2 id="ma-steps-title" style="margin: 0; font-size: 14px; font-weight: 700">설치 방법</h2>
<ol style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px">
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 6px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">1</span>
<span>휴대폰에서 [앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 이 파일을 받을지 한 번 더 물으면 그대로 받아 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 6px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">2</span>
<span>받은 파일(ARA-SYSTEM-1.0.0.apk)을 열어 주세요. 처음 설치할 때는 휴대폰이 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 돌아와 설치를 이어 가 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 6px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">3</span>
<span>Play 스토어를 거치지 않은 앱이라 Play 프로텍트가 경고할 수 있습니다. 웹메일에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 6px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">4</span>
<span>설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요. 앱 비밀번호는 만들지 않으셔도 됩니다.</span>
</li>
</ol>
</section>
<section aria-labelledby="ma-update-title" style="display: flex; flex-direction: column; gap: 6px">
<h2 id="ma-update-title" style="margin: 0; font-size: 14px; font-weight: 700">새 버전이 나오면</h2>
<p style="margin: 0; color: #344054">새 버전도 같은 곳에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.</p>
<p style="margin: 0; color: #344054">꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.</p>
</section>
<div style="display: flex; align-items: flex-start; gap: 10px; box-sizing: border-box; padding: 12px 14px; border-radius: 8px; background: #F3F8F8; color: #344054">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<div style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px">
<span style="font-weight: 600; color: #0A5F67">아이폰용 앱은 아직 없습니다</span>
<span>아이폰에서는 브라우저로 웹메일을 쓰거나, 기본 메일 앱에 회사 메일을 연결해 주세요. 연결 방법은 <a href="SettingsMailClients.dc.html">메일 프로그램 연결</a>에서 볼 수 있습니다.</span>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ aNoApp }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; gap: 22px">
<section aria-labelledby="ma-none-title" style="display: flex; flex-direction: column; gap: 8px">
<h2 id="ma-none-title" style="margin: 0; font-size: 14px; font-weight: 700">안드로이드 앱</h2>
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 10px; box-sizing: border-box; padding: 20px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg>
<div style="display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 14px; font-weight: 600">아직 받을 수 있는 앱이 없습니다</span>
<span style="color: #4A5363">앱이 준비되면 이곳에서 받아 설치할 수 있습니다.</span>
</div>
<p style="align-self: stretch; margin: 2px 0 0; padding-top: 12px; border-top: 1px solid #EEF0F3; font-size: 12.5px; color: #667085">
<sc-if value="{{ contactSet }}" hint-placeholder-val="{{ true }}">언제 받을 수 있는지 궁금하신가요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></sc-if>
<sc-if value="{{ contactUnset }}" hint-placeholder-val="{{ false }}">언제 받을 수 있는지는 관리자에게 문의해 주세요.</sc-if>
</p>
</div>
</section>
</div>
</sc-if>
</div>
</main>
</div>
</div>
</div>
<section aria-labelledby="sec-b-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="sec-b-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">B. 휴대폰 받기 화면 /app</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">앱 레일과 상단 바가 없는 독립 화면이고, 로그인해야 열립니다. QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 이 주소를 엽니다.</p>
</div>
<div role="group" aria-label="받기 화면 상태 고르기" style="display: flex; gap: 8px">
<sc-for list="{{ pageModes }}" as="s" hint-placeholder-count="2">
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 0 0 auto; min-width: 200px; min-height: 44px; box-sizing: border-box; padding: 0 16px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
</sc-for>
</div>
<div style="display: flex; gap: 40px; align-items: flex-start; padding-top: 8px">
<div style="flex: 0 0 360px; display: flex; flex-direction: column; gap: 12px">
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
<h3 style="margin: 0; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ bPhoneTitle }}</h3>
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085">{{ bPhoneNote }}</p>
</div>
<div id="board-phone" style="width: 360px; height: 1090px; box-sizing: border-box; border-radius: 28px; overflow: hidden; background: #F8F9FB; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
<div aria-hidden="true" style="flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 56px; box-sizing: border-box; padding: 0 6px 0 12px; background: #FFFFFF; border-bottom: 1px solid #E6E8EC">
<div style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; height: 36px; box-sizing: border-box; padding: 0 14px; border-radius: 18px; background: #F2F4F7; color: #344054; font-size: 13px">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4A5363" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">portal.arasystem.it.kr/app</span>
</div>
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; color: #4A5363"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="12" cy="19" r="1.6"></circle></svg></span>
</div>
<div id="board-phone-page" style="flex: 0 0 auto; display: flex; flex-direction: column">
<section aria-label="ARA SYSTEM" style="display: flex; flex-direction: column; gap: 16px; padding: 28px 20px 24px; background: #0B3640">
<img src="/_blob/3a2f4115a8f90207faa7d3a51cd9aec2" alt="ARA SYSTEM" width="120" height="57" style="display: block; width: 120px; height: auto">
<div style="display: flex; flex-direction: column; gap: 6px">
<h1 style="margin: 0; font-size: 20px; line-height: 1.35; font-weight: 700; letter-spacing: -0.02em; color: #FFFFFF">휴대폰 앱 받기</h1>
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #A3C2C6">안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.</p>
</div>
</section>
<main style="display: flex; flex-direction: column; gap: 20px; padding: 20px 16px 28px; font-size: 14px">
<sc-if value="{{ bHasApp }}" hint-placeholder-val="{{ true }}">
<div style="display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 14px; box-sizing: border-box; padding: 16px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; border-radius: 13px; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="36" height="13" style="display: block; width: 36px; height: auto"></span>
<div style="display: flex; flex-direction: column; line-height: 1.35">
<span style="font-size: 17px; font-weight: 700; letter-spacing: -0.01em">ARA SYSTEM</span>
<span style="font-size: 13px; color: #667085">안드로이드 앱</span>
</div>
</div>
<dl style="margin: 0; display: grid; grid-template-columns: 56px minmax(0, 1fr); row-gap: 2px; font-size: 13px; font-variant-numeric: tabular-nums">
<dt style="color: #667085">버전</dt><dd style="margin: 0; color: #344054">1.0.0</dd>
<dt style="color: #667085">올린 날</dt><dd style="margin: 0; color: #344054">2026년 11월 18일</dd>
<dt style="color: #667085">크기</dt><dd style="margin: 0; color: #344054">31 MB</dd>
</dl>
<a href="#" class="primary" aria-label="앱 내려받기 (31 MB)" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; border-radius: 10px; font-size: 15px; font-weight: 600; text-decoration: none"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>앱 내려받기</a>
</div>
<section aria-labelledby="b-steps-h" style="display: flex; flex-direction: column; gap: 10px">
<h2 id="b-steps-h" style="margin: 0; font-size: 15px; font-weight: 700">설치 방법</h2>
<ol style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; line-height: 1.55">
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">1</span>
<span>[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">2</span>
<span>받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">3</span>
<span>Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">4</span>
<span>설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.</span>
</li>
</ol>
<p style="margin: 2px 0 0; font-size: 13px; color: #4A5363">새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.</p>
</section>
<div style="display: flex; align-items: flex-start; gap: 10px; box-sizing: border-box; padding: 12px 14px; border-radius: 8px; background: #F3F8F8; color: #344054; font-size: 13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<div style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px">
<span style="font-weight: 600; color: #0A5F67">아이폰용 앱은 아직 없습니다</span>
<span>아이폰에서는 브라우저로 웹메일을 쓰거나, 기본 메일 앱에 회사 메일을 연결해 주세요. 연결 방법은 <a href="SettingsMailClients.dc.html">메일 프로그램 연결</a>에서 볼 수 있습니다.</span>
</div>
</div>
<p style="margin: 0; padding-top: 16px; border-top: 1px solid #E6E8EC; font-size: 13px; color: #667085">
<sc-if value="{{ contactSet }}" hint-placeholder-val="{{ true }}">설치가 잘 되지 않나요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></sc-if>
<sc-if value="{{ contactUnset }}" hint-placeholder-val="{{ false }}">설치가 잘 되지 않으면 관리자에게 문의해 주세요.</sc-if>
</p>
</div>
</sc-if>
<sc-if value="{{ bNoApp }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 10px; box-sizing: border-box; padding: 20px 16px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg>
<h2 style="margin: 0; font-size: 17px; line-height: 1.4; font-weight: 650">아직 받을 수 있는 앱이 없습니다</h2>
<p style="margin: 0; color: #344054">앱이 준비되면 이 화면에서 받을 수 있습니다.</p>
<p style="align-self: stretch; margin: 4px 0 0; padding-top: 12px; border-top: 1px solid #EEF0F3; font-size: 13px; color: #667085">
<sc-if value="{{ contactSet }}" hint-placeholder-val="{{ true }}">언제 받을 수 있는지 궁금하신가요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></sc-if>
<sc-if value="{{ contactUnset }}" hint-placeholder-val="{{ false }}">언제 받을 수 있는지는 관리자에게 문의해 주세요.</sc-if>
</p>
</div>
</sc-if>
</main>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 12px">
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
<h3 style="margin: 0; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ bWideTitle }}</h3>
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085">{{ bWideNote }}</p>
</div>
<div id="board-wide" style="height: 1090px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 12px; overflow: hidden; background: #F8F9FB; display: flex; flex-direction: column">
<div aria-hidden="true" style="flex: 0 0 auto; display: flex; align-items: center; gap: 12px; min-height: 42px; padding: 0 14px; background: #EEF0F3; border-bottom: 1px solid #E6E8EC">
<span style="display: inline-flex; gap: 6px"><span style="width: 10px; height: 10px; border-radius: 50%; background: #D0D5DD"></span><span style="width: 10px; height: 10px; border-radius: 50%; background: #D0D5DD"></span><span style="width: 10px; height: 10px; border-radius: 50%; background: #D0D5DD"></span></span>
<div style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 12px; border-radius: 6px; background: #FFFFFF; color: #344054; font-size: 12.5px">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4A5363" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"><strong style="font-weight: 600; color: #171C26">portal.arasystem.it.kr</strong>/app</span>
</div>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">탭 제목 "휴대폰 앱 받기 · ARA SYSTEM"</span>
</div>
<div id="board-wide-page" style="flex: 0 0 auto; display: flex; flex-direction: column">
<section aria-label="ARA SYSTEM" style="padding: 36px 24px 32px; background: #0B3640">
<div style="max-width: 480px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px">
<img src="/_blob/3a2f4115a8f90207faa7d3a51cd9aec2" alt="ARA SYSTEM" width="150" height="72" style="display: block; width: 150px; height: auto">
<div style="display: flex; flex-direction: column; gap: 6px">
<h1 style="margin: 0; font-size: 22px; line-height: 1.35; font-weight: 700; letter-spacing: -0.02em; color: #FFFFFF">휴대폰 앱 받기</h1>
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #A3C2C6">안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.</p>
</div>
</div>
</section>
<main style="display: flex; justify-content: center; padding: 28px 24px 40px; font-size: 14px">
<div style="width: 100%; max-width: 480px; display: flex; flex-direction: column; gap: 22px">
<sc-if value="{{ bHasApp }}" hint-placeholder-val="{{ true }}">
<div style="display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 14px; box-sizing: border-box; padding: 20px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; border-radius: 13px; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="36" height="13" style="display: block; width: 36px; height: auto"></span>
<div style="display: flex; flex-direction: column; line-height: 1.35">
<span style="font-size: 17px; font-weight: 700; letter-spacing: -0.01em">ARA SYSTEM</span>
<span style="font-size: 13px; color: #667085">안드로이드 앱</span>
</div>
</div>
<dl style="margin: 0; display: grid; grid-template-columns: 56px minmax(0, 1fr); row-gap: 2px; font-size: 13px; font-variant-numeric: tabular-nums">
<dt style="color: #667085">버전</dt><dd style="margin: 0; color: #344054">1.0.0</dd>
<dt style="color: #667085">올린 날</dt><dd style="margin: 0; color: #344054">2026년 11월 18일</dd>
<dt style="color: #667085">크기</dt><dd style="margin: 0; color: #344054">31 MB</dd>
</dl>
<a href="#" class="primary" aria-label="앱 내려받기 (31 MB)" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; border-radius: 10px; font-size: 15px; font-weight: 600; text-decoration: none"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>앱 내려받기</a>
</div>
<section aria-labelledby="b3-steps-h" style="display: flex; flex-direction: column; gap: 10px">
<h2 id="b3-steps-h" style="margin: 0; font-size: 15px; font-weight: 700">설치 방법</h2>
<ol style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; line-height: 1.55">
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">1</span>
<span>[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">2</span>
<span>받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">3</span>
<span>Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.</span>
</li>
<li style="display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px">
<span aria-hidden="true" style="font-weight: 700; color: #0A5F67; font-variant-numeric: tabular-nums">4</span>
<span>설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.</span>
</li>
</ol>
<p style="margin: 2px 0 0; font-size: 13px; color: #4A5363">새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.</p>
</section>
<div style="display: flex; align-items: flex-start; gap: 10px; box-sizing: border-box; padding: 12px 14px; border-radius: 8px; background: #F3F8F8; color: #344054; font-size: 13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<div style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px">
<span style="font-weight: 600; color: #0A5F67">아이폰용 앱은 아직 없습니다</span>
<span>아이폰에서는 브라우저로 웹메일을 쓰거나, 기본 메일 앱에 회사 메일을 연결해 주세요. 연결 방법은 <a href="SettingsMailClients.dc.html">메일 프로그램 연결</a>에서 볼 수 있습니다.</span>
</div>
</div>
<p style="margin: 0; padding-top: 16px; border-top: 1px solid #E6E8EC; font-size: 13px; color: #667085">
<sc-if value="{{ contactSet }}" hint-placeholder-val="{{ true }}">설치가 잘 되지 않나요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></sc-if>
<sc-if value="{{ contactUnset }}" hint-placeholder-val="{{ false }}">설치가 잘 되지 않으면 관리자에게 문의해 주세요.</sc-if>
</p>
</div>
</sc-if>
<sc-if value="{{ bNoApp }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 10px; box-sizing: border-box; padding: 24px 20px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg>
<h2 style="margin: 0; font-size: 17px; line-height: 1.4; font-weight: 650">아직 받을 수 있는 앱이 없습니다</h2>
<p style="margin: 0; color: #344054">앱이 준비되면 이 화면에서 받을 수 있습니다.</p>
<p style="align-self: stretch; margin: 4px 0 0; padding-top: 12px; border-top: 1px solid #EEF0F3; font-size: 13px; color: #667085">
<sc-if value="{{ contactSet }}" hint-placeholder-val="{{ true }}">언제 받을 수 있는지 궁금하신가요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></sc-if>
<sc-if value="{{ contactUnset }}" hint-placeholder-val="{{ false }}">언제 받을 수 있는지는 관리자에게 문의해 주세요.</sc-if>
</p>
</div>
</sc-if>
</div>
</main>
</div>
</div>
</div>
</div>
</section>
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 48px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">로그인하지 않았을 때</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>로그인하지 않은 브라우저로 /app을 열면(QR 코드, 앱의 [새 버전 받기]) 지금의 로그인 화면으로 갔다가, 로그인을 마치면 이 화면으로 돌아옵니다. 다른 보호 화면과 같은 길(requiresAuth 와 redirect=/app)이라 로그인 화면은 새로 그리지 않았습니다.</li>
<li>새 비밀번호 정하기나 2단계 인증 켜기 단계가 걸린 직원도 그 단계를 마친 뒤 이 화면으로 돌아옵니다.</li>
<li>이미 로그인해 둔 휴대폰 브라우저라면 바로 이 화면이 열립니다. [앱 내려받기]는 첨부 받기처럼 누르기 직전에 세션을 확인하고(confirmSession), 세션이 끝났으면 세션 만료 창이 뜹니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>메뉴는 계정 묶음의 메일 프로그램 연결 다음, 로그인 기록 앞입니다. 아이콘은 이미 있는 휴대폰 그림(smartphone)입니다.</li>
<li>앱 칸에는 앱 그림, 이름, 버전, 올린 날, 크기, [앱 내려받기]가 있습니다. 앱 그림은 남색 바탕에 흰 ARA 마크(apple-touch-icon.png 를 둥글게 깎은 것)라 새 그림이 들지 않습니다. 받는 파일 이름은 ARA-SYSTEM-버전.apk입니다.</li>
<li>QR은 2단계 인증 켜기와 같은 QrCodeImage(160)이고, 지금 주소의 출처에 /app을 붙인 주소만 담습니다. 비밀이 아닙니다. 보드의 QR은 https://portal.arasystem.it.kr/app으로 만든 진짜 QR입니다.</li>
<li>문의 줄은 로그인 화면의 문의 줄과 같은 꼴입니다. 연락처가 있으면 물음 다음 줄에 연락처를, 없거나 받지 못하면 대신 쓰는 글 한 줄을 둡니다.</li>
<li>받을 앱이 없으면 QR, 설치 방법, 새 버전 안내, 아이폰 안내를 모두 빼고 안내와 문의 줄만 둡니다. 최신 · NEW 같은 꼬리표는 달지 않습니다.</li>
<li>휴대폰으로 설정 화면을 열었을 때(좁은 폭)는 메뉴가 위로 올라가고(지금 설정 화면과 같다), QR 줄은 빼며, [앱 내려받기]는 이름 아래 전체 폭으로 둡니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">받기 화면 /app</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>앱 레일과 상단 바가 없는 독립 화면입니다. 브랜드 띠는 로그인 화면의 브랜드 패널을 줄인 꼴(로고, 제목, 설명)이고 바탕은 #F8F9FB입니다.</li>
<li>휴대폰 폭이 먼저입니다. 폭 480(30rem)보다 넓으면 띠는 화면 폭 전체로 두고, 로고와 제목, 내용은 가운데 칸 480에 맞춥니다.</li>
<li>[앱 내려받기]는 전체 폭에 높이 48(3rem)이고, 본문 글은 14입니다. 휴대폰에서도 [앱 내려받기]까지 첫 화면에 들어옵니다.</li>
<li>설치 방법 아래의 새 버전 한 줄은 앱의 [새 버전 받기]로 온 직원을 위한 것입니다. 쓰던 앱을 먼저 지우지 않게 합니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">구현 때 맞출 것</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>받기와 설치 때 뜨는 창의 글은 브라우저와 제조사마다 다릅니다. 갤럭시 S23에서 삼성 인터넷과 Chrome으로 받기 경고, 알 수 없는 앱 설치 허용, Play 프로텍트 경고를 한 번씩 거쳐 보고 차례 글을 맞춥니다.</li>
<li>서명 키가 다른 판(시험판)이 깔린 휴대폰은 새 판을 덮어 설치할 수 없습니다. 그때는 앱을 지운 뒤 설치하고 로그인을 다시 합니다.</li>
<li>앱 정보(버전, 크기, 올린 날)와 파일을 주는 서버 API는 이 보드 밖입니다(backend 에 넘김). 앱 정보를 받지 못하면 칸 자리에 사전 문구와 [다시 시도]를 둡니다.</li>
</ul>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":3209}}'>
class Component extends DCLogic {
state = { settings: null, page: null, contact: null };
// 상태 고르기 단추 — 앱 보드(App1Push)와 같은 모양
modes(list, current, choose) {
return list.map((x) => {
const on = x.id === current;
return {
label: x.label,
pressed: on ? 'true' : 'false',
pick: () => choose(x.id),
bg: on ? '#E3F2F3' : '#FFFFFF',
fg: on ? '#0A5F67' : '#344054',
border: on ? '#0E7C86' : '#D0D5DD',
};
});
}
renderVals() {
const st = this.state;
const settings = st.settings ?? 'has';
const page = st.page ?? 'has';
const contact = st.contact ?? 'set';
const aHas = settings === 'has';
const bHas = page === 'has';
const contactSet = contact === 'set';
const contactNote = contactSet ? '문의 연락처를 정해 두었을 때' : '문의 연락처를 정하지 않았을 때';
return {
contactModes: this.modes([{ id: 'set', label: '연락처 있음' }, { id: 'unset', label: '연락처 없음' }], contact, (id) => this.setState({ contact: id })),
settingsModes: this.modes([{ id: 'has', label: 'A1 받을 앱 있음' }, { id: 'none', label: 'A2 받을 앱 없음' }], settings, (id) => this.setState({ settings: id })),
pageModes: this.modes([{ id: 'has', label: 'B1 받을 앱 있음' }, { id: 'none', label: 'B2 받을 앱 없음' }], page, (id) => this.setState({ page: id })),
contactSet,
contactUnset: !contactSet,
aHasApp: aHas,
aNoApp: !aHas,
aRegionLabel: aHas ? '화면: 설정 › 계정 › 휴대폰 앱 (받을 앱 있음)' : '화면: 설정 › 계정 › 휴대폰 앱 (받을 앱 없음)',
aStateTitle: aHas ? 'A1. 받을 앱 있음' : 'A2. 받을 앱 없음 · ' + contactNote,
aStateNote: aHas
? '안드로이드 앱 칸, QR, 설치 방법, 새 버전 안내, 아이폰 안내가 있습니다. 콘텐츠 열은 다른 설정 화면과 같은 800입니다.'
: '안내와 문의 줄만 둡니다. 문의 줄은 위의 문의 연락처 고르기를 따릅니다.',
bHasApp: bHas,
bNoApp: !bHas,
bPhoneTitle: bHas ? 'B1. 휴대폰 (폭 360) · 받을 앱 있음' : 'B2. 휴대폰 (폭 360) · 받을 앱 없음',
bPhoneNote: bHas
? 'QR 코드나 앱의 [새 버전 받기]로 연 모습입니다. 아래로 내리면 설치 방법, 아이폰 안내, 문의 줄이 있습니다.'
: '설치 방법과 아이폰 안내는 빼고, 안내와 문의 줄만 둡니다.',
bWideTitle: 'B3. 넓은 화면 (PC 브라우저 폭 944) · 위의 B1 · B2 고르기를 따릅니다',
bWideNote: '띠는 화면 폭 전체이고, 로고와 제목, 내용은 가운데 칸 480에 맞춥니다. 내용은 휴대폰과 같습니다.',
};
}
}
</script>
</body>
</html>
+5 -3
View File
@@ -58,9 +58,10 @@
"App1Search.dc.html": { "x": 0, "y": 33450, "w": 3098, "h": 1028, "title": "앱 · 검색 (찾기 · 결과)", "is_interactive": true },
"App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true },
"App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true },
"App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true }
"App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true },
"SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
@@ -78,7 +79,8 @@
"app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 },
"app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 },
"app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 },
"app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 2222 }
"app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 },
"appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }
},
"designSystems": []
}
+41 -3
View File
@@ -289,6 +289,41 @@
누르면 늘 메일 탭 받은편지함이다 — `kind` 로 가르지 않는다(`session-ended` 를 눌렀으면 받은편지함의 첫 요청이 세션 끝을 알아 로그인 화면으로 간다).
작은 아이콘 · 색은 서버가 싣지 않는다 — 앱 매니페스트의 기본값(`ic_stat_ara` · #0E7C86)이고, 채널 `new_mail`(이름 "새 메일" · 중요도 높음)은 앱이 만든다.
### 휴대폰 앱 받기 — 사내 배포 (2026-11-08 정함 · 서버)
> 2026-11-08 사용자가 정했다 — 앱은 스토어(Play 내부 테스트) 없이 **직접 빌드해 서명한 APK 를 포털에 로그인한 직원에게만** 나눠 준다.
> 받는 곳은 웹의 설정 › 계정 › 「휴대폰 앱」과 휴대폰 받기 화면 `/app`(보드 `docs/design/webmail/SettingsMobileApp.dc.html`), 앱의 업데이트 화면 · 설정 › 앱 정보 「새 버전 받기」는 바깥 브라우저로 `<포털>/app` 을 연다.
> 위 app-config 계약은 그대로다(받는 곳 주소를 싣지 않는다 — 앱이 API_URL 에서 `/api` 를 뗀 출처로 만든다). 서버는 `modules/app-releases` 가 이 절대로 만들었다.
| 경로 | 응답 |
| --- | --- |
| `GET /api/app-releases/android/latest` | 200 `{ version: "1.0.0", sizeBytes: 32505856, releasedAt: "2026-11-18T01:23:45.000Z" }` · `Cache-Control: private, no-store` / 404 `RES_001` — 올린 판이 없다 |
| `GET /api/app-releases/android/latest/file` (HEAD 도) | 200 APK 바이트 (Range 206 · 416) / 404 `RES_001` — 올린 판이 없다 · 그 파일이 없다 / 429 `REQ_001` — 앞단 동시 연결 상한 |
- 두 경로 모두 로그인 필요(웹 쿠키 · 앱 Bearer — `JwtAuthGuard`), 없으면 401 `AUTH_001`. 제한된 세션(6절)은 403 `AUTH_010`(허용 목록에 넣지 않는다). 요청 수는 기본 한도(경로마다 IP 당 1분 100회). 새 오류 코드는 없다.
- **최신** = 가장 늦게 올린 판(올린 시각 차례 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길 — 단 더 높은 판을 이미 깐 휴대폰은 안드로이드가 낮은 판으로 덮어 깔지 않는다).
`releasedAt` 은 올린 때(ISO 8601 UTC), `sizeBytes` 는 파일 바이트 수다.
- **파일 응답**은 대용량 첨부 받기(4절 "파일 응답")와 같은 머리글이고 형식만 APK 다 — 서버 `shared/utils/download-headers.ts` 한 곳:
`Content-Type: application/vnd.android.package-archive` · `Content-Disposition: attachment; filename="ARA-SYSTEM-<버전>.apk"` · `X-Content-Type-Options: nosniff` ·
`Content-Security-Policy: default-src 'none'; sandbox` · `Cache-Control: private, no-store` · `Referrer-Policy: no-referrer` · `X-Robots-Tag: noindex` · `Accept-Ranges: bytes` ·
강한 `ETag: "<판 id>"` · `Last-Modified`(올린 때) · `X-Accel-Buffering: no`. Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음 — `Content-Range: bytes */<크기>`),
`If-Range` 가 ETag · Last-Modified 와 다르면 200 전체. HEAD 는 머리글만. 실패는 머리글을 쓰기 전에 표준 실패 형식(JSON)이다.
파일 이름 · 경로는 서버가 만든 값뿐이다 — 내려받는 이름은 검사한 버전으로 만들고, 디스크의 이름은 UUID 다.
- 표에는 판이 있는데 볼륨에 파일이 없거나 크기가 표와 다르면 파일은 404 `RES_001`(정보는 200 그대로)이고 서버가 error 로그를 남긴다 — 운영자가 다시 올린다(운영 도구 「휴대폰 앱 목록」이 "파일 없음"을 보인다).
"올린 판이 없음"의 404 는 화면이 설정을 열 때마다 묻는 예상된 상태라 서버 로그는 debug 다.
- **동시 연결 상한(앞단 nginx)**: 파일 `…/file`(HEAD 포함)은 대용량 첨부의 앱 안 받기와 같은 그릇으로 센다 — 클라이언트 주소(`X-Forwarded-For` 의 마지막 칸)마다 30 · 서버 전체 200,
처리 중인 요청만(4절 "운영 확인"). 넘으면 백엔드에 닿기 전에 429 `REQ_001`(nginx 오류 JSON — `message` 는 빈 값, `traceId` 는 nginx 요청 ID). 정보 경로는 세지 않는다.
앞단은 이 응답만 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(느린 휴대폰마다 APK 사본을 임시 파일에 쌓지 않게). 경로는 대소문자 · `%2F` · 겹 `/` 를 가리지 않고 같은 설정을 탄다.
- **올리기 — API 가 없다**(서버 B 의 운영 도구 「휴대폰 앱 올리기」, 6절 "운영 도구" — 차례는 mail/README "휴대폰 앱 올리기"):
운영자가 `sudo docker cp` 로 백엔드 컨테이너 안에 넣은 APK → 검사(일반 파일 · 이름 `.apk` · zip 머리 `PK\x03\x04` · 1KB~200MB) · SHA-256 → 버전(`숫자.숫자.숫자` — 위 앱 알림 등록의 `appVersion` 과 같은 규칙,
app-config 의 최소 판보다 낮으면 올리지 않는다) → 업로드 볼륨(`UPLOAD_DIR`) 아래 `app-releases/<UUID>.apk`(임시 이름에 쓰고 디스크에 내려쓴 뒤 이름을 바꾼다 — 쓰면서 다시 센 SHA-256 이 다르면 올리지 않는다)
→ 표 `app_releases` 에 한 줄 → 실행 기록(`ops_audit_log` — `app-release-upload`). 파일은 매일 백업(업로드 볼륨)에 든다.
- 표 `app_releases`(마이그레이션 `AddAppReleases`): id(UUID — ETag) · platform(지금 `android`) · version · size_bytes · sha256(소문자 16진수) · file_key(UUID — 디스크 이름) · uploaded_by(운영자 이름) · created_at(올린 때).
지우는 명령은 없다 — 옛 판은 표와 볼륨에 남는다. 직원 주소로 나뉜 표가 아니라 퇴사 마무리와 상관없다.
- **클라이언트 — 웹**: 404 `RES_001` 은 "아직 받을 수 있는 앱이 없습니다" 칸으로(알림 끔). 내려받기는 첨부처럼 이동 전에 세션을 확인한다(`confirmSession()`).
- **클라이언트 — 앱**: 이 API 를 부르지 않는다 — 바깥 브라우저로 `<포털>/app` 을 연다.
- **이 단계에서 하지 않는 것**: 앱 안에서 받아 바로 설치(REQUEST_INSTALL_PACKAGES), 최소 판이 아닌 새 판 알림, 아이폰(Ad Hoc · TestFlight), 올린 판 지우기.
---
## 2. 메일 (모두 로그인 필요 — 없으면 401 `AUTH_001`)
@@ -2355,11 +2390,12 @@ interface FileUploadCreate {
### 운영 확인 (이 계약이 기대하는 것 — 고치는 일은 release-ops)
- 앞단 nginx: `/api/file-uploads` 본문 17MB · 읽기 90초. `/api/public-links/` · `/api/messages/*/large-files/` 는 응답 버퍼링을 끄고 `X-Accel-Buffering` 을 외부로 넘긴다
- 앞단 nginx: `/api/file-uploads` 본문 17MB · 읽기 90초. `/api/public-links/` · `/api/messages/*/large-files/` · 휴대폰 앱 받기 `/api/app-releases/*/file`(1절 "휴대폰 앱 받기")은 응답 버퍼링을 끄고 `X-Accel-Buffering` 을 외부로 넘긴다
(기본값이면 느린 받는 사람마다 임시 파일을 1GB 까지 만든다 — 실시간 알림과 같은 방법). `/s/` 는 `index.html` + `Cache-Control: no-cache` · `X-Robots-Tag: noindex, nofollow` · `Referrer-Policy: no-referrer`.
접근 로그의 경로 토큰 가리기, 공개 호스트의 server(위 "공개 호스트").
**내려받기 동시 연결 상한**(2026-10-30 리뷰 — 버퍼링을 끈 느린 받기는 연결 하나가 nginx 연결 둘과 백엔드 fd 를 잡아, 토큰 하나로 프론트 nginx 를 바닥낼 수 있다): `limit_conn` 으로
공개 받기 IP 마다 10 · 앱 안 받기 IP 마다 30(사무실 NAT) · 서버 전체 공개 받기 400 · 앱 안 받기 200, 넘으면 429 `REQ_001`(nginx 오류 JSON). `worker_connections` 는 이 수와 SSE · 포털을 함께 버티게.
공개 받기 IP 마다 10 · 앱 안 받기 IP 마다 30(사무실 NAT) · 서버 전체 공개 받기 400 · 앱 안 받기 200, 넘으면 429 `REQ_001`(nginx 오류 JSON).
휴대폰 앱 받기(`/api/app-releases/*/file` — 1절 "휴대폰 앱 받기")는 앱 안 받기와 같은 그릇으로 센다(둘 다 로그인한 직원의 받기 — 합쳐 IP 마다 30 · 전체 200). `worker_connections` 는 이 수와 SSE · 포털을 함께 버티게.
전체 상한을 경로마다 따로 둔다(2026-10-30 리뷰 — 처음 판은 두 경로가 500 을 함께 써, 바깥 IP 50개(IPv6 면 /64 하나)로 직원의 앱 안 받기가 모두 429 가 됐다).
경로의 대소문자 변형(`/FILES/` · `/api/Messages/…`)도 같은 상한 · 같은 흘려보내기를 탄다(2026-10-30 리뷰 — 처음 판은 글자 하나로 상한 · `X-Accel-Buffering` 넘김을 비켜 갔다).
구현(2026-10-30): 클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값 — 없으면 접속 주소). 워커마다 `worker_connections` 4096 · 열린 파일 8192.
@@ -2873,7 +2909,7 @@ interface FileUploadCreate {
- 실행 기록 표(`ops_audit_log`): 언제 · 누가 · 명령 · 대상 · 결과. 비밀은 남기지 않는다.
- 표준 입력이 터미널(TTY)이 아니면 운영자 이름 · 비밀번호를 묻기 전에 멈춘다(`docker exec -it` 로 실행). 입력 기록(↑)에 비밀번호를 남기지 않는다.
자동 시험만 `OPS_ALLOW_NO_TTY=1` 로 파이프 입력을 받는다 — 사람은 쓰지 않는다.
- 명령
- 명령 (열세 개 — 메뉴 차례 그대로. 12 · 13 은 2026-11-08 사내 배포 때 더했다)
| 명령 | 하는 일 |
| --- | --- |
@@ -2888,6 +2924,8 @@ interface FileUploadCreate {
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 로그인(웹메일 · 앱 — 2026-11-06 부터 앱도 센다) · 메일함 사용량 · 이전 중(끝나는 날) |
| 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 |
| 메일 이전 (2026-11-02 더함) | 이전 현황(이전 중인 계정 · 끝나는 때 · 메일함 사용량) · 이전용 비밀번호 다시 만들기(한 사람 또는 이전 중인 계정 모두 — 잃었거나 30일을 늘릴 때) · 이전 마치기(한 사람 또는 이전 중인 계정 모두 → 직원용 임시 비밀번호) |
| 휴대폰 앱 올리기 (2026-11-08 더함) | 운영자가 `docker cp` 로 백엔드 컨테이너에 넣은 APK 경로 → 검사(일반 파일 · `.apk` · zip 머리 · 1KB~200MB) · SHA-256 → 버전(파일 이름의 `x.y.z` 가 기본값 · app-config 의 최소 판보다 낮으면 하지 않음 · 지금 최신보다 낮거나 같으면 y/N) → 바뀔 것(버전 · 크기 · SHA-256) y/N → 업로드 볼륨 `app-releases/` 에 임시 이름 → 이름 바꾸기 → 표 → 실행 기록. 끝에 SHA-256 을 한 번 더 보인다(빌드한 파일과 견준다). 실패하면 반쯤 쓴 파일을 남기지 않는다 — 1절 "휴대폰 앱 받기" |
| 휴대폰 앱 목록 (2026-11-08 더함) | 최근 판 10개 — 버전 · 크기 · 올린 때 · 올린 이 · SHA-256 앞부분, 맨 위가 직원이 받는 판, 볼륨에 파일이 없으면 "파일 없음"(읽기만 — 기록하지 않는다) |
- 차례가 있는 명령
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 · 앱 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기.
+108 -15
View File
@@ -1,9 +1,10 @@
# ARA SYSTEM 앱 (Flutter)
ARA SYSTEM 그룹웨어의 모바일 앱이다. 식별자 `kr.it.arasystem.app` · 이름 "ARA SYSTEM", 안드로이드 먼저(Play 내부 테스트).
ARA SYSTEM 그룹웨어의 모바일 앱이다. 식별자 `kr.it.arasystem.app` · 이름 "ARA SYSTEM", 안드로이드 먼저.
스토어에 올리지 않고 우리 키로 서명한 APK 를 사내에만 나눠 준다(포털의 휴대폰 앱 받기 화면 — 아래 "사내 배포", 2026-11-08).
- 구조 · 구현 규칙: [`.claude/skills/flutter/SKILL.md`](../.claude/skills/flutter/SKILL.md)
- 앱 로그인(앱 전용 토큰 경로 — 서버는 아직 없다): 계약 [`docs/mail/api-contract.md`](../docs/mail/api-contract.md) 1절 "앱 로그인"
- 앱 로그인(앱 전용 토큰 경로): 계약 [`docs/mail/api-contract.md`](../docs/mail/api-contract.md) 1절 "앱 로그인"
- 화면 기준: [`docs/design/app/README.md`](../docs/design/app/README.md) (보드 사본 `docs/design/webmail/App1*.dc.html`)
이 문서는 개발 PC · 배포에서 알아 둘 사실만 적는다.
@@ -22,6 +23,9 @@ flutter run # 에뮬레이터 · USB 실기기
- **API 주소는 `.env` 의 `API_URL`** 하나다(루트 env 를 따르지 않는다). `.env` 는 앱 안에 그대로 들어가므로 비밀은 넣지 않는다.
- Android 에뮬레이터: `http://10.0.2.2:3000/api` — 에뮬레이터의 10.0.2.2 가 PC 의 127.0.0.1 이다.
- USB 실기기: `adb reverse tcp:3000 tcp:3000` 뒤 `http://localhost:3000/api`. 개발 백엔드는 `127.0.0.1` 에만 열려 있어 같은 와이파이의 휴대폰은 닿지 않는다(docker 스킬 "호스트 포트").
- **받기 화면까지 볼 때**(업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기] — 아래 "사내 배포"): vite 개발 서버(5173 — `/api` 를 백엔드로 넘긴다)를 가리킨다.
에뮬레이터 `http://10.0.2.2:5173/api`, USB 실기기 `adb reverse tcp:5173 tcp:5173` 뒤 `http://localhost:5173/api`.
[새 버전 받기]는 `API_URL` 의 출처에 `/app` 을 붙여 열기 때문에, 백엔드(3000)를 바로 가리키면 백엔드의 404 가 열린다. 받기 화면은 휴대폰 브라우저에서 웹메일에 로그인해야 보인다.
- 개발 PC 의 안드로이드 도구: Android Studio 의 JDK 21 · SDK 36 · NDK 28.2(Flutter 기본)로 빌드된다. SDK 라이선스 동의(`flutter doctor --android-licenses`)는 사람이 직접 한다.
## Flutter 판
@@ -36,14 +40,22 @@ flutter run # 에뮬레이터 · USB 실기기
## 빌드
```bash
flutter build apk --debug # 2~3분 · 약 160MB (build/ 는 git 이 무시한다)
flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 키가 먼저다
flutter build apk --debug # 2~3분 · 약 160MB (build/ 는 git 이 무시한다) — 개발 · 시험용, debug 키로 서명한다
flutter build apk --release # 직원에게 나눠 주는 판 — 우리 서명 키 · google-services.json · 운영 API_URL(https://<포털>/api)이 먼저다(아래 "사내 배포")
```
- release 매니페스트(`android/app/src/main/AndroidManifest.xml`)에 INTERNET 권한이 있어야 네트워크가 된다(flutter create 기본은 debug · profile 에만 둔다).
- **release 빌드는 `android/app/google-services.json` 이 없으면 멈춘다**(아래 "새 메일 알림") — 배포 판이 새 메일 알림 없이 나가지 않게. debug 빌드는 없어도 된다.
Gradle 이 실행할 작업 그래프를 본다 — `flutter build apk` · `appbundle` 뿐 아니라 `./gradlew assemble` · `bundle` · `build`(모든 변형) · 줄여 쓴 이름(`aR`)도 멈춘다.
debug · release 를 다른 Firebase 프로젝트로 나누려면(`app/src/debug` · `app/src/release` 에 따로) `build.gradle.kts` 의 조건도 함께 바꾼다(지금은 `app/google-services.json` 하나만 본다).
- **release 빌드는 아래 셋 가운데 하나라도 갖추지 못하면 멈추고, 빠진 것을 한 번에 알린다**(`android/app/build.gradle.kts` 맨 아래). debug · profile 빌드는 셋 없이도 된다.
- `android/app/google-services.json`(아래 "새 메일 알림") — 배포 판이 새 메일 알림 없이 나가지 않게.
- 우리 서명 키 — `android/key.properties` 와 그것이 가리키는 keystore(아래 "사내 배포"). 파일이 없거나 · 칸이 비었거나 · keystore 파일이 없으면 멈춘다.
flutter create 기본처럼 debug 키로 조용히 서명하지 않는다 — debug 키로 서명한 판을 깐 휴대폰에는 우리 키의 다음 판이 덮어 설치되지 않는다. `flutter run --release` 도 키가 든다.
- 운영 API 주소 — 앱에 들어가는 `flutter/.env` 의 `API_URL` 이 운영 포털의 API 주소(`https://<포털>/api`, 끝의 `/` 하나까지)여야 한다:
`https://` · 개발 주소(localhost · `.localhost` · `.test` 로 끝나는 이름 · 127.0.0.0/8 · `::1` · 0.0.0.0 · 에뮬레이터의 10.0.2.2)가 아님 · 경로 `/api` ·
사용자 정보 · 쿼리 · 조각 없음(APK 안의 `.env` 는 누구나 풀어 볼 수 있다) · 앞뒤 빈칸 없음(앱은 읽은 값을 그대로 쓴다 — 따옴표 안의 빈칸은 떼지 않는다).
`.env` 가 없거나 · `API_URL` 이 없거나 비었거나 · 읽지 못해도 멈춘다(값이 없으면 앱은 개발 주소로 돈다). 앱과 같은 규칙(flutter_dotenv 5.2.1 의 Parser — 주석 · `export` · 따옴표 · `$이름`)으로 읽고, 알리는 글에는 주소 값을 싣지 않는다(무엇이 틀렸는지만).
- Gradle 이 실행할 작업 그래프를 본다 — `flutter build apk --release` 뿐 아니라 `./gradlew assemble` · `bundle` · `build`(모든 변형) · 줄여 쓴 이름(`aR`)도 멈춘다.
- debug · release 를 다른 Firebase 프로젝트로 나누려면(`app/src/debug` · `app/src/release` 에 따로) `build.gradle.kts` 의 조건도 함께 바꾼다(지금은 `app/google-services.json` 하나만 본다).
- CI(`ci-flutter.yml`)는 analyze · test 만 돌고 안드로이드를 빌드하지 않는다 — 서명 키가 없어도 CI 는 그대로다.
## 새 메일 알림 (Firebase Cloud Messaging)
@@ -68,15 +80,96 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명
봉투로 바꾸려면 이 다섯 장만 같은 이름으로 바꾼다 — 매니페스트의 기본 아이콘(`default_notification_icon`)이 이 이름 하나를 가리킨다.
서버 메시지는 아이콘 · 색을 싣지 않는다 — 휴대폰이 매니페스트의 기본값(아이콘 · `default_notification_color` · 채널 `new_mail`)으로 그린다.
## 배포 준비
## 사내 배포 (안드로이드 APK — 2026-11-08)
- **release 서명 키는 저장소 밖의 비밀이다.** 지금 `build.gradle.kts` 의 release 는 debug 키로 서명한다 — 배포 전에 업로드 키(keystore)를 만들고 `key.properties` 로 읽게 한다. keystore · `key.properties` 는 커밋하지 않고(`android/.gitignore` 가 무시한다) 비밀번호 관리자에 둔다(잃으면 같은 앱으로 업데이트를 올릴 수 없다 — Play 앱 서명을 켜 두면 업로드 키는 다시 받을 수 있다).
- **Play 데이터 보안 신고**: 새 메일 알림 때문에 Firebase 설치 ID(FID)를 서버에 보낸다 — Play Console 의 데이터 보안 양식에 "기기 또는 기타 ID"(수집 · 앱 기능, 공유하지 않음 · 전송 중 암호화)로 적는다.
메일 내용 · 주소는 알림에 싣지 않는다(글은 "새 메일 N통"뿐). 광고 ID 는 쓰지 않는다(매니페스트에서 뺐다 — `tools:node="remove"`).
- **안드로이드: Play 내부 테스트** — Play Console 계정(1회 25달러, 법인 계정은 D-U-N-S 번호), 테스터 100명까지 메일 목록으로 · 심사 없이 · 업데이트가 저절로. 식별자(`kr.it.arasystem.app`)는 올린 뒤 바꿀 수 없다.
APK 를 직접 나눠 주는 길도 있지만 Google 의 "인증된 개발자만 설치" 정책(2026-09 부터 나라별)이 한국에 언제 걸리는지 확인이 필요하다.
- **iOS: 맥 · Apple 개발자 계정이 먼저다** — 빌드 · 서명 · 시뮬레이터가 모두 맥(Xcode)에서만 되고, 배포 · 푸시(APNs 키)는 Apple Developer Program(연 99달러, 법인은 D-U-N-S)이 든다. 내부 배포는 TestFlight 내부 테스터(빌드 90일 만료).
맥에서 처음 빌드할 때 `ios/Runner/Info.plist` 에 `CFBundleLocalizations`(`ko`)가 있는지 확인한다 — 없으면 복사 · 붙여 넣기 같은 시스템 글이 한국어로 나오지 않는다. bundle id 는 `kr.it.arasystem.app` 으로 맞춰 두었다(맥에서 확인 전).
앱은 스토어에 올리지 않는다. 우리 키로 서명한 release APK 를 서버에 올리면, 웹메일에 로그인한 직원이 포털의 휴대폰 앱 받기 화면(`<포털>/app`)에서 받아 설치한다
(PC 의 웹 설정 › 계정 › [휴대폰 앱]의 QR 코드가 그 주소다). 앱의 업데이트 화면과 설정 › 앱 정보의 [새 버전 받기]도 같은 화면을 휴대폰 브라우저로 연다 —
주소는 `.env` 의 `API_URL` 에서 `/api` 를 뗀 출처 + `/app` 이다(같은 주소 배포 — `lib/core/app_download/`, 짝: 웹 `MOBILE_APP_PATH`).
### 1. 서명 키 만들기 (처음 한 번 — 사람이 한다)
keytool 은 Android Studio 에 든 JDK 의 것을 쓴다(`jbr\bin`). 키는 **저장소 밖**에 만든다. keytool 이 keystore 비밀번호를 두 번 묻는다.
```powershell
New-Item -ItemType Directory -Force "$env:USERPROFILE\arasystem-signing" | Out-Null
& "C:\Program Files\Android\Android Studio\jbr\bin\keytool.exe" -genkeypair -v -keystore "$env:USERPROFILE\arasystem-signing\arasystem-release.jks" -alias arasystem -keyalg RSA -keysize 4096 -validity 10000 -dname "CN=ARA SYSTEM, C=KR"
```
- `-dname` 은 APK 의 인증서에 그대로 보이는 이름이다 — 회사 이름을 넣으려면 `O=<회사 이름>` 을 더한다. 빼면 keytool 이 이름 · 조직 같은 칸을 차례로 묻는다.
- 지금 keytool 의 기본 형식(PKCS12)은 키 비밀번호를 따로 묻지 않는다 — 아래 `keyPassword` 에는 `storePassword` 와 같은 값을 적는다.
비밀번호는 영문 · 숫자로 길게 만든다(`key.properties` 는 `\` 를 이스케이프로 읽는다).
- 유효 기간은 10000일(27년 남짓)이다 — 이 키로 앱을 계속 올리므로 짧게 잡지 않는다.
- **keystore 파일과 비밀번호는 비밀번호 관리자에 함께 두고**(파일은 첨부로) 오프라인 사본을 하나 더 둔다. 저장소 · 메일 · 메신저에는 올리지 않는다 — 이 키를 가진 사람은 우리 앱처럼 보이는 판을 만들 수 있다.
- **이 키는 바꿀 수 없다.** 잃거나 새로 만들면 그 뒤의 판은 직원 휴대폰의 앱 위에 설치되지 않는다 — 직원 모두가 앱을 지우고 새로 설치해야 한다(로그인 · 새 메일 알림 설정도 처음부터).
### 2. key.properties
`flutter/android/key.properties` 를 이 꼴로 만든다 — 커밋하지 않는다(`android/.gitignore` 가 `key.properties` · `*.jks` · `*.keystore` 를 무시한다).
```properties
storeFile=C:/Users/<이름>/arasystem-signing/arasystem-release.jks
storePassword=<keystore 비밀번호>
keyAlias=arasystem
keyPassword=<storePassword 와 같은 값>
```
- 경로는 절대 경로로, `\` 대신 `/` 로 적는다(상대 경로면 `android/` 기준).
- 파일이 없거나 · 칸이 비었거나 · keystore 파일이 없으면 release 빌드가 그 까닭을 알리고 멈춘다(위 "빌드"). 비밀번호 · 별칭이 틀리면 Gradle 의 서명 단계가 실패한다.
### 3. 버전 올리기
- `pubspec.yaml` 의 `version: <이름>+<숫자>` 를 판마다 올린다 — 예 `1.0.0+1` → `1.0.1+2`.
- 이름(`1.0.1` — versionName): 직원에게 보이는 판(설정 › 앱 정보 · 받기 화면) · User-Agent · 서버의 최소 판과 견주는 값이다. 숫자 셋까지 · 칸마다 4자리까지(서버의 판 칸과 같다).
- 숫자(`+2` — versionCode): **판마다 1 이상 올린다.** 숫자가 낮은 판은 깔린 앱 위에 설치되지 않는다.
- **꼭 필요한 업데이트**(옛 앱으로는 쓸 수 없게 서버를 바꾸는 배포)면 서버 `backend/src/modules/app-config/app-config.constants.ts` 의 `MIN_SUPPORTED_APP_VERSION.android` 를 새 판 이름으로 올린다 —
그 서버가 배포되면 옛 앱은 어느 화면에서든 업데이트 화면이 된다. **새 APK 를 먼저 서버에 올린 뒤** 그 서버를 배포한다(업데이트 화면의 [새 버전 받기]가 받을 판이 있게).
### 4. release 빌드
- **`.env` 는 앱 안에 그대로 들어간다** — release 를 만들 때는 `flutter/.env` 의 `API_URL` 을 운영 주소(`https://portal.arasystem.it.kr/api` 꼴 — 포털 출처 + `/api`)로 둔 채 만들고, 다 만든 뒤 개발 주소로 되돌린다.
`API_URL` 의 출처가 곧 [새 버전 받기]가 여는 받기 화면의 출처다. 개발 주소 · http 주소인 채로 release 를 부르면 Gradle 이 멈춘다(위 "빌드").
- **release 확인(`android/app/build.gradle.kts` — 서명 키 · `google-services.json` · API 주소 판정)을 고쳤으면** key.properties 없이(또는 `.env` 가 개발 주소인 채로)
`flutter build apk --release` 를 불러 본다 — 빠진 것이 그 글로 한 번에 나오며 멈추는지, 글에 주소 값이 없는지 본다(판정 함수는 Gradle 스크립트라 저장소의 시험이 없다).
```powershell
cd flutter
flutter build apk --release
Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk
```
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
- 올리기 전에 본다:
- 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다).
- 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의
`versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`).
- 운영 서버를 가리키는지 · 판이 맞는지 — 휴대폰 하나에 설치해 로그인해 보고, **설정 › 앱 정보의 앱 버전이 파일 이름의 버전과 같은지** 본다.
운영 도구는 APK 안의 versionName 을 보지 않고 파일 이름 · 운영자가 넣은 버전을 믿는다 — 판이 어긋나면 받기 화면에 틀린 버전이 보이고, 최소 판을 올린 뒤에도 업데이트 화면이 풀리지 않을 수 있다.
- 파일 지문 — `Get-FileHash <파일> -Algorithm SHA256` 를 적어 두고, 서버에 올린 뒤 운영 도구가 보이는 값과 견준다.
### 5. 서버에 올리기
파일을 서버 B 로 옮긴 뒤 `sudo docker cp ARA-SYSTEM-<버전>.apk <PROJECT_NAME>-backend:/tmp/` → 운영 도구(`sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js`)의 「휴대폰 앱 올리기」. 자세한 차례는 mail/README(운영).
### 6. 설치 · 업데이트
- 직원은 휴대폰 브라우저로 받기 화면에 들어가 받고 설치한다(설치 차례 글은 그 화면에 있다 — 웹 `frontend/src/shared/constants/mobile-app.ts`). 새 판도 같은 곳에서 받아 쓰던 앱 위에 설치하면 로그인 · 설정이 그대로다.
- **서명이 다른 시험판이 깔린 휴대폰은 먼저 그 앱을 지우고 설치한다** — `flutter run` · debug APK(debug 키)나 다른 키로 만든 판 위에는 설치되지 않는다. 지우면 그 휴대폰의 로그인 · 알림 설정은 처음부터다.
- 받기 · 설치 때 뜨는 창의 글(받기 경고 · 알 수 없는 앱 설치 허용 · Play 프로텍트 경고)은 브라우저 · 제조사마다 다르다 — 실기기로 한 번씩 거쳐 본다(웹 보드 `SettingsMobileApp` "구현 때 맞출 것").
### 7. Google 개발자 인증 (정할 것)
Google 이 인증된 개발자의 앱만 설치되게 바꾼다 — 스토어 밖에서 받은 APK 도 그렇다. 일정은 2026-09-30 첫 네 나라, 2027 년 전 세계다(한국은 전 세계로 넓힐 때).
그 전에 개발자 인증(신원 확인 · 앱 등록)을 받을지 정한다 — 받지 않으면 그때부터 직원 휴대폰에 새로 설치되지 않을 수 있다.
### 개인정보 메모
새 메일 알림 때문에 Firebase 설치 ID(FID)를 서버에 보낸다(전송 중 암호화 · 다른 곳에 주지 않는다). 메일 내용 · 주소는 알림에 싣지 않는다(글은 "새 메일 N통"뿐).
광고 ID 는 쓰지 않는다(매니페스트에서 뺐다 — `tools:node="remove"`).
### iOS
**맥 · Apple 개발자 계정이 먼저다** — 빌드 · 서명 · 시뮬레이터가 모두 맥(Xcode)에서만 되고, 배포 · 푸시(APNs 키)는 Apple Developer Program(연 99달러, 법인은 D-U-N-S)이 든다. 내부 배포는 TestFlight 내부 테스터(빌드 90일 만료) 또는 Ad Hoc.
맥에서 처음 빌드할 때 `ios/Runner/Info.plist` 에 `CFBundleLocalizations`(`ko`)가 있는지 확인한다 — 없으면 복사 · 붙여 넣기 같은 시스템 글이 한국어로 나오지 않는다. bundle id 는 `kr.it.arasystem.app` 으로 맞춰 두었다(맥에서 확인 전).
## 보안 메모
+174 -11
View File
@@ -1,3 +1,8 @@
import java.net.InetAddress
import java.net.URI
import java.net.URISyntaxException
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +10,137 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 배포 판(release)의 서명 — 우리 서명 키로만 한다(앱은 스토어에 올리지 않고 사내에 직접 나눠 준다 — flutter/README "사내 배포").
// android/key.properties(storeFile · storePassword · keyAlias · keyPassword)가 저장소 밖의 keystore 를 가리킨다.
// key.properties · keystore 는 커밋하지 않는다(android/.gitignore). 값(비밀번호)은 어떤 오류 글에도 싣지 않는다.
// 키가 갖춰지지 않았으면 release 작업을 멈춘다(맨 아래 확인) — flutter create 기본처럼 debug 키로 조용히 서명하면,
// 그 판을 깐 휴대폰에는 우리 키로 서명한 다음 판을 덮어 설치할 수 없다(앱을 지우고 다시 설치해야 한다).
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
val releaseSigningProblem: String? = run {
if (!keystorePropertiesFile.isFile) return@run "android/key.properties 가 없습니다."
try {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
} catch (e: Exception) {
// .properties 는 \ 를 이스케이프로 읽는다 — Windows 경로의 \u… 는 여기서 실패한다
return@run "android/key.properties 를 읽지 못했습니다. 경로의 \\ 는 / 로 적어 주세요."
}
val blank = listOf("storeFile", "storePassword", "keyAlias", "keyPassword")
.filter { keystoreProperties.getProperty(it).isNullOrBlank() }
if (blank.isNotEmpty()) {
return@run "android/key.properties 에 비어 있는 칸이 있습니다: ${blank.joinToString(", ")}."
}
// 상대 경로는 android/ 기준(key.properties 가 있는 곳)이다 — 절대 경로로 적기를 권한다
if (!rootProject.file(keystoreProperties.getProperty("storeFile").trim()).isFile) {
return@run "android/key.properties 의 storeFile 이 가리키는 keystore 파일이 없습니다. 경로의 \\ 는 / 로 적어 주세요."
}
null
}
// ── 배포 판의 API 주소 판정 (순수 함수 — 아래 releaseApiUrlProblem 이 부른다) ──
// 앱 안에 그대로 들어가는 flutter/.env(pubspec 의 asset)의 API_URL 을 앱과 같은 규칙으로 읽고, 운영 포털의 API 주소인지 본다.
// Gradle 스크립트라 저장소의 시험(flutter test · CI 는 Gradle 을 돌리지 않는다)으로는 돌리지 못한다 — 고치면 flutter/README "사내 배포 › 4. release 빌드" 의
// 확인(키 없이 · 개발 주소인 채로 `flutter build apk --release`)으로 다시 본다. 판정 글에는 주소 값을 싣지 않는다(무엇이 틀렸는지만).
/** 앱이 읽는 키 — 짝: Dart `lib/core/api/api_config.dart` 의 `kApiUrlEnvKey`(없으면 앱은 개발 주소 `kDefaultApiUrl` 로 돈다) */
val apiUrlEnvKey = "API_URL"
private val dotenvComment = Regex("""#[^'"]*$""")
private val dotenvCommentWithQuotes = Regex("""#.*$""")
private val dotenvLeadingExport = Regex("""^ *export ?""")
private val dotenvSurroundQuotes = Regex("""^(["'])(.*?[^\\])\1""")
private val dotenvBashVar = Regex("""(\\)?(\$)(?:\{)?([a-zA-Z_][\w]*)+(?:\})?""")
/** Dart 의 `String.trim` — 공백과 함께 BOM(U+FEFF)도 뗀다 */
fun dartTrim(text: String): String = text.trim { it.isWhitespace() || it == '\uFEFF' }
/**
* flutter_dotenv 5.2.1 의 `Parser.parse` 를 옮겼다 — 앱이 `.env` 를 읽는 규칙 그대로(줄은 `\n` 으로만 나눈다).
* 같은 키는 처음 것, 따옴표 밖의 `#` 주석 · 앞의 `export` · 둘러싼 따옴표를 떼고, 홑따옴표가 아니면 앞에서 읽은 `$이름` 을 채운다.
* 짝: `pubspec.lock` 의 flutter_dotenv 판 — 판을 올리면 그 판의 `lib/src/parser.dart` 와 다시 견준다
*/
fun parseDotenv(lines: List<String>): Map<String, String> {
val out = LinkedHashMap<String, String>()
for (line in lines) {
val stripped = dartTrim(line.replace(dotenvComment, ""))
if (stripped.isEmpty() || !stripped.contains('=')) continue
val idx = stripped.indexOf('=')
val key = dartTrim(stripped.substring(0, idx).replace(dotenvLeadingExport, ""))
if (key.isEmpty()) continue
val rhs = dartTrim(stripped.substring(idx + 1))
val quoted = dotenvSurroundQuotes.find(rhs)
val quote = quoted?.groupValues?.get(1).orEmpty()
var value = quoted?.groupValues?.get(2) ?: dartTrim(dartTrim(rhs.replace(dotenvCommentWithQuotes, "")))
if (quote == "'") {
value = value.replace("\\'", "'")
} else {
if (quote == "\"") value = value.replace("\\\"", "\"").replace("\\n", "\n")
value = dotenvBashVar.replace(value) { m ->
if (m.groupValues[1] == "\\") m.value else out[m.groupValues[3]].orEmpty()
}.replace("\\\$", "\$")
}
out.putIfAbsent(key, value)
}
return out
}
/**
* 개발 주소 — localhost · `.localhost` · `.test` 로 끝나는 이름 · 루프백(127.0.0.0/8 · ::1) · 0.0.0.0 · 안드로이드 에뮬레이터의 PC 주소 10.0.2.2.
* [host] 는 소문자 · 대괄호 없이 받는다. IPv6 는 IP 글자일 때만 읽는다(이름을 묻지 않는다 — DNS 없음)
*/
fun isDevelopmentHost(host: String): Boolean {
if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".test")) return true
if (host == "10.0.2.2" || host == "0.0.0.0") return true
if (Regex("""127(\.\d{1,3}){3}""").matches(host)) return true
if (!host.contains(':')) return false
return try {
InetAddress.getByName(host).let { it.isLoopbackAddress || it.isAnyLocalAddress }
} catch (e: Exception) {
false
}
}
/** API 주소의 경로로 받는 것 — 같은 주소 배포라 늘 포털 출처의 `/api`(끝의 `/` 하나까지) */
val releaseApiPaths = setOf("/api", "/api/")
/**
* 배포 판에 넣을 API 주소의 문제 — 없으면 null. 운영 포털의 API 주소(`https://<포털>/api`)여야 한다:
* 앞뒤 빈칸 없음(앱은 파서가 준 값을 그대로 쓴다 — 따옴표 안의 빈칸은 파서가 떼지 않는다) · https · 개발 주소 아님 ·
* 사용자 정보 · 쿼리 · 조각 없음(APK 안의 .env 는 누구나 풀어 볼 수 있고, 사용자 정보는 dart:io 가 Basic 으로 보낸다) · 경로 `/api`.
* 글은 고정 글이다 — 주소 값을 싣지 않는다(무엇이 틀렸는지만)
*/
fun apiUrlProblemForRelease(apiUrl: String?): String? {
if (apiUrl.isNullOrEmpty()) return "flutter/.env 에 API_URL 이 없습니다."
if (apiUrl != dartTrim(apiUrl)) return "flutter/.env 의 API_URL 값 앞뒤에 빈칸이 있습니다."
val uri = try {
URI(apiUrl)
} catch (e: URISyntaxException) {
return "flutter/.env 의 API_URL 을 주소로 읽지 못했습니다."
}
if (!uri.scheme.equals("https", ignoreCase = true)) return "flutter/.env 의 API_URL 이 https 주소가 아닙니다."
val host = uri.host.orEmpty().removePrefix("[").removeSuffix("]").trimEnd('.').lowercase()
if (host.isEmpty()) return "flutter/.env 의 API_URL 에서 호스트를 읽지 못했습니다."
if (isDevelopmentHost(host)) return "flutter/.env 의 API_URL 이 개발 주소입니다."
if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) {
return "flutter/.env 의 API_URL 에 사용자 정보 · 쿼리 · 조각이 있습니다."
}
if (uri.rawPath !in releaseApiPaths) return "flutter/.env 의 API_URL 경로가 /api 가 아닙니다."
return null
}
// ── 배포 판의 API 주소 판정 끝 ──
// 앱에 들어갈 .env 를 지금 읽는다(Flutter 가 같은 파일을 asset 으로 싣는다). 없거나 읽지 못해도 release 를 막는다 — 값이 없으면 앱은 개발 주소로 돈다
val appEnvFile = rootProject.file("../.env")
val releaseApiUrlProblem: String? = if (!appEnvFile.isFile) {
"flutter/.env 가 없습니다."
} else {
try {
apiUrlProblemForRelease(parseDotenv(appEnvFile.readText(Charsets.UTF_8).split("\n"))[apiUrlEnvKey])
} catch (e: Exception) {
"flutter/.env 를 읽지 못했습니다."
}
}
android {
namespace = "kr.it.arasystem.app"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +166,22 @@ android {
versionName = flutter.versionName
}
signingConfigs {
// 키가 갖춰졌을 때만 만든다 — 빈 값으로 만들면 debug 빌드까지 설정 단계에서 실패한다
if (releaseSigningProblem == null) {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile").trim())
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias").trim()
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
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")
// 우리 키 — 갖춰지지 않았으면 null(서명 없음)이고, 맨 아래 확인이 release 작업을 멈춘다
signingConfig = signingConfigs.findByName("release")
}
}
}
@@ -45,19 +192,35 @@ flutter {
// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림").
// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다.
// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게.
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다.
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다.
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 아래 확인의 조건도 함께 바꾼다.
val firebaseConfig = file("google-services.json")
if (firebaseConfig.exists()) {
apply(plugin = "com.google.gms.google-services")
} else {
}
// 배포 판(release 변형)에 갖출 것 — 하나라도 빠졌으면 release 작업이 하나라도 있을 때 멈추고, 빠진 것을 한 번에 알린다.
// - google-services.json: 새 메일 알림 없이 나가지 않게
// - 우리 서명 키(key.properties): debug 키로 서명한 판이 나가지 않게(위 releaseSigningProblem)
// - 운영 API 주소(flutter/.env 의 API_URL): 개발 주소 · http 로 만든 판이 나가지 않게(위 releaseApiUrlProblem — 글에 주소 값을 싣지 않는다)
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다. debug · profile 빌드는 그대로 된다.
val releaseBlockers: List<String> = listOfNotNull(
if (firebaseConfig.exists()) {
null
} else {
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\")."
},
releaseSigningProblem?.let {
"$it 배포 판은 우리 서명 키로만 서명합니다 (flutter/README \"사내 배포\")."
},
releaseApiUrlProblem?.let {
"$it 배포 판은 운영 포털의 API 주소로만 만듭니다 (flutter/README \"사내 배포\")."
},
)
if (releaseBlockers.isNotEmpty()) {
gradle.taskGraph.whenReady {
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
throw GradleException(
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").",
)
throw GradleException(releaseBlockers.joinToString("\n"))
}
}
}
+2 -12
View File
@@ -44,23 +44,14 @@ String _clean(String? value) {
: kept;
}
/// 이 기기의 앱 판 · 패키지 이름(업데이트 화면의 Play 스토어 주소) · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번.
/// 이 기기의 앱 판 · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번.
/// 기기 정보를 읽지 못하면 아는 것만 싣는다.
Future<
({
String? appVersion,
String? packageName,
String userAgent,
int? androidSdkInt,
})
>
Future<({String? appVersion, String userAgent, int? androidSdkInt})>
loadAppDeviceInfo() async {
String? appVersion;
String? packageName;
try {
final PackageInfo info = await PackageInfo.fromPlatform();
appVersion = info.version;
packageName = info.packageName.isEmpty ? null : info.packageName;
} on PlatformException {
appVersion = null;
}
@@ -69,7 +60,6 @@ loadAppDeviceInfo() async {
);
return (
appVersion: appVersion,
packageName: packageName,
userAgent: device.userAgent,
androidSdkInt: device.androidSdkInt,
);
@@ -0,0 +1,73 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/app_download/app_download_link.dart';
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다
typedef AppDownloadLaunch = Future<bool> Function(Uri url, LaunchMode mode);
Future<bool> _launchExternal(Uri url, LaunchMode mode) =>
launchUrl(url, mode: mode);
/// [새 버전 받기] — 포털의 휴대폰 앱 받기 화면(`app_download_link.dart`)을 바깥 브라우저로 연다.
/// 업데이트 화면과 설정 › 앱 정보가 이 런처 하나를 함께 쓴다(같은 주소 · 같은 실패 알림).
///
/// - 바깥 앱(`LaunchMode.externalApplication` — 휴대폰의 기본 브라우저)으로 연다. 받기 화면은 로그인한 직원만 받으므로
/// 브라우저의 웹메일 로그인을 쓴다(앱의 토큰은 넘기지 않는다).
/// - `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — 인증 앱 열기와 같은 규칙).
/// 못 열면 플러그인 예외를 버리고 거짓 — 화면이 [failureMessage] 를 알린다
class AppDownloadLauncher {
AppDownloadLauncher({
required String apiBaseUrl,
AppDownloadLaunch launch = _launchExternal,
}) : pageUrl = appDownloadPageUrlOf(apiBaseUrl),
_launch = launch;
/// 받기 화면 주소 — 앱의 API 주소로 만들지 못했으면 null(열지 않고 알린다)
final Uri? pageUrl;
final AppDownloadLaunch _launch;
/// 열었으면 참
Future<bool> open() async {
final Uri? url = pageUrl;
if (url == null) return false;
try {
return await _launch(url, LaunchMode.externalApplication);
} on Object {
// PlatformException(받을 앱 없음 · 화면 없음) 등 — 화면은 주소를 알려 직접 들어가게 한다
return false;
}
}
/// 열지 못했을 때의 알림 글 — 직원이 브라우저에 직접 넣을 주소(스킴 없이)를 담는다
String get failureMessage {
final Uri? url = pageUrl;
return url == null
? AppDownloadTexts.openFailedNoAddress
: AppDownloadTexts.openFailed(appDownloadPageAddressOf(url));
}
}
/// 받기 화면 런처 — 앱의 API 주소(`apiConfigProvider`)로 주소를 만든다. 시험은 가짜로 덮어쓴다
final Provider<AppDownloadLauncher> appDownloadLauncherProvider =
Provider<AppDownloadLauncher>(
(Ref ref) =>
AppDownloadLauncher(apiBaseUrl: ref.watch(apiConfigProvider).baseUrl),
);
/// [새 버전 받기]를 누른 뒤 — 받기 화면을 열고, 열지 못하면 주소를 담아 알린다(두 화면이 같은 알림을 쓴다).
/// [messenger] 는 누른 화면에서 기다리기 전에 잡아 둔다(브라우저가 뜨는 동안 화면이 바뀔 수 있다)
Future<void> openAppDownloadPage(
WidgetRef ref,
ScaffoldMessengerState messenger,
) async {
final AppDownloadLauncher launcher = ref.read(appDownloadLauncherProvider);
if (await launcher.open()) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: KeepAllText(launcher.failureMessage)));
}
@@ -0,0 +1,30 @@
/// 포털의 휴대폰 앱 받기 화면 주소 — 앱을 스토어에 올리지 않고 사내에서 직접 나눠 주는 곳(2026-11-08).
/// 앱의 업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기]가 이 주소를 바깥 브라우저로 연다(앱 보드 `App1Push` ⑩ · ④).
///
/// 같은 주소 배포라 앱의 API 주소(`.env` 의 `API_URL` — `ApiConfig.baseUrl`)는 늘 포털 출처의 `/api` 다.
/// 그래서 받는 곳 주소를 서버에서 받지 않고(앱 설정 계약은 싣지 않는다) API 주소의 출처에 받기 화면 경로를 붙여 만든다.
library;
/// 받기 화면 경로 — 짝: 웹 `frontend/src/shared/constants/mobile-app.ts` 의 `MOBILE_APP_PATH`(한쪽을 바꾸면 둘 다)
const String kAppDownloadPagePath = '/app';
/// API 주소에서 받기 화면 주소를 만든다 — `https://portal.arasystem.it.kr/api` → `https://portal.arasystem.it.kr/app`.
///
/// 출처(스킴 · 호스트 · 포트)만 남긴다: API 경로(`/api`) · 쿼리 · 조각 · 사용자 정보는 버리고, 스킴의 기본 포트는 적지 않는다.
/// http · https 주소가 아니거나 호스트가 없으면 null — 화면은 주소 없이 알린다
Uri? appDownloadPageUrlOf(String apiBaseUrl) {
final Uri? api = Uri.tryParse(apiBaseUrl.trim());
if (api == null) return null;
if (api.scheme != 'http' && api.scheme != 'https') return null;
if (api.host.isEmpty) return null;
return Uri(
scheme: api.scheme,
host: api.host,
port: api.hasPort ? api.port : null,
path: kAppDownloadPagePath,
);
}
/// 알림에 보이는 주소 — 스킴 없이(`portal.arasystem.it.kr/app` · `10.0.2.2:5173/app`).
/// 직원이 휴대폰 브라우저의 주소 칸에 그대로 넣는다
String appDownloadPageAddressOf(Uri url) => '${url.authority}${url.path}';
@@ -0,0 +1,16 @@
/// 휴대폰 앱 받기 화면을 여는 [새 버전 받기]의 글 — 업데이트 화면(`features/app_update`)과 설정 › 앱 정보(`features/settings`)가 함께 쓴다.
///
/// 앱 보드 `App1Push` ⑩ · ④ 앱 정보 절(2026-11-08 확정 — 캔버스 v68)의 글 그대로다(디자인 README 「화면 문구 — 사용자에게 말하듯」).
abstract final class AppDownloadTexts {
/// 업데이트 화면의 단추 · 설정 › 앱 정보의 줄
static const String getNewVersion = '새 버전 받기';
/// 브라우저를 열지 못했을 때(알림) — [address] 는 받기 화면 주소를 스킴 없이(`portal.arasystem.it.kr/app`).
/// 직원이 휴대폰 브라우저에 직접 넣는다
static String openFailed(String address) =>
'브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 $address에 들어가 새 버전을 받아 주세요.';
/// 받기 화면 주소를 만들지 못했을 때(앱의 API 주소가 http · https 주소가 아니다 — 보드에 없는 경우.
/// 그런 판은 서버에도 닿지 못해 업데이트 화면 · 설정 탭에 오지 않는다)
static const String openFailedNoAddress = '브라우저를 열지 못했습니다. 관리자에게 문의해 주세요.';
}
@@ -1,7 +1,7 @@
/// 앱 전체에서 쓰는 이름 · 식별 값.
///
/// 식별자(Android applicationId · iOS bundle id `kr.it.arasystem.app`)는 플랫폼 설정 파일에 있다 —
/// 스토어에 올린 뒤에는 바꿀 수 없다.
/// 직원 휴대폰에 설치한 뒤에는 바꿀 수 없다(식별자가 다르면 다른 앱이라 새 판이 그 위에 설치되지 않는다).
library;
/// 홈 화면 · 작업 전환 화면에 보이는 앱 이름
@@ -5,9 +5,3 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
final Provider<String?> appVersionProvider = Provider<String?>(
(Ref ref) => null,
);
/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다.
/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다)
final Provider<String?> appPackageNameProvider = Provider<String?>(
(Ref ref) => null,
);
@@ -1,52 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다
typedef StoreLaunch = Future<bool> Function(Uri url, LaunchMode mode);
Future<bool> _launchExternal(Uri url, LaunchMode mode) =>
launchUrl(url, mode: mode);
/// Play 스토어의 이 앱 쪽 주소(앱이 없을 때 여는 웹 주소)
const String kPlayStoreWebDetails =
'https://play.google.com/store/apps/details';
/// [Play 스토어 열기] — 이 앱의 Play 스토어 쪽을 연다(업데이트 화면).
///
/// 먼저 Play 스토어 앱(`market://details?id=…` — 브라우저가 아닌 앱만), 열리지 않으면 웹 주소를 바깥 앱(브라우저)으로.
/// 패키지 이름은 앱 정보에서 읽은 값이다(하드코딩하지 않는다). `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소를 로그에 남긴다 —
/// 인증 앱 열기와 같은 규칙). 못 열면 플러그인 예외를 버리고 거짓 — 화면이 알린다
class AppStoreLauncher {
const AppStoreLauncher({StoreLaunch launch = _launchExternal})
: _launch = launch;
final StoreLaunch _launch;
/// 열었으면 참
Future<bool> open(String packageName) async {
final Uri market = Uri(
scheme: 'market',
host: 'details',
queryParameters: <String, String>{'id': packageName},
);
if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) {
return true;
}
final Uri web = Uri.parse(
kPlayStoreWebDetails,
).replace(queryParameters: <String, String>{'id': packageName});
return _tryLaunch(web, LaunchMode.externalApplication);
}
Future<bool> _tryLaunch(Uri url, LaunchMode mode) async {
try {
return await _launch(url, mode);
} on Object {
return false;
}
}
}
/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다
final Provider<AppStoreLauncher> appStoreLauncherProvider =
Provider<AppStoreLauncher>((Ref ref) => const AppStoreLauncher());
@@ -1,22 +1,18 @@
/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-07 확정)의 글 그대로다. 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`.
/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-08 사내 직접 배포로 고쳐 확정 — 캔버스 v68)의 글 그대로다.
/// 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`, [새 버전 받기]와 브라우저를 열지 못했을 때의 알림은
/// 설정 › 앱 정보와 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class AppUpdateTexts {
static const String message =
'지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.';
static const String openStore = 'Play 스토어 열기';
'지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. 새 버전을 받아 설치한 뒤 다시 열어 주세요.';
/// 지금 쓰는 판 — "지금 쓰는 버전 1.0.0"
static String currentVersion(String version) => '지금 쓰는 버전 $version';
/// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처
static const String supportQuestion = 'Play 스토어에 업데이트가 보이지 않나요?';
static const String supportQuestion = '새 버전을 받거나 설치하기 어려우신가요?';
/// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴)
static const String supportFallback =
'Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요.';
/// Play 스토어를 열지 못했을 때(알림)
static const String openStoreFailed =
'Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.';
static const String supportFallback = '새 버전을 받거나 설치하기 어려우신가요? 관리자에게 문의해 주세요.';
}
@@ -1,11 +1,12 @@
import 'dart:async';
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
@@ -17,9 +18,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱의 판이 서버의 최소 판보다 낮으면 라우터가 어느 화면에서든 이리로 옮긴다
/// (로그인 전 화면도 — 옛 앱이 로그인 API 를 잘못 부르지 않게. `core/router/session_redirect.dart`).
///
/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄.
/// - [Play 스토어 열기]: Play 스토어 앱 → 안 되면 웹 주소. 열지 못하면 알린다.
/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 업데이트하면 Play 가 앱을 끝내고,
/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [새 버전 받기] · 지금 쓰는 판 · 문의 줄.
/// - [새 버전 받기]: 포털의 휴대폰 앱 받기 화면(`<포털 출처>/app`)을 바깥 브라우저로 연다 — 앱은 스토어에 올리지 않고
/// 사내에서 직접 나눠 준다(2026-11-08). 열지 못하면 그 주소를 담아 알린다(설정 › 앱 정보와 같은 런처 · 알림 — `core/app_download/`).
/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 새 판을 설치하면 안드로이드가 이 앱을 끝내고,
/// 서버가 최소 판을 내리면 다음 확인에서 세션이 고른 화면으로 돌아간다.
class AppUpdatePage extends ConsumerStatefulWidget {
const AppUpdatePage({super.key});
@@ -35,18 +37,8 @@ class _AppUpdatePageState extends ConsumerState<AppUpdatePage> {
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
}
Future<void> _openStore() async {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
final String? packageName = ref.read(appPackageNameProvider);
final bool opened =
packageName != null &&
await ref.read(appStoreLauncherProvider).open(packageName);
if (opened) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: KeepAllText(AppUpdateTexts.openStoreFailed)),
);
void _openDownloadPage() {
unawaited(openAppDownloadPage(ref, ScaffoldMessenger.of(context)));
}
@override
@@ -79,12 +71,12 @@ class _AppUpdatePageState extends ConsumerState<AppUpdatePage> {
],
),
FilledButton(
onPressed: () => unawaited(_openStore()),
onPressed: _openDownloadPage,
child: const Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.s8,
children: <Widget>[
Text(AppUpdateTexts.openStore),
Text(AppDownloadTexts.getNewVersion),
Icon(Icons.open_in_new, size: AppSize.iconSmall),
],
),
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
@@ -18,11 +20,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄).
/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄 · 앱 정보 절).
/// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다).
///
/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · 앱 정보(앱 버전 · 오픈소스 라이선스) ·
/// 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 ·
/// 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
/// [새 버전 받기]는 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 연다(같은 런처 · 같은 실패 알림 — `core/app_download/`).
/// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다).
/// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
class SettingsPage extends ConsumerStatefulWidget {
@@ -65,6 +68,10 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
await ref.read(sessionProvider.notifier).signOut();
}
void _openDownloadPage() {
unawaited(openAppDownloadPage(ref, ScaffoldMessenger.of(context)));
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
@@ -118,6 +125,11 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_VersionRow(version: version),
_LinkRow(
label: AppDownloadTexts.getNewVersion,
opensOutside: true,
onTap: _openDownloadPage,
),
_LinkRow(
label: SettingsTexts.licenses,
onTap: () =>
@@ -321,13 +333,21 @@ class _VersionRow extends StatelessWidget {
}
}
/// 누르면 다른 화면으로 가는 한 줄 — 이름 · 오른쪽 화살표
/// 누르는 한 줄 — 이름 · 오른쪽 끝 아이콘. 앱 안의 다른 화면으로 가면 화살표, 바깥(브라우저)으로 열면 바깥으로 여는 아이콘
/// (보드: 화살표 20 · 바깥 아이콘 18 을 20 칸 가운데에 — 두 아이콘의 가운데가 맞는다)
class _LinkRow extends StatelessWidget {
const _LinkRow({required this.label, required this.onTap});
const _LinkRow({
required this.label,
required this.onTap,
this.opensOutside = false,
});
final String label;
final VoidCallback? onTap;
/// 바깥 앱(브라우저)으로 연다 — 끝 아이콘이 바깥으로 여는 아이콘
final bool opensOutside;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
@@ -352,10 +372,15 @@ class _LinkRow extends StatelessWidget {
children: <Widget>[
Expanded(child: Text(label, style: theme.textTheme.bodyLarge)),
ExcludeSemantics(
child: Icon(
Icons.chevron_right,
size: AppSize.iconMedium,
color: extra.textMuted,
child: SizedBox.square(
dimension: AppSize.iconMedium,
child: Icon(
opensOutside ? Icons.open_in_new : Icons.chevron_right,
size: opensOutside
? AppSize.iconSmall
: AppSize.iconMedium,
color: extra.textMuted,
),
),
),
],
@@ -1,5 +1,6 @@
/// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다.
/// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`).
/// 앱 정보의 [새 버전 받기] 줄과 그 실패 알림은 업데이트 화면과 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다(보드 `App1Push` ④).
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class SettingsTexts {
+2 -8
View File
@@ -41,13 +41,8 @@ Future<void> main() async {
firstRun: PrefsFirstRunMarker(),
);
await tokenStore.prepare();
final ({
String? appVersion,
String? packageName,
String userAgent,
int? androidSdkInt,
})
device = await loadAppDeviceInfo();
final ({String? appVersion, String userAgent, int? androidSdkInt}) device =
await loadAppDeviceInfo();
// 새 메일 알림(FCM) — Firebase 를 띄우고 플러그인의 소식 줄(FID · 받은 알림 · 누른 알림)을 지금 듣는다.
// 띄우지 못하면(google-services.json 없이 빌드 · 5초 넘음) 알림 없이 돈다 — 설정 줄은 "이 휴대폰에서는 받을 수 없음"
@@ -70,7 +65,6 @@ Future<void> main() async {
tokenStoreProvider.overrideWithValue(tokenStore),
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
appPackageNameProvider.overrideWithValue(device.packageName),
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
pickedFileStoreProvider.overrideWithValue(pickedFiles),
pushMessagingProvider.overrideWithValue(pushMessaging),
@@ -0,0 +1,101 @@
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher/url_launcher.dart';
/// [새 버전 받기]의 런처 — 업데이트 화면 · 설정 › 앱 정보가 함께 쓴다(화면은 각 화면 시험이 본다)
void main() {
/// 다른 앱으로 넘긴 주소 · 방식
final List<({Uri url, LaunchMode mode})> launched =
<({Uri url, LaunchMode mode})>[];
setUp(launched.clear);
AppDownloadLauncher launcherOf(
String apiBaseUrl, {
required Future<bool> Function() answer,
}) => AppDownloadLauncher(
apiBaseUrl: apiBaseUrl,
launch: (Uri url, LaunchMode mode) {
launched.add((url: url, mode: mode));
return answer();
},
);
test('받기 화면을 바깥 앱(브라우저)으로 한 번 연다 — 열었으면 참', () async {
final AppDownloadLauncher launcher = launcherOf(
'https://portal.arasystem.it.kr/api',
answer: () async => true,
);
expect(await launcher.open(), isTrue);
expect(launched, hasLength(1));
expect(
launched.single.url,
Uri.parse('https://portal.arasystem.it.kr/app'),
);
expect(launched.single.mode, LaunchMode.externalApplication);
});
test('열지 못하면 거짓 — 알림 글은 받기 화면 주소를 스킴 없이 담는다', () async {
final AppDownloadLauncher launcher = launcherOf(
'https://portal.arasystem.it.kr/api',
answer: () async => false,
);
expect(await launcher.open(), isFalse);
expect(
launcher.failureMessage,
'브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.',
);
});
test('여는 쪽이 던지면(받을 앱 없음) 삼키고 거짓 — 예외의 주소를 밖으로 내보내지 않는다', () async {
final AppDownloadLauncher launcher = launcherOf(
'https://portal.arasystem.it.kr/api',
answer: () async => throw PlatformException(
code: 'ACTIVITY_NOT_FOUND',
message:
'No Activity found to handle https://portal.arasystem.it.kr/app',
),
);
expect(await launcher.open(), isFalse);
expect(launched, hasLength(1));
});
test('API 주소로 받기 화면 주소를 만들지 못하면 열지 않고 거짓 — 주소 없는 알림 글', () async {
final AppDownloadLauncher launcher = launcherOf(
'portal.arasystem.it.kr/api',
answer: () async => true,
);
expect(launcher.pageUrl, isNull);
expect(await launcher.open(), isFalse);
expect(launched, isEmpty);
expect(launcher.failureMessage, AppDownloadTexts.openFailedNoAddress);
});
test('프로바이더는 앱의 API 주소(apiConfigProvider)로 받기 화면 주소를 만든다', () {
final ProviderContainer container = ProviderContainer(
retry: noAutomaticRetry,
overrides: <Override>[
apiConfigProvider.overrideWithValue(
const ApiConfig(baseUrl: 'http://10.0.2.2:5173/api'),
),
],
);
addTearDown(container.dispose);
expect(
container.read(appDownloadLauncherProvider).pageUrl,
Uri.parse('http://10.0.2.2:5173/app'),
);
});
}
@@ -0,0 +1,119 @@
import 'package:arasystem_app/core/app_download/app_download_link.dart';
import 'package:flutter_test/flutter_test.dart';
/// 포털의 휴대폰 앱 받기 화면 주소 — 앱의 API 주소(같은 주소 배포 — 포털 출처의 `/api`)에서 `/api` 를 뗀 출처 + `/app`
void main() {
group('받기 화면 주소', () {
test('운영 — https://portal.arasystem.it.kr/api 의 출처에 /app', () {
expect(
appDownloadPageUrlOf('https://portal.arasystem.it.kr/api'),
Uri.parse('https://portal.arasystem.it.kr/app'),
);
expect(
appDownloadPageUrlOf('https://portal.arasystem.it.kr/api/'),
Uri.parse('https://portal.arasystem.it.kr/app'),
reason: '끝의 / 가 붙어도 같다',
);
expect(
appDownloadPageUrlOf(' https://portal.arasystem.it.kr/api '),
Uri.parse('https://portal.arasystem.it.kr/app'),
reason: '앞뒤 공백은 뗀다',
);
});
test('개발 — 포트는 그대로 둔다(에뮬레이터 · vite 개발 서버)', () {
expect(
appDownloadPageUrlOf('http://10.0.2.2:3000/api'),
Uri.parse('http://10.0.2.2:3000/app'),
);
expect(
appDownloadPageUrlOf('http://10.0.2.2:5173/api'),
Uri.parse('http://10.0.2.2:5173/app'),
);
expect(
appDownloadPageUrlOf('http://localhost:5173/api'),
Uri.parse('http://localhost:5173/app'),
);
expect(
appDownloadPageUrlOf('http://[::1]:3000/api'),
Uri.parse('http://[::1]:3000/app'),
reason: 'IPv6 주소는 대괄호째',
);
});
test('스킴의 기본 포트는 적지 않고, 그 밖의 포트는 남긴다', () {
expect(
appDownloadPageUrlOf('https://portal.arasystem.it.kr:443/api'),
Uri.parse('https://portal.arasystem.it.kr/app'),
);
expect(
appDownloadPageUrlOf('http://portal.arasystem.it.kr:80/api'),
Uri.parse('http://portal.arasystem.it.kr/app'),
);
expect(
appDownloadPageUrlOf('https://portal.arasystem.it.kr:8443/api'),
Uri.parse('https://portal.arasystem.it.kr:8443/app'),
);
});
test('출처만 남긴다 — 쿼리 · 조각 · 사용자 정보는 버리고 대문자는 소문자로', () {
final Uri? url = appDownloadPageUrlOf(
'HTTPS://user:pw@Portal.Arasystem.IT.KR/api?x=1#y',
);
expect(url, Uri.parse('https://portal.arasystem.it.kr/app'));
expect(url!.userInfo, isEmpty);
expect(url.hasQuery, isFalse);
expect(url.hasFragment, isFalse);
expect(url.toString(), 'https://portal.arasystem.it.kr/app');
});
test('http · https 주소가 아니거나 호스트가 없으면 null', () {
for (final String input in <String>[
'',
' ',
'portal.arasystem.it.kr/api',
'/api',
'not a url',
'ftp://portal.arasystem.it.kr/api',
'file:///api',
'market://details?id=kr.it.arasystem.app',
'https:///api',
'http://[::1',
]) {
expect(appDownloadPageUrlOf(input), isNull, reason: input);
}
});
test('경로는 웹의 받기 화면 경로와 같다', () {
expect(kAppDownloadPagePath, '/app');
});
});
group('알림에 보이는 주소 — 스킴 없이', () {
test('호스트 · 포트 · 경로', () {
expect(
appDownloadPageAddressOf(
Uri.parse('https://portal.arasystem.it.kr/app'),
),
'portal.arasystem.it.kr/app',
);
expect(
appDownloadPageAddressOf(Uri.parse('http://10.0.2.2:5173/app')),
'10.0.2.2:5173/app',
);
expect(
appDownloadPageAddressOf(Uri.parse('http://[::1]:3000/app')),
'[::1]:3000/app',
);
});
test('만든 주소를 그대로 보인다', () {
expect(
appDownloadPageAddressOf(
appDownloadPageUrlOf('https://portal.arasystem.it.kr/api')!,
),
'portal.arasystem.it.kr/app',
);
});
});
}
@@ -1,10 +1,12 @@
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -12,19 +14,19 @@ import 'package:url_launcher/url_launcher.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
/// 시험 앱의 패키지 이름
const String _package = 'kr.it.arasystem.app';
/// 운영의 앱 API 주소 — 받기 화면은 그 출처의 `/app`
const String _apiBaseUrl = 'https://portal.arasystem.it.kr/api';
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩(2026-11-08 사내 직접 배포). 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
void main() {
late LoginHarness h;
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다)
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다
final List<({Uri url, LaunchMode mode})> launched =
<({Uri url, LaunchMode mode})>[];
late bool Function(Uri url) opens;
late Future<bool> Function(Uri url) opens;
Future<bool> launch(Uri url, LaunchMode mode) async {
Future<bool> launch(Uri url, LaunchMode mode) {
launched.add((url: url, mode: mode));
return opens(url);
}
@@ -32,7 +34,7 @@ void main() {
setUp(() {
h = LoginHarness();
launched.clear();
opens = (Uri _) => true;
opens = (Uri _) async => true;
h.server.on(
'GET',
ApiPaths.appConfig,
@@ -43,25 +45,24 @@ void main() {
);
});
Future<void> pump(WidgetTester tester, {String? packageName = _package}) =>
Future<void> pump(WidgetTester tester, {String apiBaseUrl = _apiBaseUrl}) =>
h.pump(
tester,
overrides: <Override>[
appVersionProvider.overrideWithValue('1.0.0'),
appPackageNameProvider.overrideWithValue(packageName),
appStoreLauncherProvider.overrideWithValue(
AppStoreLauncher(launch: launch),
appDownloadLauncherProvider.overrideWithValue(
AppDownloadLauncher(apiBaseUrl: apiBaseUrl, launch: launch),
),
],
);
Finder storeButton() =>
find.widgetWithText(FilledButton, AppUpdateTexts.openStore);
Finder downloadButton() =>
find.widgetWithText(FilledButton, AppDownloadTexts.getNewVersion);
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', (
testWidgets('제목 · 글 · [새 버전 받기] · 지금 쓰는 판 · 문의 줄(연락처)', (
WidgetTester tester,
) async {
h.supportContact('정보보안팀 02-1234-5678');
@@ -70,12 +71,21 @@ void main() {
expect(find.byType(AppUpdatePage), findsOneWidget);
expect(find.text(ScreenTitles.update), findsOneWidget);
expect(findCopy(AppUpdateTexts.message), findsOneWidget);
expect(storeButton(), findsOneWidget);
expect(downloadButton(), findsOneWidget);
expect(
find.descendant(
of: downloadButton(),
matching: find.byIcon(Icons.open_in_new),
),
findsOneWidget,
reason: '바깥(브라우저)으로 연다',
);
expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget);
expect(
findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'),
findsOneWidget,
);
expect(launched, isEmpty, reason: '누르기 전에는 열지 않는다');
expect(h.notifier.shownCodes, isEmpty);
});
@@ -86,54 +96,72 @@ void main() {
expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget);
});
testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', (
testWidgets('[새 버전 받기] — 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 한 번 연다', (
WidgetTester tester,
) async {
opens = (Uri url) => url.scheme != 'market';
await pump(tester);
await tester.tap(storeButton());
await tester.pumpAndSettle();
expect(launched, hasLength(2));
expect(launched.first.url.toString(), 'market://details?id=$_package');
expect(launched.first.mode, LaunchMode.externalNonBrowserApplication);
expect(
launched.last.url.toString(),
'https://play.google.com/store/apps/details?id=$_package',
);
expect(launched.last.mode, LaunchMode.externalApplication);
expect(snack(AppUpdateTexts.openStoreFailed), findsNothing);
});
testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async {
await pump(tester);
await tester.tap(storeButton());
await tester.tap(downloadButton());
await tester.pumpAndSettle();
expect(launched, hasLength(1));
expect(launched.single.url.scheme, 'market');
expect(
launched.single.url,
Uri.parse('https://portal.arasystem.it.kr/app'),
);
expect(launched.single.mode, LaunchMode.externalApplication);
expect(find.byType(SnackBar), findsNothing);
expect(find.byType(AppUpdatePage), findsOneWidget, reason: '화면은 그대로');
});
testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async {
opens = (Uri _) => false;
testWidgets('브라우저를 열지 못하면 받기 화면 주소를 스킴 없이 담아 알린다', (
WidgetTester tester,
) async {
opens = (Uri _) async => false;
await pump(tester);
await tester.tap(storeButton());
await tester.tap(downloadButton());
await tester.pumpAndSettle();
expect(launched, hasLength(2));
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
expect(launched, hasLength(1));
expect(
snack(AppDownloadTexts.openFailed('portal.arasystem.it.kr/app')),
findsOneWidget,
);
expect(
AppDownloadTexts.openFailed('portal.arasystem.it.kr/app'),
'브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.',
reason: '보드 ⑩ 의 알림 글 그대로',
);
});
testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async {
await pump(tester, packageName: null);
testWidgets('여는 쪽이 던져도(받을 앱 없음) 삼키고 같은 알림 — 개발 주소는 포트까지 보인다', (
WidgetTester tester,
) async {
opens = (Uri url) async =>
throw PlatformException(code: 'ACTIVITY_NOT_FOUND', message: '$url');
await pump(tester, apiBaseUrl: 'http://10.0.2.2:5173/api');
await tester.tap(storeButton());
await tester.tap(downloadButton());
await tester.pumpAndSettle();
expect(launched.single.url, Uri.parse('http://10.0.2.2:5173/app'));
expect(
snack(AppDownloadTexts.openFailed('10.0.2.2:5173/app')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('API 주소로 받기 화면 주소를 만들지 못하면 열지 않고 관리자에게 문의해 달라고 알린다', (
WidgetTester tester,
) async {
await pump(tester, apiBaseUrl: 'portal.arasystem.it.kr/api');
await tester.tap(downloadButton());
await tester.pumpAndSettle();
expect(launched, isEmpty);
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
expect(snack(AppDownloadTexts.openFailedNoAddress), findsOneWidget);
});
}
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/app_download/app_download_launcher.dart';
import 'package:arasystem_app/core/app_download/app_download_texts.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
@@ -9,6 +11,7 @@ import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
@@ -101,6 +104,115 @@ void main() {
});
});
group('설정 › 앱 정보 › [새 버전 받기] — 앱 보드 App1Push ④', () {
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다
final List<({Uri url, LaunchMode mode})> launched =
<({Uri url, LaunchMode mode})>[];
late bool opens;
setUp(() {
launched.clear();
opens = true;
h.app.overrides.add(
appDownloadLauncherProvider.overrideWithValue(
AppDownloadLauncher(
apiBaseUrl: 'https://portal.arasystem.it.kr/api',
launch: (Uri url, LaunchMode mode) async {
launched.add((url: url, mode: mode));
return opens;
},
),
),
);
});
Finder downloadRow() => find.ancestor(
of: find.text(AppDownloadTexts.getNewVersion),
matching: find.byType(InkWell),
);
Finder rowIcon(String label, IconData icon) => find.descendant(
of: find.ancestor(of: find.text(label), matching: find.byType(InkWell)),
matching: find.byIcon(icon),
);
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
testWidgets('줄 차례 — 앱 버전 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', (
WidgetTester tester,
) async {
await h.pump(tester);
await h.openSettings(tester);
final double version = tester
.getTopLeft(find.text(SettingsTexts.appVersion))
.dy;
final double download = tester
.getTopLeft(find.text(AppDownloadTexts.getNewVersion))
.dy;
final double licenses = tester
.getTopLeft(find.text(SettingsTexts.licenses))
.dy;
expect(version, lessThan(download));
expect(download, lessThan(licenses));
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new),
findsOneWidget,
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right),
findsNothing,
);
expect(
rowIcon(SettingsTexts.licenses, Icons.chevron_right),
findsOneWidget,
);
expect(
tester.getSize(downloadRow()).height,
greaterThanOrEqualTo(52),
reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다',
);
expect(launched, isEmpty);
});
testWidgets('누르면 업데이트 화면과 같은 받기 화면을 바깥 브라우저로 연다 — 설정에 머문다', (
WidgetTester tester,
) async {
await h.pump(tester);
await h.openSettings(tester);
await tapVisible(tester, find.text(AppDownloadTexts.getNewVersion));
expect(launched, hasLength(1));
expect(
launched.single.url,
Uri.parse('https://portal.arasystem.it.kr/app'),
);
expect(launched.single.mode, LaunchMode.externalApplication);
expect(h.app.location(), '/settings');
expect(find.byType(SnackBar), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('브라우저를 열지 못하면 업데이트 화면과 같은 알림 — 받기 화면 주소를 담는다', (
WidgetTester tester,
) async {
opens = false;
await h.pump(tester);
await h.openSettings(tester);
await tapVisible(tester, find.text(AppDownloadTexts.getNewVersion));
expect(launched, hasLength(1));
expect(
snack(AppDownloadTexts.openFailed('portal.arasystem.it.kr/app')),
findsOneWidget,
);
expect(h.app.location(), '/settings');
});
});
group('[로그아웃] — 확인 창부터', () {
testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', (
WidgetTester tester,
+1 -1
View File
@@ -29,7 +29,7 @@ location @api_payload_too_large {
return 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
}
# 동시 연결 수 초과 429 — 대용량 받기의 limit_conn (11단계 — nginx.conf 의 http 단계 "동시 연결 수"). 백엔드의 요청 수 한도와 같은 REQ_001
# 동시 연결 수 초과 429 — 대용량 받기 · 휴대폰 앱 받기의 limit_conn (11단계 — nginx.conf 의 http 단계 "동시 연결 수"). 백엔드의 요청 수 한도와 같은 REQ_001
location @api_too_many_requests {
add_header_inherit off;
default_type application/json;
+1 -1
View File
@@ -23,7 +23,7 @@
# 받아 가면 끊지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 끊는다(브라우저 · 내려받기 관리자는 Range 로 이어 받는다 — mail/README.md 의 시험)
# - 동시 연결: 클라이언트 주소마다 10 · 공개 받기 전체 400 — 넘으면 429 REQ_001 (nginx.conf 의 http 단계 "동시 연결 수")
# - 토큰 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다(지금은 백엔드의 토큰 검사가 그 꼴을 404 로 막지만 기대지 않는다)
# - 같은 줄들이 nginx.conf 의 앱 안 받기(/api/messages/{id}/large-files/…)에 있다 — 한쪽을 고치면 다른 쪽도
# - 같은 줄들이 nginx.conf 의 앱 안 받기(/api/messages/{id}/large-files/…) · 휴대폰 앱 받기(/api/app-releases/…/file)에 있다 — 한쪽을 고치면 다른 쪽도
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
location ~* ^/api/public-links/.+/files/ {
limit_conn download_public_ip 10;
+31 -3
View File
@@ -131,13 +131,17 @@ map $host $page_csp {
# ----------------------------------------
# 대용량 첨부 받기의 동시 연결 수 (11단계) — 공개 받기(/api/public-links/<토큰>/files/…) · 앱 안 받기(/api/messages/<id>/large-files/…)
# 휴대폰 앱(APK) 받기(/api/app-releases/<플랫폼>/latest/file — 2026-11-08, 계약 1절 "휴대폰 앱 받기")도 같은 꼴로 흘려보내며 앱 안 받기의 그릇을 함께 쓴다
#
# 받기는 버퍼링을 끄고 흘려보낸다 — 받는 쪽이 느리면 연결 하나가 nginx 연결 둘(클라이언트 · 백엔드)과 백엔드의 파일을 그동안 잡는다.
# 토큰을 가진 사람이 느린 받기를 수백 개 열어 이 nginx(포털과 함께)를 멈추지 못하게 센다 — 넘으면 429 → @api_too_many_requests 의 REQ_001 JSON:
# 공개 받기 클라이언트 주소마다 10 · 공개 받기 전체 400
# 앱 안 받기 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200
# 앱 안 받기 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 — 휴대폰 앱 받기도 여기서 함께 센다
# 전체 한도를 둘로 나눈 까닭 — 하나를 함께 쓰면 바깥 주소 몇십 개(IPv6 면 /64 하나)로 공개 받기가 그릇을 채워 직원의 앱 안 받기까지 모두 429 가 된다.
# 세는 것은 처리 중인 요청이다(쉬는 keep-alive 연결은 세지 않는다). 두 location 모두 대소문자를 가리지 않는다(위 머리말).
# 휴대폰 앱 받기를 따로 세지 않는 까닭 — 앱 안 받기처럼 포털에 로그인한 직원의 받기라 막으려는 것(느린 받기 여러 개로 그릇 채우기)이 같고,
# 그릇(받기 600 개 — 아래 worker_connections 셈)을 늘리지 않는다. APK(30MB 남짓)는 판이 나올 때 직원마다 한 번 받는다 — 사무실 휴대폰이 한꺼번에 받다
# 대용량 받기와 합쳐 주소마다 30 에 닿으면 넘은 받기만 429(잠시 뒤 다시)다.
# 세는 것은 처리 중인 요청이다(쉬는 keep-alive 연결은 세지 않는다). 세 location 모두 대소문자를 가리지 않는다(위 머리말).
#
# 클라이언트 주소 — 이 nginx 앞에는 늘 외부 리버스 프록시가 있어 $remote_addr 은 프록시다. 프록시는 X-Forwarded-For 에 자기가 본
# 클라이언트 주소를 이어 붙인다(docker 스킬 "외부 리버스 프록시 요구사항") — 그 마지막 칸을 쓴다. 머리글이 없으면(프록시 없이 바로) 접속 주소.
@@ -327,7 +331,7 @@ server {
include /etc/nginx/snippets/share-api-${NGINX_PORTAL_SHARE}.conf;
# 대용량 첨부 받기 GET·HEAD /api/messages/{id}/large-files/{fileId} (11단계 — 계약 4절 "우리 웹메일에서 읽기") — 공개 받기와 같게 흘려보낸다
# (nginx-share-api-open.conf 의 파일 받기와 같은 줄들 — 한쪽을 고치면 다른 쪽도):
# (nginx-share-api-open.conf 의 파일 받기 · 아래 휴대폰 앱 받기와 같은 줄들 — 한쪽을 고치면 다른 쪽도):
# - 동시 연결은 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 (위 http 단계). 넘으면 429 REQ_001
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 이 응답을 버퍼에 쌓지 않는다(받는 사람마다 1GB 를 디스크에 쌓지 않게).
# Range 는 손대지 않고 넘긴다(206 그대로)
@@ -352,6 +356,30 @@ server {
proxy_pass $backend_upstream;
}
# 휴대폰 앱(APK) 받기 GET·HEAD /api/app-releases/android/latest/file (2026-11-08 — 계약 1절 "휴대폰 앱 받기": 로그인한 직원 · 판마다 30MB 남짓 · Range 206) —
# 머리글이 대용량 첨부 받기와 같아(backend shared/utils/download-headers.ts — X-Accel-Buffering: no) 위 앱 안 받기와 같은 줄들로 흘려보낸다(한쪽을 고치면 다른 쪽도):
# - 동시 연결은 앱 안 받기의 그릇(download_app_ip 30 · download_app_total 200)을 함께 쓴다 — 까닭은 http 단계 "동시 연결 수". 넘으면 429 REQ_001
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 받는 사람마다 APK 를 임시 파일에 받아 두지 않는다
# - 정보 GET /api/app-releases/android/latest(JSON)는 /file 이 없어 이 location 에 들지 않는다 — 위 /api/ 그대로
# - 대소문자를 가리지 않는다(머리말). 플랫폼 · 판 칸은 .+ — nginx 는 %XX 를 풀고(%2F 도) 겹 / · .. 를 정리한 뒤 location 을 고른다.
# 끝은 묶지 않는다 — 백엔드가 받는 꼴(끝의 / 등)이 비켜 가지 않게. 없는 이웃 경로가 들어와도 상한 · 흘려보내기가 더해질 뿐이다
# 공개 내려받기 server 의 /api/ 는 공개 API 밖을 모두 404 RES_001 로 닫는다 — 이 경로도 거기서는 백엔드에 가지 않는다(백엔드도 공개 호스트에서 막는다)
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
location ~* ^/api/app-releases/.+/file {
limit_conn download_app_ip 30;
limit_conn download_app_total 200;
error_page 413 @api_payload_too_large;
error_page 429 @api_too_many_requests;
error_page 500 502 503 504 @api_unavailable;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
gzip off;
proxy_pass_header X-Accel-Buffering;
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 대용량 첨부의 조각 올리기 PUT /api/file-uploads/{id}/chunks/{n} (11단계 — 계약 4절 "올리기") — 조각 16MiB(계약 상수 — 마지막 조각만 짧다)
# + 여유 = 17m 고정. 계약 상수라 env 로 바꾸지 않는다(바꾸려면 계약 · 백엔드 · 화면을 함께). 그 밖의 /api/file-uploads(만들기 · 끝내기 · 상태 · 지우기)는
# JSON 이라 기본 1m 그대로다. 요청 본문은 nginx 가 다 받아 둔 뒤(proxy_request_buffering 기본 켬 — 느린 연결은 nginx 가 받는다, /tmp/client_temp)
+2 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR)
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR) · 설정 › 휴대폰 앱의 받기 주소(…/app — 비밀 아님)
// 칸은 shared/utils/qr-code.ts 가 고르고 여기서는 SVG <rect> 를 v-for 로 그린다 — v-html 을 쓰지 않는다.
// 바깥 여백(quiet zone)은 칸 2개 + 상자 안쪽 여백 — 읽는 앱이 그림의 끝을 알아보게 둔다.
// 이름(label)은 role="img" 의 aria-label — 그림의 내용(비밀이 든 주소)은 읽어 주지 않는다
@@ -8,7 +8,7 @@ import { computed } from 'vue'
import { qrModuleRuns } from '@/shared/utils/qr-code'
interface Props {
/** QR 에 담을 글 — otpauth 주소 */
/** QR 에 담을 글 — otpauth 주소 · 휴대폰 받기 화면 주소 */
value: string
/** 그림의 이름 (스크린리더) */
label: string
@@ -2,6 +2,7 @@
// 로그인 틀의 문의 줄 (13단계 — 디자인 README "13단계 › 문의 줄" · 계약 6절 "문의 연락처")
// 연락처가 있으면 물음(12.5px 회색) 다음 줄에 연락처(#344054 · 500), 없거나 받지 못했으면 지금 글 그대로(fallback).
// 연락처는 운영 도구로 넣는 설정값이다 — 글자로만 그린다(텍스트 보간 — 링크 · HTML 없음). 길어도 낱말 가운데서 끊지 않는다
// 휴대폰 앱 받기 화면(설정 › 휴대폰 앱 · /app)도 같은 꼴로 쓴다 — /app 은 본문이 14 라 글을 13 으로(size="base", 디자인 원본 SettingsMobileApp)
interface Props {
/** 물음 — "비밀번호를 잊으셨나요?" · "인증 앱을 쓸 수 없나요?" */
question: string
@@ -9,14 +10,18 @@ interface Props {
fallback: string
/** 연락처 한 줄 — 없으면 null */
contact: string | null
/** 글 크기 — label 12.5(기본) · base 13 */
size?: 'label' | 'base'
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
size: 'label',
})
</script>
<template>
<p
class="support-line"
:class="['support-line', { 'support-line--base': size === 'base' }]"
data-support-line
>
<template v-if="contact">
@@ -40,6 +45,10 @@ defineProps<Props>()
overflow-wrap: anywhere;
}
.support-line--base {
font-size: var(--font-size-base);
}
.support-line__contact {
color: var(--color-text-strong);
font-weight: 500;
@@ -0,0 +1,96 @@
<script setup lang="ts">
// [앱 내려받기] — 가장 늦게 올린 안드로이드 앱(APK)을 받는다 (디자인 원본 SettingsMobileApp)
// 첨부 받기처럼 <a href download> 이동이고, 누르기 직전에 세션을 확인한다(useAppReleases 의 downloadLatestAndroid — confirmSession).
// 세션이 끝났으면 세션 만료 창, 장애면 알림이 뜨고 이동하지 않는다. 확인하는 사이 다시 눌러도 한 번만 받는다.
// 가운데 버튼(새 탭)도 같은 흐름으로 받고, 오른쪽 버튼(메뉴 — 링크 주소 복사 등)은 브라우저에 맡긴다.
// 화면 낭독 이름은 크기까지 "앱 내려받기 (31 MB)" — 보이는 글로 시작한다.
// 설정의 앱 칸은 높이 44(글 13.5 · 아이콘 15), 휴대폰 받기 화면은 전체 폭 · 높이 48(block — 글 15 · 아이콘 18)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useAppReleases } from '@/composables/useAppReleases'
import { MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { appDownloadLabel } from '@/shared/utils/app-release'
interface Props {
/** 크기 글 ("31 MB") — 모르면 빈 글 */
size: string
/** 휴대폰 받기 화면 — 전체 폭 · 높이 48 */
block?: boolean
}
const props = withDefaults(defineProps<Props>(), {
block: false,
})
const { latestAndroidFileUrl, downloadLatestAndroid } = useAppReleases()
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const href = latestAndroidFileUrl()
const label = computed(() => appDownloadLabel(props.size))
/** 세션을 확인하는 중 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
let pending = false
function onDownload(event: MouseEvent) {
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (pending) return
pending = true
void downloadLatestAndroid().finally(() => {
pending = false
})
}
</script>
<template>
<a
:class="['app-download', { 'app-download--block': block }]"
:href="href"
:aria-label="label"
download
data-app-download
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon
name="download"
:size="block ? 18 : 15"
:stroke-width="2"
/>
<span>{{ MOBILE_APP_TEXT.download }}</span>
</a>
</template>
<style scoped>
/* 채운 단추 — 청록 바탕 · 흰 글 (설정의 앱 칸: 높이 44 · 좌우 16 · 모서리 8) */
.app-download {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
text-decoration: none;
}
.app-download:hover {
background: var(--color-brand-strong);
color: var(--color-on-brand);
}
/* 휴대폰 받기 화면 — 전체 폭 · 높이 48 · 모서리 10 · 글 15 (44 보다 큰 보통 크기 — 밀도 예외가 아니다) */
.app-download--block {
display: flex;
gap: var(--space-8);
min-height: var(--space-48);
border-radius: var(--radius-lg);
font-size: var(--font-size-title-sm);
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
// 앱 그림 — 남색 바탕에 흰 ARA 마크 (디자인 원본 SettingsMobileApp — 홈 화면 아이콘 apple-touch-icon.png 를 둥글게 깎은 것이라 새 그림이 들지 않는다)
// 설정의 앱 칸은 48(모서리 12), 휴대폰 받기 화면의 카드는 52(모서리 13). 옆에 앱 이름이 있어 그림은 읽어 주지 않는다(alt="")
import appIcon from '@/assets/brand/apple-touch-icon.png'
interface Props {
/** 휴대폰 받기 화면의 카드 — 52 */
large?: boolean
}
withDefaults(defineProps<Props>(), {
large: false,
})
</script>
<template>
<img
:src="appIcon"
alt=""
width="180"
height="180"
:class="['app-tile', { 'app-tile--large': large }]"
>
</template>
<style scoped>
/* 모서리는 한 변의 1/4 — 48 은 12, 52 는 13 (보드 값) */
.app-tile {
flex: 0 0 auto;
display: block;
width: 3rem;
height: 3rem;
border-radius: 25%;
}
.app-tile--large {
width: 3.25rem;
height: 3.25rem;
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
// 아이폰 안내 상자 — 옅은 청록 바탕 · 안내 아이콘 · 제목(청록 굵게) + 글 (디자인 원본 SettingsMobileApp — 설정 화면 · 휴대폰 받기 화면이 같다)
// 「메일 프로그램 연결」은 설정 › 계정 › 메일 프로그램 연결로 가는 링크다(기본 메일 앱에 회사 메일을 잇는 차례가 있다). 글은 13 그대로
import AppIcon from '@/components/AppIcon.vue'
import LinkedText from '@/components/LinkedText.vue'
import { MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
const mailClientsRoute = { name: SETTINGS_ROUTES.mailClients }
</script>
<template>
<div
class="iphone-note"
data-app-iphone-note
>
<AppIcon
name="info"
class="iphone-note__icon"
/>
<div class="iphone-note__body">
<span class="iphone-note__title">{{ MOBILE_APP_TEXT.iphoneTitle }}</span>
<LinkedText
:before="MOBILE_APP_TEXT.iphoneBefore"
:link="MOBILE_APP_TEXT.iphoneLink"
:after="MOBILE_APP_TEXT.iphoneAfter"
:to="mailClientsRoute"
/>
</div>
</div>
</template>
<style scoped>
.iphone-note {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-tint);
color: var(--color-text-strong);
font-size: var(--font-size-base);
}
.iphone-note__icon {
margin-top: var(--space-2);
color: var(--color-brand-strong);
}
.iphone-note__body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.iphone-note__title {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 설치 차례 — 번호 줄(청록 굵은 숫자 · 동그라미 없음 — 2단계 인증 「켜는 차례」와 같은 꼴, 디자인 원본 SettingsMobileApp)
// 숫자는 꾸밈이라 읽어 주지 않는다(aria-hidden) — 목록(ol)의 차례로 읽힌다.
// 설정 화면은 줄 사이 8 · 숫자 칸 뒤 6, 휴대폰 받기 화면(roomy)은 줄 사이 10 · 숫자 칸 뒤 4 · 줄 높이 1.55
interface Props {
steps: readonly string[]
/** 휴대폰 받기 화면 */
roomy?: boolean
}
withDefaults(defineProps<Props>(), {
roomy: false,
})
</script>
<template>
<ol
:class="['app-steps', { 'app-steps--roomy': roomy }]"
data-app-steps
>
<li
v-for="(step, index) in steps"
:key="index"
class="app-steps__item"
>
<span
class="app-steps__number"
aria-hidden="true"
>{{ index + 1 }}</span>
<span>{{ step }}</span>
</li>
</ol>
</template>
<style scoped>
.app-steps {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.app-steps__item {
display: grid;
grid-template-columns: var(--space-20) minmax(0, 1fr);
gap: var(--space-6);
}
.app-steps__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.app-steps--roomy {
gap: var(--space-10);
line-height: 1.55;
}
.app-steps--roomy .app-steps__item {
gap: var(--space-4);
}
</style>
@@ -0,0 +1,40 @@
import type { AxiosRequestConfig } from 'axios'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import { startBrowserDownload } from './useMail'
import type { AppRelease } from '@/shared/types/app-release'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type AppReleaseRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
const LATEST_ANDROID_PATH = '/app-releases/android/latest'
// 휴대폰 앱 받기 API Composable — 서버와 맞춘 약속(2026-11-08, 로그인 필요 · 제한된 세션은 막힌다)
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다.
// 파일은 첨부처럼 <a href> 이동으로 받는다(표준 래퍼 없음 — application/vnd.android.package-archive, attachment, Range)
export function useAppReleases() {
const api = useApi()
/** 가장 늦게 올린 안드로이드 판 — 올린 판이 없으면 404 RES_001 */
function getLatestAndroidRelease(options?: AppReleaseRequestOptions): Promise<AppRelease> {
return api.get<AppRelease, AppRelease>(LATEST_ANDROID_PATH, options)
}
/** 그 판의 파일 주소 — axios baseURL 과 같은 규칙으로 만들어야 쿠키(Path=/api)가 함께 간다 */
function latestAndroidFileUrl(): string {
return buildApiUrl(`${LATEST_ANDROID_PATH}/file`)
}
/**
* 앱(APK)을 내려받는다 — 첨부처럼 이동하기 전에 세션을 확인한다 (confirmSession).
* <a href> 이동은 axios 의 401 갱신 규칙을 타지 않아, 액세스 토큰이 만료된 채 이동하면 오류 JSON 이 파일로 받아진다.
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자 · 알림)는 이미 끝났다
*/
async function downloadLatestAndroid(): Promise<boolean> {
if (!(await confirmSession())) return false
startBrowserDownload(latestAndroidFileUrl())
return true
}
return { getLatestAndroidRelease, latestAndroidFileUrl, downloadLatestAndroid }
}
+10
View File
@@ -85,6 +85,16 @@ describe('LoginPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('휴대폰 받기 화면(QR 코드 · 앱의 [새 버전 받기])에서 왔으면 로그인한 뒤 /app 으로 돌아간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=/app')
await submit(wrapper, 'secret')
expect(router.currentRoute.value.name).toBe('mobile-app')
expect(router.currentRoute.value.fullPath).toBe('/app')
})
it('redirect 가 앱 밖 주소면 무시하고 메일로 간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=//evil.example')
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MobileAppPage from './MobileAppPage.vue'
// 휴대폰 받기 화면 /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app")
// B1 받을 앱 있음 — 카드 · 설치 방법 · 아이폰 · 문의 줄 / B2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
/**
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
*/
const RELEASE: AppRelease = {
version: '1.0.0',
sizeBytes: 32505856,
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
}
const FILE_URL = '/api/app-releases/android/latest/file'
interface Replies {
release?: () => MockResult
contact?: () => MockResult
me?: () => MockResult
}
describe('MobileAppPage', () => {
let pinia: Pinia
let requests: MockRequest[]
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(replies: Replies = {}) {
requests = installMockApi((request) => {
switch (request.url) {
case '/app-releases/android/latest':
return (replies.release ?? (() => ok(RELEASE)))()
case '/support-contact':
return (replies.contact ?? (() => ok({ text: CONTACT })))()
case '/auth/me':
return (replies.me ?? (() => ok(USER)))()
default:
return fail(404, 'RES_001')
}
})
const router = createTestRouter()
await router.push('/app')
await router.isReady()
const wrapper = mount(MobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
it('앱 틀 없이 브랜드 띠(로고 · 제목 · 설명)와 내용 — 레일 · 상단 바가 없다', async () => {
const wrapper = await mountPage()
const band = wrapper.get('header')
expect(band.get('img').attributes('alt')).toBe('ARA SYSTEM')
expect(band.get('h1').text()).toBe('휴대폰 앱 받기')
expect(band.text()).toContain('안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.')
expect(wrapper.find('main').exists()).toBe(true)
expect(wrapper.find('nav').exists()).toBe(false)
})
describe('B1 받을 앱 있음', () => {
it('카드 — 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기](전체 폭, 크기까지 읽어 준다)', async () => {
const wrapper = await mountPage()
const card = wrapper.get('[data-mobile-app-release]')
expect(card.text()).toContain('ARA SYSTEM')
expect(card.text()).toContain('안드로이드 앱')
const facts = card.get('dl[data-mobile-app-facts]')
expect(facts.findAll('dt').map((term) => term.text())).toEqual(['버전', '올린 날', '크기'])
expect(facts.findAll('dd').map((value) => value.text())).toEqual(['1.0.0', '2026년 11월 18일', '31 MB'])
const download = card.get('a[data-app-download]')
expect(download.classes()).toContain('app-download--block')
expect(download.text()).toBe('앱 내려받기')
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
expect(download.attributes('href')).toBe(FILE_URL)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /app-releases/android/latest',
'GET /support-contact',
])
})
it('설치 방법 1~4 + 새 버전 한 줄 · 아이폰 안내 · 문의 줄(연락처가 있으면 "설치가 잘 되지 않나요?" 다음 줄에)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('#app-page-steps-title').text()).toBe('설치 방법')
const steps = wrapper.findAll('[data-app-steps] li')
expect(steps.map((step) => step.text().slice(1))).toEqual([
'[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.',
'받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.',
'Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.',
])
expect(wrapper.text()).toContain(
'새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
)
expect(wrapper.get('[data-app-iphone-note] a').attributes('href')).toBe('/settings/account/mail-clients')
const line = wrapper.get('[data-support-line]')
expect(line.classes()).toContain('support-line--base')
expect(line.text()).toContain('설치가 잘 되지 않나요?')
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
})
it('연락처가 없으면 문의 줄은 대신 쓰는 글 한 줄', async () => {
const wrapper = await mountPage({ contact: () => ok({ text: ' ' }) })
expect(wrapper.get('[data-support-line]').text()).toBe('설치가 잘 되지 않으면 관리자에게 문의해 주세요.')
})
it('[앱 내려받기] — 세션을 확인한 뒤 같은 주소로 받는다', async () => {
const wrapper = await mountPage()
await wrapper.get('a[data-app-download]').trigger('click')
await flushPromises()
expect(requests.map((request) => request.url)).toContain('/auth/me')
expect(downloads).toEqual([FILE_URL])
})
})
describe('B2 받을 앱 없음 (404 RES_001)', () => {
it('안내와 문의 줄만 — 카드 · 설치 방법 · 아이폰 안내는 없고 알리지 않는다', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const none = wrapper.get('[data-mobile-app-none]')
expect(none.get('h2').text()).toBe('아직 받을 수 있는 앱이 없습니다')
expect(none.text()).toContain('앱이 준비되면 이 화면에서 받을 수 있습니다.')
expect(none.get('[data-support-line]').text()).toContain('언제 받을 수 있는지 궁금하신가요?')
expect(none.get('[data-support-contact]').text()).toBe(CONTACT)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 없으면 "언제 받을 수 있는지는 관리자에게 문의해 주세요."', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
expect(wrapper.get('[data-mobile-app-none] [data-support-line]').text()).toBe(
'언제 받을 수 있는지는 관리자에게 문의해 주세요.',
)
})
})
it('받지 못하면 카드 자리에 사전 문구와 [다시 시도] — 알리지 않고, 누르면 다시 받는다', async () => {
let reply: MockResult = 'network'
const wrapper = await mountPage({ release: () => reply })
const alert = wrapper.get('main [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
reply = ok(RELEASE)
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.get('[data-mobile-app-release]').text()).toContain('1.0.0')
})
})
@@ -0,0 +1,398 @@
<script setup lang="ts">
// 휴대폰 받기 화면 — /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app") · 탭 제목 "휴대폰 앱 받기 · ARA SYSTEM"
// 설정 화면의 QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 이 주소를 연다.
// 독립 화면: 앱 레일 · 상단 바가 없다(인쇄 화면처럼 앱 틀 밖의 보호 화면). 로그인해야 열린다 — 로그인하지 않은 브라우저는 라우터 가드가
// 지금의 로그인 화면으로 보냈다가(?redirect=/app) 로그인을 마치면 이 화면으로 돌아온다(새 비밀번호 · 2단계 켜기 단계가 걸린 직원도 마친 뒤 돌아온다).
// - 브랜드 띠(#0B3640 — 로그인 브랜드 패널을 줄인 꼴: 로고 · 제목 · 설명) + 바탕 #F8F9FB 의 내용
// - 받을 앱 있음(B1): 카드(앱 그림 52 · 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기] 전체 폭 48) → 설치 방법 1~4 + 새 버전 한 줄 →
// 아이폰 안내 → 문의 줄("설치가 잘 되지 않나요?")
// - 받을 앱 없음(B2 — 404 RES_001, 알림 없음): 안내와 문의 줄("언제 받을 수 있는지 …")만
// - 받지 못함: 카드 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
// - [앱 내려받기]는 첨부처럼 누르기 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession). 세션이 끝났으면 세션 만료 창
// - 휴대폰 폭이 먼저다. 폭 480 보다 넓으면 띠는 화면 폭 전체로 두고 로고 · 제목 · 내용은 가운데 칸 480 에 맞춘다(B3)
import { computed } from 'vue'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
import { APP_NAME } from '@/shared/constants/brand'
import { MOBILE_APP_PAGE_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { appReleaseFacts, appReleaseSize } from '@/shared/utils/app-release'
import { useAppReleaseStore } from '@/stores/app-release.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
const appReleaseStore = useAppReleaseStore()
const supportContactStore = useSupportContactStore()
/** 받을 앱 — 받은 판이 있을 때만 */
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
const facts = computed(() => (release.value ? appReleaseFacts(release.value) : []))
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
void appReleaseStore.load()
void supportContactStore.ensureLoaded()
</script>
<template>
<div class="app-page">
<header class="app-page__band">
<div class="app-page__band-inner">
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 960×459 -->
<img
:src="logoFull"
:alt="APP_NAME"
width="960"
height="459"
class="app-page__logo"
>
<div class="app-page__intro">
<h1 class="app-page__title">
{{ MOBILE_APP_PAGE_TEXT.title }}
</h1>
<p class="app-page__lead">
{{ MOBILE_APP_PAGE_TEXT.lead }}
</p>
</div>
</div>
</header>
<main class="app-page__main">
<div class="app-page__column">
<!-- 받을 앱 있음 -->
<template v-if="release">
<div
class="app-page__card"
data-mobile-app-release
>
<div class="app-page__card-head">
<MobileAppIcon large />
<div class="app-page__card-name">
<span class="app-page__name">{{ MOBILE_APP_TEXT.appName }}</span>
<span class="app-page__platform">{{ MOBILE_APP_PAGE_TEXT.platform }}</span>
</div>
</div>
<dl
class="app-page__facts"
data-mobile-app-facts
>
<template
v-for="fact in facts"
:key="fact.label"
>
<dt class="app-page__fact-label">
{{ fact.label }}
</dt>
<dd class="app-page__fact-value">
{{ fact.value }}
</dd>
</template>
</dl>
<MobileAppDownloadLink
:size="size"
block
/>
</div>
<section
class="app-page__steps"
aria-labelledby="app-page-steps-title"
>
<h2
id="app-page-steps-title"
class="app-page__section-title"
>
{{ MOBILE_APP_TEXT.stepsTitle }}
</h2>
<MobileAppSteps
:steps="MOBILE_APP_PAGE_TEXT.steps"
roomy
/>
<p class="app-page__update">
{{ MOBILE_APP_PAGE_TEXT.updateText }}
</p>
</section>
<MobileAppIphoneNote />
<div class="app-page__support">
<LoginSupportLine
:question="MOBILE_APP_PAGE_TEXT.supportQuestion"
:fallback="MOBILE_APP_PAGE_TEXT.supportFallback"
:contact="supportContactStore.text"
size="base"
/>
</div>
</template>
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
<div
v-else-if="appReleaseStore.status === 'none'"
class="app-page__none"
data-mobile-app-none
>
<AppIcon
name="smartphone"
:size="28"
:stroke-width="1.5"
class="app-page__none-icon"
/>
<h2 class="app-page__none-title">
{{ MOBILE_APP_TEXT.noneTitle }}
</h2>
<p class="app-page__none-text">
{{ MOBILE_APP_PAGE_TEXT.noneText }}
</p>
<div class="app-page__none-support">
<LoginSupportLine
:question="MOBILE_APP_TEXT.noneQuestion"
:fallback="MOBILE_APP_TEXT.noneFallback"
:contact="supportContactStore.text"
size="base"
/>
</div>
</div>
<!-- 받지 못함 — 카드 자리에 사전 문구와 [다시 시도] -->
<MailLoadError
v-else-if="loadError"
:error="loadError"
leave-label=""
@retry="appReleaseStore.load()"
/>
<div
v-else
class="app-page__placeholder"
aria-busy="true"
/>
</div>
</main>
</div>
</template>
<style scoped>
/* 휴대폰 폭(360)이 먼저다 — 넓은 화면(480 초과)은 아래 미디어 쿼리 */
.app-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-surface-panel);
color: var(--color-text);
word-break: keep-all;
overflow-wrap: anywhere;
}
/* 브랜드 띠 — 로그인 브랜드 패널을 줄인 꼴 (로고 120 · 제목 20 · 설명) */
.app-page__band {
padding: var(--space-28) var(--space-20) var(--space-24);
background: var(--color-brand-surface);
}
.app-page__band-inner {
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.app-page__logo {
display: block;
width: 7.5rem;
height: auto;
}
.app-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.app-page__title {
margin: 0;
font-size: var(--font-size-heading);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
color: var(--color-on-brand);
}
.app-page__lead {
margin: 0;
font-size: var(--font-size-body);
line-height: 1.6;
color: var(--color-on-brand-muted);
}
/* 내용 — 본문 14, 가운데 칸 480 */
.app-page__main {
flex: 1 1 auto;
padding: var(--space-20) var(--space-16) var(--space-28);
font-size: var(--font-size-body);
}
.app-page__column {
width: 100%;
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-22);
}
/* 앱 카드 — 테두리 · 모서리 12 · 안쪽 16 */
.app-page__card {
display: flex;
flex-direction: column;
gap: var(--space-14);
padding: var(--space-16);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
}
.app-page__card-head {
display: flex;
align-items: center;
gap: var(--space-12);
}
.app-page__card-name {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.app-page__name {
font-size: var(--font-size-title-md);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.app-page__platform {
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
/* 버전 · 올린 날 · 크기 — 이름 칸 56 */
.app-page__facts {
margin: 0;
display: grid;
grid-template-columns: var(--space-56) minmax(0, 1fr);
row-gap: var(--space-2);
font-size: var(--font-size-base);
font-variant-numeric: tabular-nums;
}
.app-page__fact-label {
color: var(--color-text-muted);
}
.app-page__fact-value {
margin: 0;
color: var(--color-text-strong);
}
.app-page__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.app-page__section-title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.app-page__update {
margin: var(--space-2) 0 0;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
/* 문의 줄 — 위에 가는 선 */
.app-page__support {
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border);
}
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 28 · 제목 17 · 안내 · 가는 선 아래 문의 줄 */
.app-page__none {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20) var(--space-16);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
}
.app-page__none-icon {
color: var(--color-icon-muted);
}
.app-page__none-title {
margin: 0;
font-size: var(--font-size-title-md);
line-height: var(--line-height-heading);
font-weight: 650;
}
.app-page__none-text {
margin: 0;
color: var(--color-text-strong);
}
.app-page__none-support {
align-self: stretch;
margin-top: var(--space-4);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.app-page__placeholder {
min-height: 16rem;
}
/* 넓은 화면(480 초과 — PC 브라우저) — 띠는 화면 폭 전체, 로고 · 제목 · 내용은 가운데 칸 480 (B3) */
@media (min-width: 30.0625rem) {
/* 띠 위 36 · 좌우 24 · 아래 32 */
.app-page__band {
padding: var(--space-36) var(--space-24) 2rem;
}
.app-page__band-inner {
gap: var(--space-18);
}
.app-page__logo {
width: 9.375rem;
}
.app-page__title {
font-size: var(--font-size-heading-lg);
}
.app-page__main {
padding: var(--space-28) var(--space-24) var(--space-40);
}
.app-page__card {
padding: var(--space-20);
}
.app-page__none {
padding: var(--space-24) var(--space-20);
}
}
</style>
@@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import QrCodeImage from '@/components/QrCodeImage.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AccountMobileAppPage from './AccountMobileAppPage.vue'
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱")
// A1 받을 앱 있음 — 앱 칸 · QR · 설치 방법 · 새 버전 · 아이폰 / A2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
/**
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
*/
const RELEASE: AppRelease = {
version: '1.0.0',
sizeBytes: 32505856,
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
}
const FILE_URL = '/api/app-releases/android/latest/file'
interface Replies {
release?: () => MockResult | Promise<MockResult>
contact?: () => MockResult
me?: () => MockResult | Promise<MockResult>
refresh?: () => MockResult
}
describe('AccountMobileAppPage', () => {
let pinia: Pinia
let requests: MockRequest[]
/** 실제 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(replies: Replies = {}) {
requests = installMockApi((request) => {
switch (request.url) {
case '/app-releases/android/latest':
return (replies.release ?? (() => ok(RELEASE)))()
case '/support-contact':
return (replies.contact ?? (() => ok({ text: CONTACT })))()
case '/auth/me':
return (replies.me ?? (() => ok(USER)))()
case '/auth/refresh':
return (replies.refresh ?? (() => ok(USER)))()
default:
return fail(404, 'RES_001')
}
})
const router = createTestRouter()
await router.push('/settings/account/mobile-app')
await router.isReady()
const wrapper = mount(AccountMobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
describe('A1 받을 앱 있음', () => {
it('머리 · 「안드로이드 앱」 칸 — 앱 이름과 "버전 · 올린 날 · 크기" 한 줄, [앱 내려받기]는 크기까지 읽어 준다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('휴대폰 앱')
expect(wrapper.text()).toContain(
'안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
)
expect(wrapper.get('#mobile-app-android-title').text()).toBe('안드로이드 앱')
const box = wrapper.get('[data-mobile-app-release]')
expect(box.text()).toContain('ARA SYSTEM')
expect(box.get('[data-mobile-app-facts]').text()).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
const download = box.get('a[data-app-download]')
expect(download.text()).toBe('앱 내려받기')
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
expect(download.attributes('href')).toBe(FILE_URL)
expect(download.attributes('download')).toBe('')
expect(requestLines()).toEqual(['GET /app-releases/android/latest', 'GET /support-contact'])
})
it('QR 은 지금 주소의 출처 + /app 만 담는다 — 휴대폰으로 받기 · 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄', async () => {
const wrapper = await mountPage()
const qr = wrapper.getComponent(QrCodeImage)
expect(qr.props('value')).toBe(`${window.location.origin}/app`)
expect(wrapper.get('[data-qr-code]').attributes('aria-label')).toBe('휴대폰에서 앱을 받는 주소의 QR 코드')
const phone = wrapper.get('[data-mobile-app-phone]')
expect(phone.get('h3').text()).toBe('휴대폰으로 받기')
expect(phone.text()).toContain(
'휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.',
)
expect(phone.text()).toContain('QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.')
expect(phone.get('[data-mobile-app-address]').text()).toBe(`${window.location.host}/app`)
expect(phone.text()).toContain('PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.')
})
it('설치 방법 1~4(둘째 줄에 받는 파일 이름) · 새 버전이 나오면 · 아이폰 안내(메일 프로그램 연결 링크)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('#mobile-app-steps-title').text()).toBe('설치 방법')
const steps = wrapper.findAll('[data-app-steps] li')
expect(steps).toHaveLength(4)
expect(steps.map((step) => step.find('[aria-hidden="true"]').text())).toEqual(['1', '2', '3', '4'])
expect(steps[1]?.text()).toContain('받은 ‘ARA-SYSTEM-1.0.0.apk’ 파일을 열어 주세요.')
expect(steps[3]?.text()).toContain('앱 비밀번호는 만들지 않으셔도 됩니다.')
expect(wrapper.get('#mobile-app-update-title').text()).toBe('새 버전이 나오면')
expect(wrapper.text()).toContain('꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.')
const iphone = wrapper.get('[data-app-iphone-note]')
expect(iphone.text()).toContain('아이폰용 앱은 아직 없습니다')
expect(iphone.get('a').text()).toBe('메일 프로그램 연결')
expect(iphone.get('a').attributes('href')).toBe('/settings/account/mail-clients')
// 받을 앱이 있으면 문의 줄은 없다
expect(wrapper.find('[data-support-line]').exists()).toBe(false)
})
it('받는 동안은 칸 자리만 둔다 (aria-busy) — QR · 설치 방법은 받은 뒤에', async () => {
let release: (result: MockResult) => void = () => {}
const gate = new Promise<MockResult>((settle) => {
release = settle
})
const wrapper = await mountPage({ release: () => gate })
expect(wrapper.find('section [aria-busy="true"]').exists()).toBe(true)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
release(ok(RELEASE))
await flushPromises()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
})
})
describe('A2 받을 앱 없음 (404 RES_001)', () => {
it('안내와 문의 줄만 — QR · 설치 방법 · 새 버전 · 아이폰 안내는 없고 알리지 않는다', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const none = wrapper.get('[data-mobile-app-none]')
expect(none.text()).toContain('아직 받을 수 있는 앱이 없습니다')
expect(none.text()).toContain('앱이 준비되면 이곳에서 받아 설치할 수 있습니다.')
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 있으면 물음 다음 줄에 연락처', async () => {
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
const line = wrapper.get('[data-mobile-app-none] [data-support-line]')
expect(line.text()).toContain('언제 받을 수 있는지 궁금하신가요?')
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
expect(line.find('br').exists()).toBe(true)
})
it('연락처를 정하지 않았거나 받지 못하면 대신 쓰는 글 한 줄', async () => {
const unset = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
expect(unset.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
expect(unset.find('[data-support-contact]').exists()).toBe(false)
unset.unmount()
setActivePinia((pinia = createPinia()))
useAuthStore().markAuthenticated(USER)
const failed = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => fail(503, 'SYS_001') })
expect(failed.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
expect(useToastStore().toasts).toEqual([])
})
})
describe('받지 못함', () => {
it('칸 자리에 사전 문구와 [다시 시도] — 알림은 띄우지 않고, 누르면 다시 받는다', async () => {
let reply: MockResult = fail(503, 'SYS_001')
const wrapper = await mountPage({ release: () => reply })
const alert = wrapper.get('section [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(alert.text()).toContain('문의 코드 3f1c0a2e')
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
reply = ok(RELEASE)
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
expect(requestLines().filter((line) => line.endsWith('/latest'))).toHaveLength(2)
})
})
describe('[앱 내려받기] — 이동 전 세션 확인', () => {
it('누르면 세션을 확인한 뒤 같은 주소로 받는다 — 원래 링크의 이동은 막고, 확인 중에 다시 눌러도 한 번만', async () => {
let releaseMe: (result: MockResult) => void = () => {}
const meGate = new Promise<MockResult>((settle) => {
releaseMe = settle
})
const wrapper = await mountPage({ me: () => meGate })
const link = wrapper.get<HTMLAnchorElement>('a[data-app-download]').element
const first = new MouseEvent('click', { bubbles: true, cancelable: true })
link.dispatchEvent(first)
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await flushPromises()
expect(first.defaultPrevented).toBe(true)
expect(downloads).toEqual([])
releaseMe(ok(USER))
await flushPromises()
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me'])
expect(downloads).toEqual([FILE_URL])
})
it('세션이 끝났으면 받지 않고 세션 만료 창으로 (갱신도 실패)', async () => {
const wrapper = await mountPage({ me: () => fail(401, 'AUTH_001'), refresh: () => fail(401, 'AUTH_001') })
await wrapper.get('a[data-app-download]').trigger('click')
await flushPromises()
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me', 'POST /auth/refresh'])
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
})
it('세션이 바뀐 뒤 늦게 온 앱 정보는 쓰지 않는다', async () => {
let release: (result: MockResult) => void = () => {}
const gate = new Promise<MockResult>((settle) => {
release = settle
})
const wrapper = await mountPage({ release: () => gate })
useAuthStore().clearAuth()
release(ok(RELEASE))
await flushPromises()
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
})
})
@@ -0,0 +1,383 @@
<script setup lang="ts">
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱") — /settings/account/mobile-app · 탭 제목 "휴대폰 앱"
// 안드로이드 앱(APK)은 스토어에 올리지 않고 웹메일에 로그인한 직원만 받는다. PC 에서 보는 화면이다.
// 머리 + 설명 → 「안드로이드 앱」 칸:
// - 받을 앱 있음(A1): 앱 그림 · 이름 · "버전 · 올린 날 · 크기" · [앱 내려받기] / 아래 줄에 QR(지금 주소의 출처 + /app — 비밀 아님) · 「휴대폰으로 받기」 ·
// 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄. 그 뒤 「설치 방법」 1~4 · 「새 버전이 나오면」 · 아이폰 안내(메일 프로그램 연결 링크)
// - 받을 앱 없음(A2 — 404 RES_001, 알림 없음): 안내와 문의 줄만(문의 연락처가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 대신 쓰는 글 한 줄)
// - 받지 못함: 칸 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
// - 들어올 때마다 새로 받는다(app-release.store — 운영자가 새 판을 올렸을 수 있다). 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다
// - [앱 내려받기]는 첨부처럼 이동 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession)
// - 좁은 폭(휴대폰 — 480 이하): 설정 메뉴는 위로(지금 설정 셸), QR 줄은 빼고 [앱 내려받기]는 이름 아래 전체 폭
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
import QrCodeImage from '@/components/QrCodeImage.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { MOBILE_APP_SETTINGS_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import {
addressWithoutScheme,
appReleaseFactsLine,
appReleaseFileName,
appReleaseSize,
mobileAppPageUrl,
} from '@/shared/utils/app-release'
import { useAppReleaseStore } from '@/stores/app-release.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
const appReleaseStore = useAppReleaseStore()
const supportContactStore = useSupportContactStore()
/** QR 이 담는 주소 — 지금 주소의 출처 + /app ("https://portal.arasystem.it.kr/app") */
const pageUrl = mobileAppPageUrl(window.location.origin)
/** 휴대폰 브라우저의 주소 칸에 넣을 글 ("portal.arasystem.it.kr/app") */
const pageAddress = addressWithoutScheme(pageUrl)
/** 받을 앱 — 받은 판이 있을 때만 */
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
const factsLine = computed(() => (release.value ? appReleaseFactsLine(release.value) : ''))
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
const steps = computed(() => MOBILE_APP_SETTINGS_TEXT.steps(appReleaseFileName(release.value?.version ?? '')))
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
void appReleaseStore.load()
void supportContactStore.ensureLoaded()
</script>
<template>
<SettingsPageFrame
title-id="mobile-app-page-title"
:title="MOBILE_APP_SETTINGS_TEXT.title"
:description="MOBILE_APP_SETTINGS_TEXT.description"
>
<section
class="mobile-app__section"
aria-labelledby="mobile-app-android-title"
>
<h2
id="mobile-app-android-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_SETTINGS_TEXT.androidTitle }}
</h2>
<!-- 받을 앱 있음 — 앱 줄 + QR 줄 -->
<div
v-if="release"
class="mobile-app__box"
data-mobile-app-release
>
<div class="mobile-app__app">
<MobileAppIcon />
<div class="mobile-app__app-text">
<span class="mobile-app__app-name">{{ MOBILE_APP_TEXT.appName }}</span>
<p
class="mobile-app__facts"
data-mobile-app-facts
>
{{ factsLine }}
</p>
</div>
<MobileAppDownloadLink
class="mobile-app__download"
:size="size"
/>
</div>
<div
class="mobile-app__phone"
data-mobile-app-phone
>
<div class="mobile-app__qr-frame">
<QrCodeImage
:value="pageUrl"
:label="MOBILE_APP_SETTINGS_TEXT.qrLabel"
/>
</div>
<div class="mobile-app__phone-text">
<h3 class="mobile-app__phone-title">
{{ MOBILE_APP_SETTINGS_TEXT.phoneTitle }}
</h3>
<p class="mobile-app__copy">
{{ MOBILE_APP_SETTINGS_TEXT.phoneScan }}
</p>
<div class="mobile-app__typing">
<p class="mobile-app__copy">
{{ MOBILE_APP_SETTINGS_TEXT.phoneTyping }}
</p>
<p
class="mobile-app__address"
data-mobile-app-address
>
{{ pageAddress }}
</p>
</div>
<p class="mobile-app__pc-note">
{{ MOBILE_APP_SETTINGS_TEXT.pcNote }}
</p>
</div>
</div>
</div>
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
<div
v-else-if="appReleaseStore.status === 'none'"
class="mobile-app__none"
data-mobile-app-none
>
<AppIcon
name="smartphone"
:size="24"
:stroke-width="1.5"
class="mobile-app__none-icon"
/>
<div class="mobile-app__none-text">
<span class="mobile-app__none-title">{{ MOBILE_APP_TEXT.noneTitle }}</span>
<span class="mobile-app__none-hint">{{ MOBILE_APP_SETTINGS_TEXT.noneText }}</span>
</div>
<div class="mobile-app__none-support">
<LoginSupportLine
:question="MOBILE_APP_TEXT.noneQuestion"
:fallback="MOBILE_APP_TEXT.noneFallback"
:contact="supportContactStore.text"
/>
</div>
</div>
<!-- 받지 못함 — 칸 자리에 사전 문구와 [다시 시도] -->
<MailLoadError
v-else-if="loadError"
:error="loadError"
leave-label=""
@retry="appReleaseStore.load()"
/>
<div
v-else
class="mobile-app__placeholder"
aria-busy="true"
/>
</section>
<template v-if="release">
<section
class="mobile-app__steps"
aria-labelledby="mobile-app-steps-title"
>
<h2
id="mobile-app-steps-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_TEXT.stepsTitle }}
</h2>
<MobileAppSteps :steps="steps" />
</section>
<section
class="mobile-app__update"
aria-labelledby="mobile-app-update-title"
>
<h2
id="mobile-app-update-title"
class="mobile-app__section-title"
>
{{ MOBILE_APP_SETTINGS_TEXT.updateTitle }}
</h2>
<p
v-for="text in MOBILE_APP_SETTINGS_TEXT.updateTexts"
:key="text"
class="mobile-app__copy"
>
{{ text }}
</p>
</section>
<MobileAppIphoneNote />
</template>
</SettingsPageFrame>
</template>
<style scoped>
.mobile-app__section {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.mobile-app__section-title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
/* 앱 칸 — 테두리 · 모서리 10, 앱 줄과 QR 줄 사이에 가는 선 */
.mobile-app__box {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
/* 앱 줄 — 그림 48 · 이름 · 정보 · [앱 내려받기](44). 좁으면 단추가 이름 아래 전체 폭으로 내려간다 */
.mobile-app__app {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-14);
padding: var(--space-16);
}
.mobile-app__app-text {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
line-height: var(--line-height-heading);
}
.mobile-app__app-name {
font-size: var(--font-size-body);
font-weight: 600;
}
.mobile-app__facts {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.mobile-app__download {
flex: 0 0 auto;
}
/* QR 줄 — QR 160(상자 안쪽 6 · 테두리) + 글 */
.mobile-app__phone {
display: flex;
align-items: flex-start;
gap: var(--space-20);
padding: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__qr-frame {
flex: 0 0 auto;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.mobile-app__phone-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-8);
padding-top: var(--space-2);
}
.mobile-app__phone-title {
margin: 0;
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.mobile-app__copy {
margin: 0;
color: var(--color-text-strong);
}
.mobile-app__typing {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: var(--space-10);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__address {
margin: 0;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--color-text);
}
.mobile-app__pc-note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 24 · 제목 · 안내 · 가는 선 아래 문의 줄 */
.mobile-app__none {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.mobile-app__none-icon {
color: var(--color-icon-muted);
}
.mobile-app__none-text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mobile-app__none-title {
font-size: var(--font-size-body);
font-weight: 600;
}
.mobile-app__none-hint {
color: var(--color-text-secondary);
}
.mobile-app__none-support {
align-self: stretch;
margin-top: var(--space-2);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.mobile-app__placeholder {
min-height: 16rem;
}
.mobile-app__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.mobile-app__update {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 좁은 폭(휴대폰) — QR 줄은 빼고(휴대폰에서는 [앱 내려받기]로 바로 받는다) 단추는 이름 아래 전체 폭 */
@media (max-width: 30rem) {
.mobile-app__phone {
display: none;
}
.mobile-app__download {
flex: 1 1 100%;
}
}
</style>
@@ -9,8 +9,9 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안")
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
// "13단계" · "휴대폰 앱 받기")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -35,7 +36,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -48,6 +49,7 @@ describe('SettingsLayout', () => {
['2단계 인증', '/settings/account/two-factor'],
['앱 비밀번호', '/settings/account/app-passwords'],
['메일 프로그램 연결', '/settings/account/mail-clients'],
['휴대폰 앱', '/settings/account/mobile-app'],
['로그인 기록', '/settings/account/login-history'],
],
[
@@ -145,6 +147,24 @@ describe('SettingsLayout', () => {
expect(clock.findAll('path').map((shape) => shape.attributes('d'))).toContain('M3 3v5h5')
})
it('설정 › 계정 › 휴대폰 앱 — /settings/account/mobile-app, 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/account/mobile-app')
expect(router.currentRoute.value.name).toBe('settings-account-mobile-app')
expect(router.currentRoute.value.meta).toMatchObject({ title: '휴대폰 앱', requiresAuth: true })
expect(router.resolve({ name: 'settings-account-mobile-app' }).fullPath).toBe('/settings/account/mobile-app')
})
it('휴대폰 앱 메뉴는 휴대폰 아이콘(smartphone) · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/account/mobile-app')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('휴대폰 앱')
expect(current.find('rect').attributes()).toMatchObject({ x: '5', y: '2', width: '14', height: '20' })
expect(current.find('line').attributes()).toMatchObject({ x1: '12', y1: '18', x2: '12.01', y2: '18' })
})
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/large-files')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
+24
View File
@@ -44,6 +44,30 @@ describe('router 가드', () => {
expect(router.currentRoute.value.query.redirect).toBe('/settings/mail/large-files')
})
// 휴대폰 받기 화면 /app (2026-11-08 — 디자인 README "휴대폰 앱 받기") — 앱 틀 밖의 보호 화면. QR 코드 · 앱의 [새 버전 받기]가 연다
it('휴대폰 받기 화면(/app)은 로그인해야 열린다 — 세션이 없으면 로그인으로, 돌아올 곳은 /app', async () => {
const requests = installMockApi(() => fail(401, 'AUTH_001'))
await router.push('/app')
expect(requests.map((request) => request.url)).toEqual(['/auth/me', '/auth/refresh'])
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/app')
})
it('로그인해 둔 브라우저는 /app 을 바로 연다(탭 제목 "휴대폰 앱 받기 · ARA SYSTEM") — 로그인 화면의 redirect=/app 은 /app 으로 돌려보낸다', async () => {
installMockApi(() => ok(USER))
useAuthStore().markAuthenticated(USER)
await router.push('/app')
expect(router.currentRoute.value.name).toBe('mobile-app')
expect(router.currentRoute.value.meta).toMatchObject({ requiresAuth: true, title: '휴대폰 앱 받기' })
expect(document.title).toBe('휴대폰 앱 받기 · ARA SYSTEM')
await router.push('/login?redirect=/app')
expect(router.currentRoute.value.fullPath).toBe('/app')
})
// 제한된 세션 (13단계 — 계약 6절) — 로그인은 됐지만 새 비밀번호 · 2단계 켜기를 마칠 때까지 보호 화면 대신 로그인 틀의 그 단계
it('제한된 세션이면 보호 화면 대신 로그인으로 — 돌아올 곳은 가려던 주소', async () => {
installMockApi(() => ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
+23 -1
View File
@@ -3,6 +3,12 @@ import type { RouteRecordRaw } from 'vue-router'
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
import { PUBLIC_SHARE_ROUTE } from '@/shared/constants/large-file'
import { MAIL_PRINT_ROUTE } from '@/shared/constants/mail-print'
import {
MOBILE_APP_PAGE_TEXT,
MOBILE_APP_PATH,
MOBILE_APP_ROUTE,
MOBILE_APP_SETTINGS_TEXT,
} from '@/shared/constants/mobile-app'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import {
MAIL_SCHEDULED_MESSAGE_ROUTE,
@@ -41,6 +47,15 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/mail/MailPrintPage.vue'),
meta: { requiresAuth: true, title: '메일 인쇄' },
},
{
// 휴대폰 받기 화면 (2026-11-08 — 디자인 README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app") — 앱 틀 없이 안드로이드 앱(APK) 받기.
// 설정 › 휴대폰 앱의 QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 연다. 로그인해야 열린다 — 로그인하지 않았으면
// 가드가 로그인 화면으로 보냈다가(?redirect=/app) 로그인을 마치면 돌아온다. 경로는 앱도 쓰는 약속이다(MOBILE_APP_PATH)
path: MOBILE_APP_PATH,
name: MOBILE_APP_ROUTE,
component: () => import('@/pages/mobile-app/MobileAppPage.vue'),
meta: { requiresAuth: true, title: MOBILE_APP_PAGE_TEXT.title },
},
{
path: '/mail',
component: () => import('@/pages/mail/MailLayout.vue'),
@@ -96,7 +111,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -129,6 +144,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/AccountMailClientsPage.vue'),
meta: { title: '메일 프로그램 연결' },
},
{
// 휴대폰 앱 (2026-11-08 — 디자인 README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱") — 안드로이드 앱(APK) 받기 · QR · 설치 방법
path: 'account/mobile-app',
name: SETTINGS_ROUTES.mobileApp,
component: () => import('@/pages/settings/AccountMobileAppPage.vue'),
meta: { title: MOBILE_APP_SETTINGS_TEXT.title },
},
{
path: 'account/login-history',
name: SETTINGS_ROUTES.loginHistory,
@@ -0,0 +1,93 @@
// 휴대폰 앱 받기 — 설정 › 계정 › 휴대폰 앱(PC)과 휴대폰 받기 화면 /app 의 글 (2026-11-08 — 디자인 원본 SettingsMobileApp · README "휴대폰 앱 받기")
// 안드로이드 앱(APK)은 스토어에 올리지 않고 웹메일에 로그인한 직원만 받는다. 글은 확정한 보드(캔버스 v68)에서 그대로 옮겼고,
// 이제부터 이 상수가 글의 기준이다 — 고칠 때는 README 의 그 절도 함께 고친다 (README "화면 문구 — 사용자에게 말하듯").
// 서버가 거절한 까닭의 문구는 에러 코드 사전에서만 온다(받지 못함 · 다시 시도 — 화면 글로 만들지 않는다).
import { APP_NAME } from '@/shared/constants/brand'
/**
* 휴대폰 받기 화면의 주소 — 설정 화면의 QR 코드가 담고(지금 주소의 출처 + 이 경로), 앱의 업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기]가 연다
* (앱은 API_URL 에서 /api 를 뗀 출처 + 이 경로 — 같은 주소 배포). 바꾸면 앱도 함께 바꾼다
*/
export const MOBILE_APP_PATH = '/app'
/** 휴대폰 받기 화면의 라우트 이름 */
export const MOBILE_APP_ROUTE = 'mobile-app'
/** 받는 파일 이름 "ARA-SYSTEM-<버전>.apk" — 서버의 Content-Disposition 과 같은 꼴 (설치 차례 글에만 쓴다 — 실제 이름은 서버가 정한다) */
export const APP_RELEASE_FILE_PREFIX = 'ARA-SYSTEM-'
export const APP_RELEASE_FILE_EXTENSION = '.apk'
/** 두 화면이 함께 쓰는 글 */
export const MOBILE_APP_TEXT = {
/** 앱 이름 — 앱 칸 · 받기 화면의 카드 */
appName: APP_NAME,
/** 앱 정보의 이름 — 설정은 "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB" 한 줄, 받기 화면은 이름 · 값 두 칸 */
versionLabel: '버전',
releasedOnLabel: '올린 날',
sizeLabel: '크기',
/** 받기 단추 */
download: '앱 내려받기',
/** 받기 단추의 화면 낭독 이름 — "앱 내려받기 (31 MB)" (크기를 모르면 단추 글 그대로) */
downloadLabel: (size: string) => `앱 내려받기 (${size})`,
stepsTitle: '설치 방법',
/** 올린 앱이 없을 때 (404 RES_001) */
noneTitle: '아직 받을 수 있는 앱이 없습니다',
/** 올린 앱이 없을 때의 문의 줄 — 연락처가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 fallback 한 줄 */
noneQuestion: '언제 받을 수 있는지 궁금하신가요?',
noneFallback: '언제 받을 수 있는지는 관리자에게 문의해 주세요.',
/** 아이폰 안내 상자 — 「메일 프로그램 연결」이 그 설정 화면 링크 */
iphoneTitle: '아이폰용 앱은 아직 없습니다',
iphoneBefore: '아이폰에서는 브라우저로 웹메일을 쓰거나, 기본 메일 앱에 회사 메일을 연결해 주세요. 연결 방법은 ',
iphoneLink: '메일 프로그램 연결',
iphoneAfter: '에서 볼 수 있습니다.',
} as const
/** 설정 › 계정 › 휴대폰 앱 (PC) — 보드 A */
export const MOBILE_APP_SETTINGS_TEXT = {
/** 메뉴 이름 · 탭 제목 · 머리 */
title: '휴대폰 앱',
description: '안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
androidTitle: '안드로이드 앱',
/** QR 코드의 화면 낭독 이름 — 담은 주소는 비밀이 아니다 */
qrLabel: '휴대폰에서 앱을 받는 주소의 QR 코드',
phoneTitle: '휴대폰으로 받기',
phoneScan: '휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.',
phoneTyping: 'QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.',
pcNote: 'PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.',
/** 설치 차례 — 받는 파일 이름("ARA-SYSTEM-1.0.0.apk")을 둘째 줄에 넣는다 */
steps: (fileName: string): readonly string[] => [
'휴대폰에서 [앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 이 파일을 받을지 한 번 더 물으면 그대로 받아 주세요.',
`받은 ‘${fileName}’ 파일을 열어 주세요. 처음 설치할 때는 휴대폰이 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 돌아와 설치를 이어 가 주세요.`,
'Play 스토어를 거치지 않은 앱이라 Play 프로텍트가 경고할 수 있습니다. 웹메일에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요. 앱 비밀번호는 만들지 않으셔도 됩니다.',
],
updateTitle: '새 버전이 나오면',
updateTexts: [
'새 버전도 같은 곳에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
'꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.',
],
/** 올린 앱이 없을 때 — 제목은 MOBILE_APP_TEXT.noneTitle */
noneText: '앱이 준비되면 이곳에서 받아 설치할 수 있습니다.',
} as const
/** 휴대폰 받기 화면 /app — 보드 B (탭 제목 "휴대폰 앱 받기 · ARA SYSTEM") */
export const MOBILE_APP_PAGE_TEXT = {
/** 탭 제목 · 브랜드 띠의 제목 */
title: '휴대폰 앱 받기',
lead: '안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
/** 카드의 앱 이름 아래 */
platform: '안드로이드 앱',
steps: [
'[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.',
'받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.',
'Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.',
],
/** 설치 방법 아래 한 줄 — 앱의 [새 버전 받기]로 온 직원이 쓰던 앱을 먼저 지우지 않게 */
updateText: '새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
/** 받을 앱이 있을 때의 문의 줄 */
supportQuestion: '설치가 잘 되지 않나요?',
supportFallback: '설치가 잘 되지 않으면 관리자에게 문의해 주세요.',
/** 올린 앱이 없을 때 — 제목은 MOBILE_APP_TEXT.noneTitle */
noneText: '앱이 준비되면 이 화면에서 받을 수 있습니다.',
} as const
@@ -1,10 +1,12 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래 ·
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결)
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결 ·
// "휴대폰 앱 받기"(2026-11-08) — 메일 프로그램 연결 아래 휴대폰 앱)
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크)가 이 이름을 함께 쓴다.
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크 · 휴대폰 앱의 아이폰 안내)가 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
import { MOBILE_APP_SETTINGS_TEXT } from '@/shared/constants/mobile-app'
export const SETTINGS_ROUTES = {
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
@@ -14,6 +16,8 @@ export const SETTINGS_ROUTES = {
appPasswords: 'settings-account-app-passwords',
/** 메일 프로그램 연결 — 설정값 · 프로그램별 연결 차례 (13단계) */
mailClients: 'settings-account-mail-clients',
/** 휴대폰 앱 — 안드로이드 앱(APK) 받기 · QR · 설치 방법 (2026-11-08) */
mobileApp: 'settings-account-mobile-app',
loginHistory: 'settings-account-login-history',
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
@@ -49,6 +53,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.twoFactor, label: '2단계 인증', icon: 'shield-check' },
{ routeName: SETTINGS_ROUTES.appPasswords, label: '앱 비밀번호', icon: 'key' },
{ routeName: SETTINGS_ROUTES.mailClients, label: '메일 프로그램 연결', icon: 'monitor-smartphone' },
{ routeName: SETTINGS_ROUTES.mobileApp, label: MOBILE_APP_SETTINGS_TEXT.title, icon: 'smartphone' },
{ routeName: SETTINGS_ROUTES.loginHistory, label: '로그인 기록', icon: 'history' },
],
},
+12
View File
@@ -0,0 +1,12 @@
// 휴대폰 앱 받기 API 공유 타입 — 서버와 맞춘 약속(2026-11-08 — 계약에 새 절로 들어간다)과 같은 구조
// GET /api/app-releases/android/latest(로그인 필요) — 올린 판이 없으면 404 RES_001. 파일은 같은 경로의 /file(<a href> 이동으로 받는다)
/** 가장 늦게 올린 안드로이드 판 — "최신"은 올린 시각 차례다(버전 차례가 아니다 — 옛 판을 다시 올리면 그것이 최신) */
export interface AppRelease {
/** "1.0.0" */
version: string
/** 파일 크기 (바이트) */
sizeBytes: number
/** 올린 때 — ISO 8601 UTC */
releasedAt: string
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import type { AppRelease } from '@/shared/types/app-release'
import {
addressWithoutScheme,
appDownloadLabel,
appReleaseFacts,
appReleaseFactsLine,
appReleaseFileName,
appReleaseSize,
mobileAppPageUrl,
} from './app-release'
// 휴대폰 앱 받기 — 앱 정보 글 (디자인 원본 SettingsMobileApp: "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB")
const SEOUL = 'Asia/Seoul'
const UTC = 'UTC'
/** 31 MB — 서버 약속의 예시 값 */
const RELEASE: AppRelease = { version: '1.0.0', sizeBytes: 32505856, releasedAt: '2026-11-18T01:23:45.000Z' }
describe('appReleaseFacts', () => {
it('버전 · 올린 날(연도까지 쓴 날짜) · 크기(첨부와 같은 표기)', () => {
expect(appReleaseFacts(RELEASE, SEOUL)).toEqual([
{ label: '버전', value: '1.0.0' },
{ label: '올린 날', value: '2026년 11월 18일' },
{ label: '크기', value: '31 MB' },
])
})
it('올린 날은 시간대의 날짜다 — 한국 아침 7시에 올린 판은 UTC 로는 전날', () => {
const early: AppRelease = { ...RELEASE, releasedAt: '2026-11-17T22:00:00.000Z' }
expect(appReleaseFacts(early, SEOUL)[1]).toEqual({ label: '올린 날', value: '2026년 11월 18일' })
expect(appReleaseFacts(early, UTC)[1]).toEqual({ label: '올린 날', value: '2026년 11월 17일' })
})
it('읽을 수 없는 값은 칸째 뺀다 — 빈 버전 · 날짜가 아님 · 음수 크기', () => {
expect(appReleaseFacts({ version: ' ', sizeBytes: -1, releasedAt: 'not-a-date' }, SEOUL)).toEqual([])
expect(appReleaseFacts({ ...RELEASE, releasedAt: '' }, SEOUL).map((fact) => fact.label)).toEqual(['버전', '크기'])
})
it('버전 앞뒤 공백은 뗀다', () => {
expect(appReleaseFacts({ ...RELEASE, version: ' 1.2.0 ' }, SEOUL)[0]).toEqual({ label: '버전', value: '1.2.0' })
})
})
describe('appReleaseFactsLine', () => {
it('설정 화면의 한 줄 — 칸을 가운뎃점으로 잇는다', () => {
expect(appReleaseFactsLine(RELEASE, SEOUL)).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
expect(appReleaseFactsLine(RELEASE, UTC)).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
})
it('뺀 칸은 가운뎃점도 없다', () => {
expect(appReleaseFactsLine({ ...RELEASE, sizeBytes: Number.NaN }, SEOUL)).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일')
})
})
describe('크기 · 받기 단추 이름 · 파일 이름', () => {
it('크기 — 1MB 넘으면 MB, 10 미만은 소수 한 자리', () => {
expect(appReleaseSize(RELEASE)).toBe('31 MB')
expect(appReleaseSize({ ...RELEASE, sizeBytes: 9.5 * 1024 * 1024 })).toBe('9.5 MB')
})
it('[앱 내려받기]의 화면 낭독 이름 — 크기를 모르면 단추 글 그대로', () => {
expect(appDownloadLabel('31 MB')).toBe('앱 내려받기 (31 MB)')
expect(appDownloadLabel('')).toBe('앱 내려받기')
})
it('받는 파일 이름 — ARA-SYSTEM-<버전>.apk (서버의 Content-Disposition 과 같은 꼴)', () => {
expect(appReleaseFileName('1.0.0')).toBe('ARA-SYSTEM-1.0.0.apk')
expect(appReleaseFileName(' 1.0.1 ')).toBe('ARA-SYSTEM-1.0.1.apk')
})
})
describe('휴대폰 받기 화면 주소', () => {
it('지금 주소의 출처 + /app — QR 이 담는다', () => {
expect(mobileAppPageUrl('https://portal.arasystem.it.kr')).toBe('https://portal.arasystem.it.kr/app')
expect(mobileAppPageUrl('http://localhost:5173/')).toBe('http://localhost:5173/app')
})
it('주소 칸에 넣을 글은 앞의 https:// 를 뗀다', () => {
expect(addressWithoutScheme('https://portal.arasystem.it.kr/app')).toBe('portal.arasystem.it.kr/app')
expect(addressWithoutScheme('http://localhost:5173/app')).toBe('localhost:5173/app')
expect(addressWithoutScheme('portal.arasystem.it.kr/app')).toBe('portal.arasystem.it.kr/app')
})
})
+61
View File
@@ -0,0 +1,61 @@
// 휴대폰 앱 받기 — 앱 정보를 화면 글로 다듬는다 (설정 › 계정 › 휴대폰 앱 · 휴대폰 받기 화면 /app — 순수 함수만 둔다)
import {
APP_RELEASE_FILE_EXTENSION,
APP_RELEASE_FILE_PREFIX,
MOBILE_APP_PATH,
MOBILE_APP_TEXT,
} from '@/shared/constants/mobile-app'
import type { AppRelease } from '@/shared/types/app-release'
import { formatFileSize, formatLongDate } from '@/shared/utils/format'
/** 앱 정보 한 칸 — 이름(버전 · 올린 날 · 크기)과 값 */
export interface AppReleaseFact {
label: string
value: string
}
/**
* 앱 정보 — 버전 · 올린 날 · 크기. 올린 날은 연도까지 쓴 날짜("2026년 11월 18일" — 다른 설정 화면처럼 브라우저 시간대),
* 크기는 첨부와 같은 표기("31 MB"). 읽을 수 없는 값(빈 버전 · 날짜가 아님 · 크기가 음수)은 칸째 뺀다
*/
export function appReleaseFacts(release: AppRelease, timeZone?: string): AppReleaseFact[] {
const facts: AppReleaseFact[] = [
{ label: MOBILE_APP_TEXT.versionLabel, value: release.version.trim() },
{ label: MOBILE_APP_TEXT.releasedOnLabel, value: formatLongDate(release.releasedAt, timeZone) },
{ label: MOBILE_APP_TEXT.sizeLabel, value: appReleaseSize(release) },
]
return facts.filter((fact) => fact.value !== '')
}
/** 설정 화면의 한 줄 — "버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB" (칸 구분의 가운뎃점 — 나열이 아니다) */
export function appReleaseFactsLine(release: AppRelease, timeZone?: string): string {
return appReleaseFacts(release, timeZone)
.map((fact) => `${fact.label} ${fact.value}`)
.join(' · ')
}
/** 크기 글 — "31 MB". 읽을 수 없으면 빈 글 */
export function appReleaseSize(release: AppRelease): string {
return formatFileSize(release.sizeBytes)
}
/** [앱 내려받기]의 화면 낭독 이름 — "앱 내려받기 (31 MB)", 크기를 모르면 단추 글 그대로 */
export function appDownloadLabel(size: string): string {
return size ? MOBILE_APP_TEXT.downloadLabel(size) : MOBILE_APP_TEXT.download
}
/** 받는 파일 이름 — "ARA-SYSTEM-1.0.0.apk" (서버의 Content-Disposition 과 같은 꼴) */
export function appReleaseFileName(version: string): string {
return `${APP_RELEASE_FILE_PREFIX}${version.trim()}${APP_RELEASE_FILE_EXTENSION}`
}
/** 휴대폰 받기 화면의 주소 — QR 이 담는다. 지금 주소의 출처 + /app ("https://portal.arasystem.it.kr/app") */
export function mobileAppPageUrl(origin: string): string {
return `${origin.replace(/\/+$/, '')}${MOBILE_APP_PATH}`
}
/** 주소 칸에 그대로 넣을 글 — 앞의 https:// · http:// 를 뗀다 ("portal.arasystem.it.kr/app") */
export function addressWithoutScheme(url: string): string {
return url.replace(/^https?:\/\//i, '')
}
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockResult,
} from '@/test-utils/mock-api'
import { useAppReleaseStore } from './app-release.store'
// 휴대폰 앱 받기 — 가장 늦게 올린 안드로이드 판 (GET /api/app-releases/android/latest)
// 404 RES_001 은 "받을 앱 없음"(알림 없음), 그 밖의 실패는 화면이 칸 자리에 사전 문구와 [다시 시도] — 알림은 모두 끈다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const RELEASE: AppRelease = { version: '1.0.0', sizeBytes: 32505856, releasedAt: '2026-11-18T01:23:45.000Z' }
/** 응답을 손으로 보내는 요청 — 늦게 온 응답을 흉내 낸다 */
function deferredReply() {
let resolve: (result: MockResult) => void = () => {}
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('app-release.store', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
it('받을 앱이 있으면 ready — 그 경로를 알림을 끈 채 묻는다', async () => {
const requests = installMockApi(() => ok(RELEASE))
const store = useAppReleaseStore()
await store.load()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /app-releases/android/latest'])
expect(requests[0]?.config.silent).toBe(true)
expect(store.status).toBe('ready')
expect(store.release).toEqual(RELEASE)
expect(store.error).toBeNull()
})
it('404 RES_001 은 실패가 아니라 "받을 앱 없음" — 알리지 않는다', async () => {
installMockApi(() => fail(404, 'RES_001'))
const store = useAppReleaseStore()
await store.load()
expect(store.status).toBe('none')
expect(store.release).toBeNull()
expect(store.error).toBeNull()
expect(useToastStore().toasts).toEqual([])
})
it('그 밖의 실패는 error — 사전 문구 그대로 상태에 담고 알리지 않는다(화면이 [다시 시도]를 그린다)', async () => {
installMockApi(() => fail(503, 'SYS_001'))
const store = useAppReleaseStore()
await store.load()
expect(store.status).toBe('error')
expect(store.error?.code).toBe('SYS_001')
expect(store.error?.traceId).toBe('3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d')
expect(useToastStore().toasts).toEqual([])
})
it('네트워크가 끊겨도 error — 다시 받으면 그 결과로 바뀐다', async () => {
let reply: MockResult = 'network'
installMockApi(() => reply)
const store = useAppReleaseStore()
await store.load()
expect(store.status).toBe('error')
reply = ok(RELEASE)
const loading = store.load()
expect(store.status).toBe('loading')
expect(store.error).toBeNull()
await loading
expect(store.status).toBe('ready')
})
it('세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다 — 받아 둔 값도 지운다', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useAppReleaseStore()
const loading = store.load()
expect(store.status).toBe('loading')
// 받는 사이에 로그아웃했다
useAuthStore().clearAuth()
reply.resolve(ok(RELEASE))
await loading
await flushPromises()
expect(store.status).toBe('idle')
expect(store.release).toBeNull()
})
it('늦게 온 실패도 세션이 바뀌었으면 쓰지 않는다 (다른 계정으로 로그인)', async () => {
const reply = deferredReply()
installMockApi(() => reply.promise)
const store = useAppReleaseStore()
const loading = store.load()
useAuthStore().startSession({ email: 'seojun.park@arasystem.it.kr', name: '박서준' })
reply.resolve(fail(503, 'SYS_001'))
await loading
await flushPromises()
expect(store.status).toBe('idle')
expect(store.error).toBeNull()
})
it('다시 받기가 시작됐으면 앞의 늦은 응답은 버린다', async () => {
const first = deferredReply()
let calls = 0
installMockApi(() => {
calls += 1
return calls === 1 ? first.promise : fail(404, 'RES_001')
})
const store = useAppReleaseStore()
const stale = store.load()
await store.load()
expect(store.status).toBe('none')
first.resolve(ok(RELEASE))
await stale
expect(store.status).toBe('none')
expect(store.release).toBeNull()
})
})
+70
View File
@@ -0,0 +1,70 @@
import { ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useAppReleases } from '@/composables/useAppReleases'
import type { AppRelease } from '@/shared/types/app-release'
import { useAuthStore } from '@/stores/auth.store'
/**
* 받을 앱 정보의 상태 — idle(받기 전) · loading · ready(받을 앱 있음) · none(올린 앱 없음 — 404 RES_001) · error(받지 못함 — 화면이 사전 문구와 [다시 시도])
*/
export type AppReleaseStatus = 'idle' | 'loading' | 'ready' | 'none' | 'error'
// 휴대폰 앱 받기 스토어 — 가장 늦게 올린 안드로이드 판 (GET /api/app-releases/android/latest)
// - 설정 › 계정 › 휴대폰 앱 · 휴대폰 받기 화면 /app 이 들어올 때마다 새로 받는다(운영자가 새 판을 올렸을 수 있다).
// - 알림은 모두 끈다 — 404 RES_001 은 실패가 아니라 "받을 앱 없음"이고, 그 밖의 실패는 화면이 칸 자리에 사전 문구와 [다시 시도]를 그린다
// (세션 만료 대화상자는 silent 와 상관없이 뜬다).
// - 늦게 온 응답은 쓰지 않는다 — 다시 받기(seq)가 시작됐거나 세션 세대(authStore.generation — 로그아웃 · 만료 · 다른 계정 로그인)가 바뀌었으면 버린다.
// 세션이 바뀌면 받아 둔 값도 지운다
export const useAppReleaseStore = defineStore('app-release', () => {
const api = useAppReleases()
const authStore = useAuthStore()
const release = shallowRef<AppRelease | null>(null)
const status = ref<AppReleaseStatus>('idle')
const error = ref<ApiError | null>(null)
let seq = 0
/** 새로 받는다 — 결과는 상태로만 남긴다(던지지 않는다) */
async function load(): Promise<void> {
seq += 1
const current = seq
const session = authStore.generation
status.value = 'loading'
error.value = null
const stale = () => current !== seq || session !== authStore.generation
try {
const result = await api.getLatestAndroidRelease({ silent: true })
if (stale()) return
release.value = result
status.value = 'ready'
} catch (caught) {
if (stale()) return
const failure = toApiError(caught)
release.value = null
if (failure.code === 'RES_001') {
status.value = 'none'
return
}
status.value = 'error'
error.value = failure
}
}
function reset() {
seq += 1
release.value = null
status.value = 'idle'
error.value = null
}
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 앞 세션의 응답 · 값이 남지 않게
watch(
() => authStore.generation,
() => reset(),
)
return { release, status, error, load, reset }
})
+1
View File
@@ -22,6 +22,7 @@
--font-size-body: 0.875rem; /* 14px — 본문·입력 */
--font-size-title-sm: 0.9375rem; /* 15px — 영역 제목 */
--font-size-title: 1rem; /* 16px — 상단 바 모듈 이름 */
--font-size-title-md: 1.0625rem; /* 17px — 휴대폰 앱 받기 화면(/app)의 앱 이름 · 받을 앱이 없을 때의 제목 (디자인 원본 SettingsMobileApp) */
--font-size-title-lg: 1.125rem; /* 18px — 설정 화면 제목 (디자인 원본 Settings) */
--font-size-heading: 1.25rem; /* 20px — 메일 제목 */
--font-size-heading-lg: 1.375rem; /* 22px — 로그인 제목 */
+8
View File
@@ -71,6 +71,14 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 앱 틀 밖의 휴대폰 받기 화면 (router/routes.ts 와 같은 이름 · 모양)
{
path: '/settings/account/mobile-app',
name: 'settings-account-mobile-app',
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/app', name: 'mobile-app', component: Empty, meta: { requiresAuth: true } },
{
path: '/settings/account/login-history',
name: 'settings-account-login-history',
+42 -3
View File
@@ -778,11 +778,11 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
- **조각 올리기** `PUT /api/file-uploads/{id}/chunks/{n}` 만 본문 **17m**(조각 16MiB + 여유 — 계약 상수라 env 로 바꾸지 않는다) · 읽기 90초. 요청 본문은 nginx 가 다 받아 둔 뒤 넘긴다(`/tmp/client_temp`).
그 밖의 `/api/file-uploads` · `/api/file-quota` · `/api/share-links` · `/api/share-link-lookups` 는 JSON 1m 그대로다. 조각 요청은 포털 호스트로 온다.
이 경로의 오류 로그는 `error` 부터다 — 조각마다 nginx 가 `a client request body is buffered to a temporary file`(warn)을 남겨 2GB 파일 하나에 128줄이 쌓였다(본문 버퍼는 키우지 않는다 — 동시 조각 수 × 16MiB 가 메모리에 앉는다).
- **받기** `/api/public-links/<토큰>/files/…`(공개) · `/api/messages/<id>/large-files/…`(앱 안) — 응답 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(실시간 알림과 같다),
- **받기** `/api/public-links/<토큰>/files/…`(공개) · `/api/messages/<id>/large-files/…`(앱 안) · `/api/app-releases/<플랫폼>/latest/file`(휴대폰 앱 APK — 앱 안 받기와 같은 그릇으로 센다) — 응답 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(실시간 알림과 같다),
`Range` · `If-Range` 는 그대로 넘긴다(백엔드가 206 · 416). 머리글 버퍼는 16k(`proxy_buffer_size` — 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이라 기본 4k 로는 빠듯하다).
**동시 연결** — 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 전체는 공개 400 · 앱 안 200 을 **따로** 센다(공개 받기가 가득 차도 직원의 앱 안 받기는 된다). 넘으면 429 `REQ_001`(JSON).
클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값)이다 — 앞에 꾸민 값을 붙여도 마지막 칸으로 센다.
두 경로는 **대소문자 · `%2F` · 겹 `/` 를 가리지 않고** 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아 `/api/Messages/…/LARGE-FILES/…` 도 받기 처리기에 닿기 때문이다
세 경로는 **대소문자 · `%2F` · 겹 `/` 를 가리지 않고** 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아 `/api/Messages/…/LARGE-FILES/…` 도 받기 처리기에 닿기 때문이다
(nginx 의 대소문자를 가리는 prefix 에 기대면 한도 · 버퍼링 끔을 비켜 간다 — 2026-10-30 리뷰. CI 의 infra 잡 "대용량 받기 경로 시험"이 변형마다 실제 요청으로 본다).
그릇은 nginx 워커마다 `worker_connections` 4096(이미지 기본 1024 에서 올림 — `frontend/Dockerfile`) · frontend 의 열린 파일 수 8192(compose `ulimits`)다 —
받기 600 개(연결 1,200) · 실시간 알림(브라우저마다 연결 둘) · 포털 요청이 워커 하나에 몰려도 모자라지 않게
@@ -1157,6 +1157,45 @@ setup 이 엔진의 `x:DataRetention` 을 맞춘다 — 휴지통 · 스팸메
---
## 휴대폰 앱 올리기 (사내 배포 — 2026-11-08)
휴대폰 앱(안드로이드 APK)은 스토어 없이 포털에서 나눠 준다 — 로그인한 직원이 웹메일 설정 › 계정 › 휴대폰 앱이나 휴대폰의 `<포털>/app` 에서 받는다(계약 1절 "휴대폰 앱 받기").
올리기는 API 가 없고 서버 B 의 운영 도구로 한다. 파일은 업로드 볼륨 `<PROJECT_NAME>_uploads` 의 `app-releases/` 라 매일 [백업](#백업)에 든다.
빌드 · 서명 키는 [flutter/README](../flutter/README.md) — **같은 서명 키로 빌드한 APK 만 덮어 깔린다**(키를 잃으면 직원마다 앱을 지우고 다시 깔아야 한다).
```bash
# 0) 빌드한 PC 에서 서버 B 로 — 서버 A 를 거쳐(관리 화면 SSH 터널과 같은 길)
scp -J <서버 A> ARA-SYSTEM-1.0.0.apk <서버 B>:~/
# 1) 서버 B 의 저장소 폴더에서 — 백엔드 컨테이너의 /tmp 로.
# root 소유로 들어가니 컨테이너의 node 가 읽게 먼저 644 로
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
chmod 644 ~/ARA-SYSTEM-1.0.0.apk
sudo docker cp ~/ARA-SYSTEM-1.0.0.apk "${PROJECT_NAME}-backend:/tmp/"
# 2) 운영 도구 → 12. 휴대폰 앱 올리기 → 경로 /tmp/ARA-SYSTEM-1.0.0.apk
# → 버전(빈 값이면 파일 이름의 1.0.0) → 바뀔 것(버전 · 크기 · SHA-256) y
sudo docker exec -it "${PROJECT_NAME}-backend" node dist/cli/ops.js
# 3) 끝에 보이는 SHA-256 을 빌드한 PC 의 값과 견준다
# (PC 의 PowerShell: certutil -hashfile ARA-SYSTEM-1.0.0.apk SHA256)
# 4) 넣었던 파일 지우기 — 볼륨에 사본이 있다 (root 소유라 -u 0)
sudo docker exec -u 0 "${PROJECT_NAME}-backend" rm /tmp/ARA-SYSTEM-1.0.0.apk
rm ~/ARA-SYSTEM-1.0.0.apk
```
- 올린 순간 직원이 받는 버전이 된다 — **최신 = 가장 늦게 올린 것**(버전 번호가 아니다). 확인은 운영 도구 `13. 휴대폰 앱 목록`(▶ 가 직원이 받는 버전, 볼륨에 파일이 없으면 "파일 없음 — 다시 올리세요").
- 도구가 막는 것: 이름이 `.apk` 가 아님 · zip 머리가 아님 · 1KB 아래 · 200MB 위 · 바로가기(심볼릭 링크) · 읽을 권한 없음(위 1 의 644),
버전이 `숫자.숫자.숫자` 가 아님 · 서버가 받는 가장 낮은 버전(app-config — 지금 1.0.0)보다 낮음. 지금 최신보다 낮거나 같은 버전은 묻고 올린다.
- **되돌리기**: 옛 APK 를 다시 올리면 그것이 최신이 된다. 단 더 높은 버전을 이미 깐 휴대폰은 안드로이드가 낮은 버전으로 덮어 깔지 않는다(앱을 지우고 다시 깔아야 한다 — 로그인도 다시).
그래서 고친 판을 더 높은 버전(pubspec 의 `version` — `+` 뒤의 빌드 번호도)으로 다시 빌드해 올리는 편이 낫다. 옛 판은 지우지 않는다(표 · 볼륨에 남는다).
- **직원 안내** (새 버전을 올린 뒤 — 예시). 꼭 필요한 업데이트는 앱이 알려 준다(업데이트 화면의 [새 버전 받기]):
> 휴대폰 앱 새 버전(1.0.1)을 올렸습니다. PC 웹메일의 [설정] → [휴대폰 앱]에 있는 QR 코드를 휴대폰 카메라로 찍은 뒤, 열린 화면에서 [앱 내려받기]를 눌러 설치해 주세요.
> 앱을 이미 쓰고 계시면 앱의 [설정] → [새 버전 받기]를 눌러도 됩니다.
> 휴대폰이 앱 설치를 허용할지 물으면 허용해 주세요. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.
안내에는 **받는 주소 · 링크를 넣지 않는다** — 메뉴 경로만 적는다. "메일의 링크로 APK 를 받아 설치하라"는 가짜 APK 피싱이 노리는 꼴이고,
회사 안내에 링크가 없다는 습관이 링크 달린 가짜를 드러낸다(서버가 넣는 알림 메일의 규칙과 같은 까닭 — `.claude/rules/security.md`).
---
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.
@@ -1770,7 +1809,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a
| --- | --- | --- |
| 메일 엔진 `<PROJECT_NAME>_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 |
| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 |
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` |
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 30MB 남짓, 지우는 명령이 없어 올린 판이 모두 남는다) |
| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 |
| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 |