공식 로고 앱 아이콘 적용 · 확정 정책에 맞춰 등급·POP 소멸 필드를 규칙·인덱스에서 제외

This commit is contained in:
Sn-Kinos
2026-08-14 00:46:56 +09:00
parent 832cb88c30
commit 97ea5fad35
41 changed files with 51 additions and 77 deletions
+11 -11
View File
@@ -23,7 +23,7 @@ cd app
flutter pub get flutter pub get
flutter run # 실기기·시뮬레이터 flutter run # 실기기·시뮬레이터
flutter analyze # 정적 분석 flutter analyze # 정적 분석
flutter test # 스모크 테스트 3종 flutter test # 스모크·회귀 테스트 9건
flutter build apk --debug # Android 디버그 APK flutter build apk --debug # Android 디버그 APK
``` ```
@@ -117,7 +117,7 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
| Q3 | 네이버 client ID / secret | 미정 | 네이버 버튼이 「키 미투입」 안내를 반환 | | Q3 | 네이버 client ID / secret | 미정 | 네이버 버튼이 「키 미투입」 안내를 반환 |
| Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 | | Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 |
| Q14 | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | 현재 `kr.co.sweech.fancake` 로 잠정. **스토어 등록 후에는 변경 불가** | | Q14 | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | 현재 `kr.co.sweech.fancake` 로 잠정. **스토어 등록 후에는 변경 불가** |
| Q6 | 확정 로고 원본(벡터·알파 PNG) | `assets/brand/` | 로그인 화면 워드마크를 텍스트로 대체 중 | | ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 |
| Q15 | 하단 탭 아이콘 자산 | `assets/icons/` | Material 기본 아이콘 5종으로 잠정 | | Q15 | 하단 탭 아이콘 자산 | `assets/icons/` | Material 기본 아이콘 5종으로 잠정 |
### 5-3. 폰트 ### 5-3. 폰트
@@ -206,12 +206,12 @@ app/
## 9. 검증 결과 ## 9. 검증 결과
<!-- VERIFY:BEGIN 실행 출력 그대로. 손으로 고치지 마라. --> <!-- VERIFY:BEGIN 실행 출력 그대로. 손으로 고치지 마라. -->
2026-08-08 실행. macOS 27.0 · Flutter 3.41.7 (stable) · Dart 3.11.5 · Android SDK 36 · Xcode 26.6 2026-08-14 재실행. macOS 27.0 · Flutter 3.41.7 (stable) · Dart 3.11.5 · Android SDK 36 · Xcode 26.6
``` ```
$ flutter analyze $ flutter analyze
Analyzing app... Analyzing app...
No issues found! (ran in 1.3s) No issues found! (ran in 3.2s)
$ flutter test $ flutter test
00:00 +0: loading /Users/Kinos/Projects/p-fancake/app/test/app_smoke_test.dart 00:00 +0: loading /Users/Kinos/Projects/p-fancake/app/test/app_smoke_test.dart
@@ -221,13 +221,14 @@ $ flutter test
00:00 +3: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다 00:00 +3: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다
00:00 +4: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스 빌드 로그인 화면에 목 세션 버튼이 없다 00:00 +4: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스 빌드 로그인 화면에 목 세션 버튼이 없다
00:00 +5: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지) 00:00 +5: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)
00:00 +6: 화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다 00:00 +6: 제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다
00:00 +7: 3. 탭을 전환하면 각 탭 루트 화면이 바뀐다 00:00 +7: 화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다
00:00 +8: All tests passed! 00:00 +8: 3. 탭을 전환하면 각 탭 루트 화면이 바뀐다
00:00 +9: All tests passed!
$ flutter build apk --debug $ flutter build apk --debug
Running Gradle task 'assembleDebug'... 219.5s (첫 빌드 — NDK 28.2 자동 설치 포함) Running Gradle task 'assembleDebug'... 219.5s (첫 빌드 — NDK 28.2 자동 설치 포함)
✓ Built build/app/outputs/flutter-apk/app-debug.apk (142.4 MB · debug) ✓ Built build/app/outputs/flutter-apk/app-debug.apk (164,432,730 bytes · 약 156.8 MiB · debug)
10.6s (증분 재빌드) 10.6s (증분 재빌드)
$ flutter build ios --debug --no-codesign $ flutter build ios --debug --no-codesign
@@ -241,7 +242,7 @@ $ python3 tool/gen_tokens.py
전 쌍 AA 통과. (최저 color-on-neon-violet / color-neon-violet = 5.09:1) 전 쌍 AA 통과. (최저 color-on-neon-violet / color-neon-violet = 5.09:1)
``` ```
**테스트 8종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.** **테스트 9종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
음성 검증 2회 — 테스트가 실제로 게이트 역할을 하는지 직접 깨뜨려 확인했다. 음성 검증 2회 — 테스트가 실제로 게이트 역할을 하는지 직접 깨뜨려 확인했다.
@@ -292,11 +293,10 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail
| L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 | | L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 |
| L3 | 카카오·네이버 SDK 미도입 | 스텁만 있다. 실 연동 시 `kakao_flutter_sdk_user` · 네이버 SDK 추가와 함께 `KeylessSocialProvider` 를 실 구현체로 교체 | Q3 회신 후 | | L3 | 카카오·네이버 SDK 미도입 | 스텁만 있다. 실 연동 시 `kakao_flutter_sdk_user` · 네이버 SDK 추가와 함께 `KeylessSocialProvider` 를 실 구현체로 교체 | Q3 회신 후 |
| L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 | | L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 |
| L5 | 로고 원본 미확정(Q6) | 로그인 화면 워드마크가 텍스트다 | 2주차 M1 |
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 | | L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
| L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 | | L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 |
| L8 | **릴리스 서명 키 없음 → `flutter build apk --release` 는 의도적으로 실패한다** | 서명 키는 스토어 등록 명의(Q5·S3)가 정해져야 만들 수 있다. Flutter 기본 템플릿은 이 상태에서 **debug 키로 조용히 서명**해 Play 가 거부하는 산출물을 "성공"으로 내놓는다 — 그 경로를 막고 사유를 띄우도록 `android/app/build.gradle.kts` 를 고쳤다. 키가 생기면 `android/key.properties`(storeFile·storePassword·keyAlias·keyPassword)만 놓으면 된다. 해당 파일과 `*.jks`/`*.keystore` 는 `app/android/.gitignore` 가 이미 막고 있다(확인함) | Q5·S3 | | L8 | **릴리스 서명 키 없음 → `flutter build apk --release` 는 의도적으로 실패한다** | 서명 키는 스토어 등록 명의(Q5·S3)가 정해져야 만들 수 있다. Flutter 기본 템플릿은 이 상태에서 **debug 키로 조용히 서명**해 Play 가 거부하는 산출물을 "성공"으로 내놓는다 — 그 경로를 막고 사유를 띄우도록 `android/app/build.gradle.kts` 를 고쳤다. 키가 생기면 `android/key.properties`(storeFile·storePassword·keyAlias·keyPassword)만 놓으면 된다. 해당 파일과 `*.jks`/`*.keystore` 는 `app/android/.gitignore` 가 이미 막고 있다(확인함) | Q5·S3 |
| L8-1 | debug APK 142.4 MB | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 | | L8-1 | debug APK 164,432,730 bytes(약 156.8 MiB) | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 |
| L9 | 첫 Gradle 빌드에서 Kotlin 데몬 경고 1회 | `e: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0` — 자동 재시도로 성공했고 산출물에 영향 없음. 증분 빌드에서는 재현되지 않음 | — | | L9 | 첫 Gradle 빌드에서 Kotlin 데몬 경고 1회 | `e: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0` — 자동 재시도로 성공했고 산출물에 영향 없음. 증분 빌드에서는 재현되지 않음 | — |
| L9-1 | iOS 런치 화면 색 변경은 **컴파일 검증만** 했다 | `LaunchScreen.storyboard` 배경을 흰색 → `color-cosmic-void`(#000)로 바꿨다. Android 는 에뮬레이터에서 눈으로 확인했지만 iOS 는 시뮬레이터 실행을 하지 않아 빌드 성공까지만 확인했다 | 3주차 실기기 빌드 | | L9-1 | iOS 런치 화면 색 변경은 **컴파일 검증만** 했다 | `LaunchScreen.storyboard` 배경을 흰색 → `color-cosmic-void`(#000)로 바꿨다. Android 는 에뮬레이터에서 눈으로 확인했지만 iOS 는 시뮬레이터 실행을 하지 않아 빌드 성공까지만 확인했다 | 3주차 실기기 빌드 |
| L10 | 온보딩 6화면 라우트 없음 | S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS | 3주차 | | L10 | 온보딩 6화면 라우트 없음 | S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS | 3주차 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 KiB

After

Width:  |  Height:  |  Size: 526 KiB

-17
View File
@@ -74,23 +74,6 @@
{ "fieldPath": "createdAt", "order": "DESCENDING" } { "fieldPath": "createdAt", "order": "DESCENDING" }
] ]
}, },
{
"collectionGroup": "popLots",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "popType", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "grantedAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "popLots",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "expiresAt", "order": "ASCENDING" }
]
},
{ {
"collectionGroup": "activityLogs", "collectionGroup": "activityLogs",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
+12 -14
View File
@@ -8,7 +8,7 @@ rules_version = '2';
// - 주간보고/1주차/src/1주차_정책설계서_초안.md P-02 원장 무결성 · P-06 어뷰징 방어 // - 주간보고/1주차/src/1주차_정책설계서_초안.md P-02 원장 무결성 · P-06 어뷰징 방어
// //
// 3대 원칙 // 3대 원칙
// 1) POP · FPI · 등급 · 원장 필드는 클라이언트가 쓸 수 없다. // 1) POP · FPI 집계 · 원장 필드는 클라이언트가 쓸 수 없다.
// 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다. // 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다.
// → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장. // → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장.
// 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다. // 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다.
@@ -58,13 +58,13 @@ service cloud.firestore {
} }
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08) // 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08)
function campaignIsPublic() { function campaignIsPublic() {
return resource.data.status in ['voting', 'funding', 'success', 'fail', 'report']; return resource.data.status in ['funding', 'success', 'fail', 'report'];
} }
// ───────────────────────── 1. 운영 파라미터 ───────────────────────── // ───────────────────────── 1. 운영 파라미터 ─────────────────────────
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다. // 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
// 근거: 결정 D-07·D-13·D-14·D-21·D-22·D-23 · FPI 가중치(P-03) · 이클립스 수치(CD-91) // 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE // 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
match /config/{configId} { match /config/{configId} {
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기 allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
@@ -92,14 +92,14 @@ service cloud.firestore {
// ───────────────────────── 2. 사용자 ───────────────────────── // ───────────────────────── 2. 사용자 ─────────────────────────
// 계정·프로필. 등급(grade)·누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다. // 계정·프로필. 누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다.
// 화면: S-A-PROFILE · S-M-HOME · S-M-SETTINGS · S-AD-USER // 화면: S-A-PROFILE · S-M-HOME · S-M-SETTINGS · S-AD-USER
match /users/{uid} { match /users/{uid} {
allow get: if isSelf(uid) || isViewer(); allow get: if isSelf(uid) || isViewer();
allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER) allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER)
// 교차검증 F-06 — 클라이언트 생성 경로를 닫는다. // 교차검증 F-06 — 클라이언트 생성 경로를 닫는다.
// 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고 // 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고
// ② 클라이언트가 먼저 만들면 grade·totalContributedPop 이 허용 목록에 없어 필드가 빈 // ② 클라이언트가 먼저 만들면 totalContributedPop 등 서버 산출 필드가 빈
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점). // 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
// 생성 주체를 Auth 트리거 하나로 고정한다. // 생성 주체를 Auth 트리거 하나로 고정한다.
allow create: if false; allow create: if false;
@@ -119,14 +119,14 @@ service cloud.firestore {
allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」 allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」
} }
// POP Lot — 획득 단위별 잔량·만료일. FIFO 차감의 계산 단위. // POP Lot — 획득 단위별 잔량. 원장 대사와 차감의 계산 단위.
// 화면: S-M-WALLET(소멸 예정 POP) // 화면: S-M-WALLET
match /users/{uid}/popLots/{lotId} { match /users/{uid}/popLots/{lotId} {
allow read: if isSelf(uid) || isViewer(); allow read: if isSelf(uid) || isViewer();
allow write: if false; // 잔량·만료 조작 차단 allow write: if false; // 잔량 조작 차단
} }
// POP 원장. 지급·차감·환불·소멸 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다. // POP 원장. 지급·차감·환불·보류 무효 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다.
// 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER // 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER
match /users/{uid}/popTransactions/{txId} { match /users/{uid}/popTransactions/{txId} {
allow read: if isSelf(uid) || isViewer(); allow read: if isSelf(uid) || isViewer();
@@ -147,7 +147,7 @@ service cloud.firestore {
allow write: if false; allow write: if false;
} }
// 보유 배지·등급 이력. 부여는 서버 판정. // 보유 배지 이력. 부여는 서버 판정.
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드) // 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
// //
// 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데, // 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
@@ -326,7 +326,7 @@ service cloud.firestore {
&& request.resource.data.keys().hasOnly([ && request.resource.data.keys().hasOnly([
'fandomId','creatorUid','creatorName','title','summary','body', 'fandomId','creatorUid','creatorName','title','summary','body',
'coverImageUrl','campaignType','targetPop','endsAt','status', 'coverImageUrl','campaignType','targetPop','endsAt','status',
'raisedPop','achievedRate','donorCount','votedPop','isAiDraft', 'raisedPop','achievedRate','donorCount',
'refundState','createdAt','updatedAt' 'refundState','createdAt','updatedAt'
]) ])
&& request.resource.data.creatorUid == request.auth.uid && request.resource.data.creatorUid == request.auth.uid
@@ -336,8 +336,6 @@ service cloud.firestore {
&& request.resource.data.raisedPop == 0 // 개설 시 모금액 0 고정 && request.resource.data.raisedPop == 0 // 개설 시 모금액 0 고정
&& request.resource.data.achievedRate == 0 // 달성률 위조 차단(웹 브릿지 노출 대상) && request.resource.data.achievedRate == 0 // 달성률 위조 차단(웹 브릿지 노출 대상)
&& request.resource.data.donorCount == 0 // 후원자 수 위조 차단 && request.resource.data.donorCount == 0 // 후원자 수 위조 차단
&& request.resource.data.votedPop == 0 // D-22 승격 임계치 판정 입력
&& request.resource.data.isAiDraft == false // 「AI 초안」 배지 사칭 차단
&& request.resource.data.refundState == 'none'; && request.resource.data.refundState == 'none';
// 개설자는 심사 전 초안만 고칠 수 있다. // 개설자는 심사 전 초안만 고칠 수 있다.
allow update: if signedIn() allow update: if signedIn()
@@ -618,7 +616,7 @@ service cloud.firestore {
allow read: if signedIn(); allow read: if signedIn();
} }
// popLots(IDX-11)·환불 배치 donations(IDX-21)는 스케줄 Function 전용이다. // 환불 배치 donations(IDX-21)는 스케줄 Function 전용이다.
// Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다. // Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다.
// ───────────────────────── 13. 기본 거부 ───────────────────────── // ───────────────────────── 13. 기본 거부 ─────────────────────────
@@ -49,11 +49,9 @@ export function userDocument(uid, overrides = {}) {
uid, uid,
displayName: uid, displayName: uid,
fandomId: 'fandom-a', fandomId: 'fandom-a',
grade: 'SILVER',
totalContributedPop: 0, totalContributedPop: 0,
status: 'active', status: 'active',
updatedAt: '2026-08-08T00:00:00Z', updatedAt: '2026-08-08T00:00:00Z',
...overrides, ...overrides,
}; };
} }
@@ -43,7 +43,7 @@ afterAll(async () => {
await testEnvironment.cleanup(); await testEnvironment.cleanup();
}); });
describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가', () => { describe('원칙 1 — POP·FPI·원장은 클라이언트 쓰기 불가', () => {
test('본인 지갑 잔액 직접 증액을 거부한다', async () => { test('본인 지갑 잔액 직접 증액을 거부한다', async () => {
await seedDocuments(testEnvironment, { await seedDocuments(testEnvironment, {
'users/alice/wallet/main': { balance: 100 }, 'users/alice/wallet/main': { balance: 100 },
@@ -61,14 +61,14 @@ describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가
); );
}); });
test('POP Lot 만료일 연장을 거부한다', async () => { test('POP Lot 잔량 조작을 거부한다', async () => {
await seedDocuments(testEnvironment, { await seedDocuments(testEnvironment, {
'users/alice/popLots/lot-1': { remaining: 100, expiresAt: '2026-08-09T00:00:00Z' }, 'users/alice/popLots/lot-1': { remaining: 100 },
}); });
await assertFails( await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice/popLots/lot-1'), { updateDoc(doc(dbFor('alice'), 'users/alice/popLots/lot-1'), {
expiresAt: '2099-12-31T00:00:00Z', remaining: 999_999,
}), }),
); );
}); });
@@ -82,12 +82,14 @@ describe('원칙 1 — POP·FPI·등급·원장은 클라이언트 쓰기 불가
); );
}); });
test('사용자 등급 직접 승격을 거부한다', async () => { test('누적 기여 통계 직접 조작을 거부한다', async () => {
await seedDocuments(testEnvironment, { await seedDocuments(testEnvironment, {
'users/alice': userDocument('alice'), 'users/alice': userDocument('alice'),
}); });
await assertFails(updateDoc(doc(dbFor('alice'), 'users/alice'), { grade: 'DIAMOND' })); await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice'), { totalContributedPop: 999_999 }),
);
}); });
test('다른 사용자의 지갑 쓰기를 거부한다', async () => { test('다른 사용자의 지갑 쓰기를 거부한다', async () => {
@@ -125,11 +127,11 @@ describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => {
await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob'), { displayName: 'Forged' })); await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob'), { displayName: 'Forged' }));
}); });
test('허용 필드와 등급을 섞은 본인 업데이트를 전부 거부한다', async () => { test('허용 필드와 서버 통계를 섞은 본인 업데이트를 전부 거부한다', async () => {
await assertFails( await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice'), { updateDoc(doc(dbFor('alice'), 'users/alice'), {
displayName: 'New Alice', displayName: 'New Alice',
grade: 'DIAMOND', totalContributedPop: 999_999,
}), }),
); );
}); });
@@ -251,15 +253,13 @@ describe('F-02 — 캠페인 집계 필드 위조 회귀', () => {
summary: 'Summary', summary: 'Summary',
body: 'Body', body: 'Body',
coverImageUrl: 'https://example.test/cover.png', coverImageUrl: 'https://example.test/cover.png',
campaignType: 'support', campaignType: 'other',
targetPop: 1000, targetPop: 1000,
endsAt: '2026-09-01T00:00:00Z', endsAt: '2026-09-01T00:00:00Z',
status: 'draft', status: 'draft',
raisedPop: 0, raisedPop: 0,
achievedRate: 0, achievedRate: 0,
donorCount: 0, donorCount: 0,
votedPop: 0,
isAiDraft: false,
refundState: 'none', refundState: 'none',
createdAt: '2026-08-08T00:00:00Z', createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z', updatedAt: '2026-08-08T00:00:00Z',
@@ -275,14 +275,12 @@ describe('F-02 — 캠페인 집계 필드 위조 회귀', () => {
await assertSucceeds(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign)); await assertSucceeds(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign));
}); });
test('집계·투표·AI 초안 필드 위조를 거부한다', async () => { test('집계 필드 위조를 거부한다', async () => {
await assertFails( await assertFails(
setDoc(doc(dbFor('alice'), 'campaigns/forged'), { setDoc(doc(dbFor('alice'), 'campaigns/forged'), {
...validCampaign, ...validCampaign,
achievedRate: 1, achievedRate: 1,
donorCount: 99_999, donorCount: 99_999,
votedPop: 999_999,
isAiDraft: true,
}), }),
); );
}); });
@@ -389,7 +387,10 @@ describe('F-14 — posts 허용 필드 회귀', () => {
describe('F-06 — users 클라이언트 생성 경로 회귀', () => { describe('F-06 — users 클라이언트 생성 경로 회귀', () => {
test('일반 사용자의 users 문서 생성을 거부한다', async () => { test('일반 사용자의 users 문서 생성을 거부한다', async () => {
await assertFails( await assertFails(
setDoc(doc(dbFor('alice'), 'users/alice'), userDocument('alice', { grade: 'DIAMOND' })), setDoc(
doc(dbFor('alice'), 'users/alice'),
userDocument('alice', { totalContributedPop: 999_999 }),
),
); );
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 B

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1011 B

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

+3 -3
View File
@@ -33,14 +33,14 @@ dev_dependencies:
# 재생성: dart run flutter_launcher_icons # 재생성: dart run flutter_launcher_icons
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
# 앱 아이콘 — 원본은 시안/assets/app-icon.png (제안 자산). # 앱 아이콘 — 2026-08-13 수령한 공식 `fanuzu logo only.png`의 중앙 1:1 영역을
# 발주사 확정 로고(Q6)가 오면 assets/brand/ 의 두 파일만 교체하고 위 명령을 다시 돌린다. # 픽셀 변경 없이 사용한다. `assets/brand/` 두 원본은 같은 공식 마크다.
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true
image_path: "assets/brand/app-icon.png" image_path: "assets/brand/app-icon.png"
# Android 8+ 적응형 아이콘 — 마스크가 바깥을 잘라내므로 마크만 담은 전경을 따로 쓴다. # Android 8+ 적응형 아이콘 — 마스크가 바깥을 잘라내므로 마크만 담은 전경을 따로 쓴다.
adaptive_icon_background: "#120E25" # 원본 배경 실측값 adaptive_icon_background: "#120E25" # 투명 공식 마크 바깥의 브랜드 다크 배경
adaptive_icon_foreground: "assets/brand/app-icon-foreground.png" adaptive_icon_foreground: "assets/brand/app-icon-foreground.png"
# iOS 는 알파 채널을 허용하지 않는다(App Store 심사 거부 사유). # iOS 는 알파 채널을 허용하지 않는다(App Store 심사 거부 사유).
remove_alpha_ios: true remove_alpha_ios: true
-4
View File
@@ -8,10 +8,6 @@ CAPS_OK:
- {term: STATION, reason: "견적서 v2.1 5탭 라벨 · 한글 대체 불가(계약 표기)"} - {term: STATION, reason: "견적서 v2.1 5탭 라벨 · 한글 대체 불가(계약 표기)"}
- {term: INSIDE, reason: "견적서 v2.1 5탭 라벨 · 한글 대체 불가(계약 표기)"} - {term: INSIDE, reason: "견적서 v2.1 5탭 라벨 · 한글 대체 불가(계약 표기)"}
- {term: GALAXY, reason: "견적서 v2.1 5탭 라벨 · 한글 대체 불가(계약 표기)"} - {term: GALAXY, reason: "견적서 v2.1 5탭 라벨 · 한글 대체 불가(계약 표기)"}
# ── 등급명 — 인수 데모가 확정한 제품 어휘(FANUZU_prototype.tsx:627 'DIAMOND TIER').
- {term: SILVER, reason: "등급 체계 고유명 · 인수 데모 확정 어휘(결정 D-21)"}
- {term: GOLD, reason: "등급 체계 고유명 · 인수 데모 확정 어휘(결정 D-21)"}
- {term: DIAMOND, reason: "등급 체계 고유명 · 인수 데모 확정 어휘(결정 D-21)"}
# ── 지표·재화 약어 — 화면 전반에서 단위처럼 쓰인다. # ── 지표·재화 약어 — 화면 전반에서 단위처럼 쓰인다.
- {term: FPI, reason: "Fandom Planet Index 약어 · PRD v9.0 PRD-03 공식 명칭"} - {term: FPI, reason: "Fandom Planet Index 약어 · PRD v9.0 PRD-03 공식 명칭"}
+9 -11
View File
@@ -17,16 +17,12 @@
한 곳에만 쓰고 그 외에는 쓰지 않는다. 한 곳에만 쓰고 그 외에는 쓰지 않는다.
## 로고 결정 ## 로고 결정
- 발주사 인수 자산 `고객사 준비 데모/LOGO/*.png` 4종은 모두 **회색 배경 위 네온 글로우 목업**이다. - 발주사 공식 자산 `고객사 준비 데모/LOGO/fanuzu logo only.png`를 수령했고, PNG의 알파 채널도 확인했다.
알파 채널이 없어 헤더에 회색 사각형이 박히고, 글로우·발광은 계약 §5.1 이 금지한 신호다. - 시안은 이 공식 심볼을 `시안/assets/logo.png`에 반영했다. 공식 앱 아이콘 원천도 같은 심볼이며
또한 워드마크가 **FANUZU(팬우주)** 인데 견적서·별첨의 서비스명은 **FANUZU** 다. `app/assets/brand/app-icon.png`와 `app/assets/brand/app-icon-foreground.png`에 반영돼 있다.
- 그래서 시안에는 **FANUZU 플랫 워드마크를 제안본으로 만들어** 썼다(첫 `a` 의 카운터를 고리 행성으로 치환, - 따라서 현재 로고는 AI 생성 제안본이나 임시 Fancake 브랜드가 아니다. 별도 제안 로고 승인 대기도 없다.
단색 플럼, 알파 PNG). 인수 원본은 `sources/logo-발주사원본-fanuzu.png` 로 보존했다. - **서비스명은 `FANUZU`, 국문 병행 표기는 `팬우주`로 확정됐다.** 발주사 공식 검토 회신서의
- **서비스명은 `FANUZU` 로 확정됐다 (CD-01).** 데모 최신 소스 3종이 전부 FANUZU 표기이고 표지 지시에 따라 종전 CD-01(`Fancake` 확정)은 철회·대체했다(W-01).
견적서·별첨의 서비스명과도 일치한다. **FANUZU(팬우주)는 인수 원본에만 남은 옛 표기이므로 신규 산출물에 쓰지 않는다.**
인수 자산의 FANUZU 워드마크는 이력 보존용으로만 둔다.
- **남은 확인 항목은 로고 원본 하나뿐**: 이 제안 워드마크를 쓸지, 발주사가 별도 로고 원본(벡터·알파)을
제공할지 (확인요청항목 **Q6**).
## 컬러 운용 규율 (60-30-10) ## 컬러 운용 규율 (60-30-10)
- 60 배경/서피스 · 30 중립 텍스트·보더 · **10 악센트**. 악센트는 **행동·선택·포커스에만** 쓴다. - 60 배경/서피스 · 30 중립 텍스트·보더 · **10 악센트**. 악센트는 **행동·선택·포커스에만** 쓴다.
@@ -242,4 +238,6 @@ contrast_checks:
| 일자 | 내용 | | 일자 | 내용 |
|---|---| |---|---|
| 2026-08-08 | **서비스명 확인 질문 삭제 — CD-01 로 이미 확정된 사항이었음.** 「① 서비스명 표기가 FANUZU 인지 팬우주(FANUZU) 인지」를 확인 필요 항목으로 남겨 두었으나, 변경 대장 **CD-01(서비스명 FANUZU 확정)** 으로 닫힌 건이라 질문을 지우고 확정 문구로 교체했다. **디자인 결정 변경은 없고**, 이미 확정된 것을 미확정처럼 적어 둔 표기를 바로잡은 것이다. 로고 원본 제공 여부(**Q6**)는 실제 미확정이라 그대로 남겼다 | | 2026-08-13 | **공식 자산 반영.** `고객사 준비 데모/LOGO/fanuzu logo only.png`를 공식 FANUZU 심볼 원천으로 확정하고 시안 로고·앱 아이콘에 반영했다. 제안 로고/Q6 대기는 종료했다 |
| 2026-08-11 | **W-01 브랜드 정정.** 발주사 공식 검토 회신서 표지에 따라 서비스명을 `FANUZU`(국문 `팬우주`)로 교체하고, 종전 CD-01(`Fancake` 확정)을 철회했다 |
| 2026-08-08 | **당시 CD-01 기준 정리.** 인수 데모를 근거로 `Fancake`를 확정값으로 보아 서비스명 확인 질문을 삭제했다. 이후 발주사 공식 회신으로 이 판단이 철회됐으며 위 2026-08-11 행이 현재값이다 |