generated from ttipo/project-base
Compare commits
7 Commits
117ec6c767
...
ff1948b7df
| Author | SHA1 | Date | |
|---|---|---|---|
| ff1948b7df | |||
| 7c70862647 | |||
| e9acb7809c | |||
| 433ef12cc3 | |||
| f2ba81513e | |||
| 1f8555340e | |||
| 178a53fd6f |
@@ -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)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
|
||||
|
||||
@@ -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
@@ -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]`) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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('메뉴의 번호를 넣어 주세요.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 "새 메일 알림").
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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
@@ -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` 으로 맞춰 두었다(맥에서 확인 전).
|
||||
|
||||
## 보안 메모
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 — 로그인 제목 */
|
||||
|
||||
@@ -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
@@ -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(상태 없음), 로그 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user