feat(n4): wire FCM token path and document Master secrets

Add PushTokenService with Firebase Messaging fallback to install
placeholders, conditional google-services Gradle plugin, fcm-setup.md,
and mark Android UI checklist api-done via prod e2e 16/16.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-31 04:51:57 +00:00
parent fea1b555b4
commit 82a5809d4e
No known key found for this signature in database
14 changed files with 297 additions and 49 deletions

View File

@ -29,8 +29,9 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
### NOW
- 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK**. 다음: N4 FCM·Android QA (선택)
- 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서
@ -159,25 +160,28 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 |
| **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) |
### FCM
### FCM — [`fcm-setup.md`](./fcm-setup.md)
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 |
| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 |
| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) |
| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 |
| **N4-1** | Firebase + `google-services.json` | blocked | Master: Console 앱 + `mobile/android/app/google-services.json` (git 금지) |
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
| **N4-3** | 서버 FCM 자격증명 | blocked | Master: 호스트 `.env``FCM_SERVER_KEY` |
| **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
### Android UI 탭 (`mobile/README.md`)
| ID | 작업 | Status |
|----|------|--------|
| **N4-5** | 가입 → 말투 저장 | todo |
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo |
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo |
| **N4-8** | 에스컬레이션 → 사후알림 함 | todo |
| **N4-9** | 되돌리기·거부권 | todo |
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo |
API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.com` → e2e **16/16**, 2026-07-31).
실기기/에뮬레이터 **화면 탭**은 Master 로컬에서 1회.
| ID | 작업 | Status | 비고 |
|----|------|--------|------|
| **N4-5** | 가입 → 말투 저장 | api-done | 실기기 UI 탭 남음 |
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | api-done | 실기기 UI 탭 남음 |
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | api-done | 실기기 UI 탭 남음 |
| **N4-8** | 에스컬레이션 → 사후알림 함 | api-done | 실기기 UI 탭 남음 |
| **N4-9** | 되돌리기·거부권 | api-done | 실기기 UI 탭 남음 |
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | api-done | 실기기 UI 탭 남음 |
### 후순위
@ -213,7 +217,7 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 배포 안정화~~ **done** — 다음: **N4 FCM/Android QA** 또는 서버에 `GEMINI_API_KEY` 주입
5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

69
docs/fcm-setup.md Normal file
View File

@ -0,0 +1,69 @@
# FCM 설정 (N4-1 ~ N4-4)
서버·클라이언트의 푸시 **코드 경로는 준비됨**. 실제 전송은 Master가 Firebase 시크릿을
넣기 전까지 soft-skip 한다 (`install:` 토큰·`FCM_SERVER_KEY` 없음).
## 이미 된 것 (코드)
| 계층 | 동작 |
|------|------|
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
| core-backend | `notifyUser` + `POST /admin/push-test``FCM_SERVER_KEY` + 실 토큰일 때만 전송 |
## Master가 할 일
### N4-1 — Firebase 앱
1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용).
2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`**
3. 받은 `google-services.json`을 로컬에만 배치 (git 금지):
```bash
cp ~/Downloads/google-services.json mobile/android/app/google-services.json
```
템플릿: `mobile/android/app/google-services.json.example`
### N4-3 — 서버 키 (env only)
Cloud Messaging **레거시 서버 키**(또는 호환 서버 키)를 호스트 `.env`에만 설정:
```bash
# 서버 ~/project/ykavu/.env
FCM_SERVER_KEY=AAAA...
```
```bash
cd ~/project/ykavu
docker compose up -d core-backend
```
**git / 이미지에 키를 넣지 않는다** (N2-A5).
> 레거시 HTTP API가 Console에서 비활성이면 HTTP v1 마이그레이션이 필요하다.
> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다.
### N4-2 / N4-4 — 실기기 스모크
```bash
cd mobile
flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# 가입 → 로그에 "device token registered (FCM)" 확인
```
```bash
curl -sS -X POST https://msn.iykyka.com/admin/push-test \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}'
```
기대: `sent >= 1`, `skipped_reason` 없음. 기기에 알림 표시.
`only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인.
## Web
현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며
이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다.

View File

@ -30,7 +30,7 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다.
- 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`).
- 문제/스크린샷은 Master에게 전달해 주세요.
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.

View File

@ -63,9 +63,14 @@ HTTP로 검증한다.
sudo apt-get install -y libsqlite3-dev libsqlcipher1
```
## 푸시 (FCM)
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
- Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`)
## 아직 없는 것
- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
- Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4)
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- iOS 빌드 (v1 범위 밖)

View File

@ -14,3 +14,6 @@ key.properties
**/*.jks
*.jks
*.keystore
# Firebase — never commit real project credentials (see docs/fcm-setup.md).
app/google-services.json

View File

@ -8,6 +8,11 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// Apply only when Master drops google-services.json (see docs/fcm-setup.md).
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()

View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER",
"project_id": "ykavu-REPLACE",
"storage_bucket": "ykavu-REPLACE.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:REPLACE:android:REPLACE",
"android_client_info": {
"package_name": "com.ykavu.ykavu_mobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "REPLACE_WITH_ANDROID_API_KEY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="와카뷰"
android:name="${applicationName}"

View File

@ -20,6 +20,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
include(":app")

View File

@ -96,7 +96,7 @@ class DataFlowScreen extends StatelessWidget {
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n'
'계정: 표시 이름·초대 코드·세션 토큰\n'
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후',
'푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
),
_section(
context,

View File

@ -0,0 +1,69 @@
import 'dart:math';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
/// Resolves a device push token for core-backend registration.
///
/// Prefer a real FCM registration token when Firebase is configured
/// (`google-services.json` on Android). Otherwise fall back to a stable
/// `install:` placeholder that the server intentionally skips for delivery.
class PushTokenService {
PushTokenService({this.storeInstallId, this.readInstallId});
/// Persist a newly generated install id (KV / secure storage).
final Future<void> Function(String installId)? storeInstallId;
/// Load a previously stored install id.
final Future<String?> Function()? readInstallId;
Future<({String token, String platform, bool isFcm})> resolve() async {
final platform = _platformLabel();
final fcm = await _tryFirebaseToken();
if (fcm != null && fcm.isNotEmpty) {
return (token: fcm, platform: platform, isFcm: true);
}
final installId = await _ensureInstallId();
return (token: 'install:$installId', platform: platform, isFcm: false);
}
Future<String?> _tryFirebaseToken() async {
// Web push needs a separate Firebase web config + VAPID; skip until provided.
if (kIsWeb) return null;
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return null;
}
try {
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp();
}
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await messaging.getToken();
if (token == null || token.isEmpty) return null;
return token;
} catch (e) {
debugPrint('FCM token unavailable (using install placeholder): $e');
return null;
}
}
Future<String> _ensureInstallId() async {
final existing = await readInstallId?.call();
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final installId =
List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
await storeInstallId?.call(installId);
return installId;
}
String _platformLabel() {
if (kIsWeb) return 'web';
if (defaultTargetPlatform == TargetPlatform.android) return 'android';
if (defaultTargetPlatform == TargetPlatform.iOS) return 'ios';
return 'other';
}
}

View File

@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -7,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../db/app_database.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../services/push_token_service.dart';
class SessionState extends ChangeNotifier {
SessionState({ApiClient? api, AppDatabase? db})
@ -148,22 +148,25 @@ class SessionState extends ChangeNotifier {
}
}
/// Registers a stable install id as the push token until Firebase Messaging
/// is wired with a real FCM registration token (roadmap B).
/// Registers a real FCM token when Firebase is configured; otherwise a stable
/// `install:` placeholder (server skips placeholders for delivery).
Future<void> _registerDeviceTokenBestEffort() async {
if (user == null) return;
try {
_db ??= await _openDb();
var installId = await _db!.getKv(_kDeviceInstallId);
if (installId == null || installId.isEmpty) {
final rand = Random.secure();
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
await _db!.setKv(_kDeviceInstallId, installId);
}
final resolved = await PushTokenService(
readInstallId: () => _db!.getKv(_kDeviceInstallId),
storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id),
).resolve();
await _api.registerDeviceToken(
userId: user!.id,
token: 'install:$installId',
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other',
token: resolved.token,
platform: resolved.platform,
);
debugPrint(
resolved.isFcm
? 'device token registered (FCM)'
: 'device token registered (install placeholder)',
);
} catch (e) {
debugPrint('device token register skipped: $e');

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "88.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c"
url: "https://pub.dev"
source: hosted
version: "1.3.75"
analyzer:
dependency: transitive
description:
@ -93,10 +101,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
charcode:
dependency: transitive
description:
@ -209,6 +217,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9"
url: "https://pub.dev"
source: hosted
version: "4.12.1"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938
url: "https://pub.dev"
source: hosted
version: "8.0.0"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555
url: "https://pub.dev"
source: hosted
version: "3.9.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556"
url: "https://pub.dev"
source: hosted
version: "16.4.3"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0
url: "https://pub.dev"
source: hosted
version: "4.2.3"
fixnum:
dependency: transitive
description:
@ -356,26 +412,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.10"
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.1"
lints:
dependency: transitive
description:
@ -396,26 +452,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@ -713,10 +769,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.4"
typed_data:
dependency: transitive
description:
@ -729,10 +785,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.4"
vm_service:
dependency: transitive
description:
@ -798,5 +854,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.0-0 <4.0.0"
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0"

View File

@ -45,6 +45,8 @@ dependencies:
flutter_secure_storage: ^10.3.1
sqlcipher_flutter_libs: ^0.6.8
google_fonts: ^6.3.2
firebase_core: ^4.12.1
firebase_messaging: ^16.4.3
dev_dependencies:
flutter_test: