28 Commits
Author SHA1 Message Date
Sn-Kinos d096d0cb00 2차 빌드 버전 0.2.0+12 2026-10-22 01:26:54 +09:00
Sn-Kinos 577f6453a5 HOME GIVE [+1] · [+10] 라벨을 행성 우하단 안쪽으로 옮겨 GALAXY 미니맵과 겹치지 않게 함 2026-10-22 01:26:46 +09:00
Sn-Kinos 1a7dc711d3 GIVE [+1] · [+10] 은 서버가 기여를 확정한 묶음 기준으로만 표시 2026-10-22 01:26:46 +09:00
Sn-Kinos 2836f07a71 레벨업 때 행성에 표면 아우라가 떴다 사라짐 2026-10-22 01:25:27 +09:00
Sn-Kinos 6a1f97fbc2 Stage 6 행성에 자기장 라인이 행성을 감싸며 바깥으로 번지는 연출 2026-10-22 01:25:27 +09:00
Sn-Kinos a2739a37f1 GIVE 연타에 행성이 흔들리고 우하단에 [+1] · 10회 이상이면 [+10]과 자전 1.5배, 전송 중 연타는 다음 묶음으로 쌓임 2026-10-22 01:25:27 +09:00
Sn-Kinos 3f59c5070e README에 로그인 구성·폰트·제출 빌드 대응과 재빌드 방법 정리 2026-10-18 17:24:00 +09:00
Sn-Kinos 4236d75fd1 코드 주석을 이 저장소 기준으로 정리 2026-10-16 18:04:14 +09:00
Sn-Kinos 80b90aad4c 1차 보완(0.1.1+14) 내용 합치기 2026-10-16 17:58:55 +09:00
fanuzu ec84a9da43 docs: clarify monthly nebula reset and per-member firepower denominator 2026-10-13 13:01:39 +09:00
Sn-Kinos 0d2394c002 1차 보완 빌드 버전 0.1.1+14 2026-10-12 15:02:43 +09:00
Sn-Kinos de65316205 Android LINE 로그인 후 앱으로 돌아오지 않던 문제 수정 2026-10-12 15:02:43 +09:00
Sn-Kinos 59a8f9ead8 1차 보완 빌드 버전 0.1.1+13 2026-10-12 13:55:46 +09:00
Sn-Kinos 3b2bb5435e 1차 보완 빌드 버전 0.1.1+12 2026-10-12 04:12:08 +09:00
Sn-Kinos 3511ba2de9 약관 동의 화면의 개인정보처리방침 보기에 처리방침 임시 문구 표시 2026-10-12 04:07:04 +09:00
Sn-Kinos 6e0aa5d43c 약관 동의 화면에 개인정보처리방침 고지 · 보기 링크 추가 2026-10-12 00:03:46 +09:00
Sn-Kinos c42fd615a6 소셜 로그인 진행 중 로딩 표시 · 중복 입력 방지 2026-10-11 23:43:43 +09:00
Sn-Kinos c40bde2cd0 가입 시 약관 동의 단계 추가 — 필수·선택 항목 구분 · 개인정보처리방침 고지 · 동의 기록 저장 2026-10-11 23:43:25 +09:00
Sn-Kinos 9119b3beaa LINE 로그인을 LINE SDK와 서버 인증 토큰 방식으로 변경 — iOS·Android 웹 로그인 중단 문제 해결 2026-10-11 01:29:23 +09:00
Sn-Kinos e440251c78 iOS 에서도 LINE 앱 자동 로그인을 꺼 웹 로그인이 끊기지 않게 함 2026-10-11 00:56:08 +09:00
Sn-Kinos cef5436336 1차 보완 빌드 버전 0.1.1+11 2026-10-11 00:35:21 +09:00
Sn-Kinos 897dff950d MY 화면에 로그아웃(확인 후 로그인 화면으로 복귀) 추가 2026-10-11 00:29:44 +09:00
Sn-Kinos bc19797158 소셜 웹 로그인 중단·실패를 이메일 오류 대신 소셜 로그인 문구로 안내 · 네트워크 오류 안내 · Android 중단된 웹 로그인 정리 후 재시도 2026-10-10 21:23:15 +09:00
Sn-Kinos 4d2ce67886 SNS 계정 연결의 이메일 일치 조건 제거 — 현재 로그인과 새 제공자 인증을 서버에서 확인 · 다른 계정에 연결된 제공자는 거부 · 세션 만료 시 재로그인 안내 2026-10-10 21:14:49 +09:00
Sn-Kinos b66863a149 Android Apple 로그인을 Firebase 웹 흐름(apple.com 제공자)으로 연결 · 설정 안내 2026-10-08 15:44:49 +09:00
Sn-Kinos 7bfc4faa13 Kakao 인가·토큰 단계 실패를 로그인 실패로 처리 · 실패 원인 로그 2026-10-06 02:24:26 +09:00
Sn-Kinos 9d34935b94 Kakao·LINE 이메일 선택 수집 — 동의한 이메일만 저장 · 이메일 없이 제공자 식별값으로 가입·로그인 2026-10-05 21:53:23 +09:00
Sn-Kinos c301f3a1db Kakao 로그인(카카오 SDK · 서버 커스텀 토큰)과 LINE 로그인(Firebase OIDC) 연결 · SNS 연동 · 인증 취소 처리 2026-10-05 19:01:28 +09:00
148 changed files with 3411 additions and 733 deletions
+4
View File
@@ -157,6 +157,10 @@ Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
의 `KAKAO_APP_ID` 로 받는다.
**LINE 채널 ID** — 같은 `config/kakao.env` 에 `FANUZU_LINE_CHANNEL_ID=<LINE Login 채널 ID(숫자)>`. Dart 만 읽는다
(iOS 복귀 스킴은 `line3rdp.<번들 ID>` 라 키가 필요 없다). 없으면 LINE 로그인만 「설정 전」으로 막힌다. 서버
`fnAuthLine` 은 같은 값을 `firebase/functions/.env.<프로젝트>` 의 `LINE_CHANNEL_ID` 로 받는다.
**구글 지도 API 키(위치 선택 지도)** — 키는 저장소에 없다. 없어도 빌드는 되고 지도만 빈다.
- Android: `android/local.properties` 에 `MAPS_API_KEY=<키>` → `build.gradle.kts` 가 `manifestPlaceholders` 로
AndroidManifest `com.google.android.geo.API_KEY` 에 넣는다.
+45 -8
View File
@@ -37,7 +37,17 @@ JDK 17 을 Flutter 에 지정합니다.
fvm flutter config --jdk-dir <JDK 17 경로>
```
## 2. 저장소에 포함하지 않은 파일
## 2. 이 브랜치 · 태그와 제출 빌드
| 브랜치 · 태그 | 커밋 | 대응 제출 빌드 | 산출물 SHA-256 |
|---|---|---|---|
| `project-1` · 태그 `v0.1.1+14` | `0d2394c` | 1차 보완 빌드 0.1.1(14) — Android APK · iOS TestFlight 같은 소스 | APK `7d2ebadc624666c7992ac92ac3aa5d2c5e7b4bd0c602efeabc194ec73c99eb52` · IPA `d00870facdb27d38b458cdadc5723190b9fb18bfba06c64f578e0705a28627c8` |
| 태그 `v0.2.0+10` | `be99511` | 2차 제출 빌드 0.2.0(10) | APK `856e15781d002aff066a02da0d53f9d8eeada9e1fc6b6fc139c0705c77342c2c`(릴리스 「2차 제출 빌드 v0.2.0+10」 첨부) |
| `project-2` | `4236d75` | 제출 빌드 없음 — 태그 `v0.2.0+10` 에 1차 보완(0.1.1+14) 내용을 합친 최신 코드 | — |
**재빌드 확인.** 태그를 체크아웃한 뒤 아래 3절 명령으로 빌드하면 같은 버전 · 빌드 번호의 APK 가 나옵니다. 서명 키가 달라 서명 블록은 다르므로, 비교는 `unzip -l` 로 본 파일 목록과 `classes*.dex` · `lib/*/libapp.so` 의 SHA-256 으로 합니다. 제출본과 같은 서명으로 다시 만들어야 하면 서명 키를 별도 보안 경로로 요청해 주십시오.
## 3. 저장소에 포함하지 않은 파일
아래 파일은 보안상 저장소에 넣지 않았습니다. 빌드 전에 준비해 주십시오.
@@ -45,16 +55,18 @@ fvm flutter config --jdk-dir <JDK 17 경로>
|---|---|
| `android/app/google-services.json` | Firebase 콘솔 → 프로젝트 `fanuzu-dev-b020d` → 프로젝트 설정 → Android 앱(`kr.co.fanuzu.app`) |
| `ios/Runner/GoogleService-Info.plist` | 같은 화면의 iOS 앱 |
| `config/kakao.env` | `config/kakao.env.example` 을 복사해 `FANUZU_KAKAO_NATIVE_APP_KEY`(카카오 네이티브 앱 키) · `FANUZU_LINE_CHANNEL_ID`(LINE Login 채널 ID)를 적습니다. 없으면 카카오 · LINE 로그인만 「설정 전」으로 막힙니다 |
| `firebase/functions/.env.<프로젝트>` | 서버 `fnAuthKakao` 의 `KAKAO_APP_ID`(카카오 앱 ID) · `fnAuthLine` 의 `LINE_CHANNEL_ID` 를 적습니다 |
| `android/key.properties` · 업로드 서명 키 | **릴리스(서명) 빌드에만** 필요합니다. 요청하시면 별도 보안 경로로 전달합니다. 디버그 빌드에는 필요 없습니다 |
설정 파일이 없으면 앱은 표본 데이터로 동작하는 목(mock) 모드로 뜹니다(디버그 빌드에서만 진입 가능).
## 3. 앱 빌드 · 실행
## 4. 앱 빌드 · 실행
```bash
cd app
fvm flutter pub get
fvm flutter run # 연결된 실기기 · 에뮬레이터에서 실행
fvm flutter run --dart-define-from-file=config/kakao.env # 연결된 실기기 · 에뮬레이터에서 실행
fvm flutter build apk --debug # 디버그 APK → build/app/outputs/flutter-apk/app-debug.apk
fvm flutter build apk --release # 서명 릴리스 APK (key.properties 필요)
fvm flutter build ios --no-codesign # iOS 빌드 확인
@@ -67,7 +79,7 @@ fvm flutter build ios --no-codesign # iOS 빌드 확인
| `FANUZU_SHARE_BASE_URL` | 공유 링크 주소(웹 브릿지) | 공유 시 링크 없이 카드 · 문구만 보냅니다 |
| `FANUZU_EMULATOR_HOST` | 로컬 Firebase 에뮬레이터 연결(개발용) | 실서버에 연결합니다 |
## 4. 테스트
## 5. 테스트
```bash
cd app
@@ -86,7 +98,7 @@ npm run test:emulator # 서버 테스트 (Firestore 에뮬레
의존성을 바꾼 경우 `analyze` · `test` 만으로는 Android 네이티브 컴파일 오류를 잡지 못하므로 `fvm flutter build apk --debug` 까지 확인해 주십시오. `pubspec.lock` 의 `firebase_core` 는 4.13.0 이어야 합니다(4.14.0 에서 Android 컴파일이 실패합니다).
## 5. 서버 배포
## 6. 서버 배포
```bash
cd app/firebase
@@ -97,12 +109,37 @@ firebase deploy --only firestore,storage,functions --project fanuzu-dev-b020d
Storage 규칙 배포는 Firebase 콘솔에서 Storage 를 먼저 활성화해야 합니다.
## 6. 테스트 계정 · 권한
## 7. 로그인 구성
- 릴리스 빌드의 로그인은 **Google 계정**으로 합니다. 각자의 Google 계정으로 가입하시면 됩니다. Apple 은 iOS(TestFlight) 준비 후, Kakao · LINE 은 제공자 앱 키 수령 후 연결합니다.
1차 로그인은 Google · Apple · Kakao · LINE 4종입니다(별첨 B). 이메일 · 비밀번호 가입과 X 로그인은 없습니다.
| 제공자 | 앱 쪽 | 서버 쪽 | Firebase 사용자 식별 |
|---|---|---|---|
| Google | Google 로그인 SDK | Firebase Authentication | `google.com` |
| Apple | iOS 는 Apple 로그인 SDK, Android 는 Firebase 웹 흐름 | Firebase Authentication | `apple.com` |
| Kakao | 카카오 SDK 로그인 | `fnAuthKakao` 가 카카오 토큰을 확인하고 Firebase 커스텀 토큰 발급 | `oidc.kakao` 제공자 ID 로 기록 |
| LINE | LINE SDK 로그인(앱 설치 시 LINE 앱, 없으면 웹 로그인) | `fnAuthLine` 이 LINE 토큰을 확인하고 Firebase 커스텀 토큰 발급 | `oidc.line` 제공자 ID 로 기록 |
- Kakao · LINE 은 Firebase OIDC 웹 흐름이 아니라 각 SDK 와 서버 커스텀 토큰으로 로그인합니다. 제공자 ID `oidc.kakao` · `oidc.line` 은 이전 가입자의 UID 를 유지하려고 그대로 씁니다.
- Kakao · LINE 이메일은 선택 동의 항목입니다. 이메일이 없어도 제공자 고유 식별값으로 가입 · 로그인합니다.
- **SNS 연결(MY › 설정)** 은 서버가 현재 로그인과 새 제공자의 인증 자격을 확인해 붙입니다. 이메일이 같은지는 보지 않으며, 다른 FANUZU 계정에 이미 붙은 제공자는 거부합니다. 마지막 남은 제공자는 뗄 수 없습니다.
## 8. 폰트
`pubspec.yaml` 에 등록되어 앱에 들어가 있습니다.
| 패밀리 | 파일 | 쓰임 |
|---|---|---|
| 42dot Sans | `assets/fonts/42dotSans[wght].ttf` | 본문(디자인 토큰 `font-body`) |
| ABeeZee | `assets/fonts/ABeeZee-Regular.ttf` | 제목 · 수치(디자인 토큰 `font-display` · `font-mono`) |
| Pretendard(Black, 라틴 부분집합) | `assets/fonts/Pretendard-Black-Latin.otf` | 홈 헤더 워드마크 · 이클립스 숫자 |
## 9. 테스트 계정 · 권한
- 로그인은 위 4종 가운데 각자의 계정으로 가입하시면 됩니다.
- 운영 기능(퀘스트 · 캠페인 검수, 타임어택 발동, 결과보고 등록 등)은 운영자 권한이 있는 계정만 호출할 수 있습니다. 권한을 드릴 Google 계정을 알려 주시면 운영자 권한을 부여합니다.
## 7. 실기기에서만 확인 가능한 기능
## 10. 실기기에서만 확인 가능한 기능
| 기능 | 이유 |
|---|---|
+6
View File
@@ -112,3 +112,9 @@ android {
flutter {
source = "../.."
}
dependencies {
// MainActivity 의 「버려진 Firebase 웹 로그인 정리」(kr.co.fanuzu.app/auth) — firebase-auth 가 이미 쓰는
// 라이브러리라 새로 들어오는 것은 없다. 앱 모듈에서 직접 부르려고 컴파일 경로에 올린다.
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
}
@@ -20,7 +20,6 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -2,11 +2,13 @@ package kr.co.fanuzu.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import java.text.Collator
import java.util.Locale
@@ -54,6 +56,22 @@ class MainActivity : FlutterFragmentActivity() {
.sortedWith(compareBy(collator) { it[1] })
result.success(pairs)
}
// 버려진 Firebase 웹 로그인(LINE · Apple OIDC) 정리 — Dart `clearStaleWebSignIn`.
// 인가 중 다른 앱(LINE 본인 확인 등)으로 갔다 돌아오면 Firebase 의 대기 작업이 결과 없이 남아,
// 다음 시도가 `web-context-already-presented` 로 막힌다. Firebase 의 인가 화면이 「닫힘」을 알릴 때
// 보내는 것과 같은 취소 신호를 보내 대기 작업을 취소로 끝내고 다음 시도를 연다. 대기 작업이 없으면 아무 일도 없다.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/auth")
.setMethodCallHandler { call, result ->
if (call.method != "cancelPendingWebSignIn") {
result.notImplemented()
return@setMethodCallHandler
}
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
Intent("com.google.firebase.auth.ACTION_RECEIVE_FIREBASE_AUTH_INTENT")
.putExtra("com.google.firebase.auth.internal.EXTRA_CANCELED", true)
)
result.success(null)
}
// API 레벨 — Android 13+ 는 클립보드 복사를 시스템이 알리므로 앱 토스트를 생략한다(Dart `copyToClipboard`).
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/device")
.setMethodCallHandler { call, result ->
+1
View File
@@ -1 +1,2 @@
FANUZU_KAKAO_NATIVE_APP_KEY=
FANUZU_LINE_CHANNEL_ID=
+4 -4
View File
@@ -228,7 +228,7 @@ service cloud.firestore {
// 생성 주체를 Auth 트리거 하나로 고정한다.
allow create: if false;
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(원장 B-52 —
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(정책 원장 B-52 —
// 2~20자 · 중복 허용 · 유니크 인덱스 제거). 목록에 없으면 MY 의 닉네임 변경이
// permission-denied 로 떨어지는데, 원인이 화면처럼 보여 찾기 어렵다.
// `displayName` 은 Auth 트리거가 처음 채우는 필드라(§8.5 fn-auth-oncreate) 함께 남긴다.
@@ -342,7 +342,7 @@ service cloud.firestore {
}
// 결제 기록. `fn-iap-verify` 가 쓰고 환불 회수는 `fn-iap-refund-webhook` 가 바꾼다.
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (원장 B-02 · B-72)
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (정책 원장 B-02 · B-72)
// 본인이 할 수 있는 것은 **지급 완료 → 환불 요청** 한 가지 전이뿐이다. 금액·상태의
// 다른 전이(승인·반려·회수)는 Admin 과 서버가 한다.
match /users/{uid}/purchases/{purchaseId} {
@@ -511,7 +511,7 @@ service cloud.firestore {
allow write: if false; // 수령은 fn-herodrop-claim 경유
}
// Hero Gift 상품 Catalog — 원장 B-85. 문서 ID = 스토어 상품 ID.
// Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID = 스토어 상품 ID.
// 화면: 홈 「아이템 구입」 · Admin `관리 마스터_아이템 관리`(fnAdminWrite 경유)
// 지급량(buyerPop · dropPop)은 `fn-iap-verify` 가 여기서 읽으므로 클라이언트 쓰기를 막는다.
match /heroGiftProducts/{productId} {
@@ -529,7 +529,7 @@ service cloud.firestore {
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
// 위치 Quest 의 QR 코드(원장 C-03) — 생성자가 현장에 붙일 QR 을 띄우려고 읽는다. 참여자가 읽으면
// 위치 Quest 의 QR 코드(정책 원장 C-03) — 생성자가 현장에 붙일 QR 을 띄우려고 읽는다. 참여자가 읽으면
// 찍지 않고 값을 보낼 수 있으므로 생성자 · 조회 등급만. `fn-quest-create` 가 발급하고 고치지 않는다.
match /questSecrets/{questId} {
allow read: if signedIn() && (resource.data.authorUid == request.auth.uid || isViewer());
+73 -25
View File
@@ -26,7 +26,7 @@ export const fnAuthOnCreate = functionsV1.region('asia-northeast3').auth.user().
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
const defaults: Record<string, unknown> = {
uid: user.uid,
@@ -86,12 +86,12 @@ export async function planetColorKeys(): Promise<string[]> {
/**
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
* - 성운 전환: 정책 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - 대표 팬덤: 정책 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지.
* 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
* 정책 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
* 자동 지정은 C-15 대기에 세지 않는다.
*/
const FANDOM_DEFAULTS = {
@@ -122,7 +122,7 @@ function primaryOf(user: Data): string | null {
}
/**
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 원장 C-15.
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 정책 원장 C-15.
*
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
@@ -145,7 +145,7 @@ function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: n
/**
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
*
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 원장 B-68 #001~ 기록).
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 정책 원장 B-68 #001~ 기록).
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
@@ -183,7 +183,7 @@ function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data)
}
/**
* `fn-nebula-create` — 성운(Stage0) 생성. 원장 B-08 · B-09 · B-10 · C-15.
* `fn-nebula-create` — 성운(Stage0) 생성. 정책 원장 B-08 · B-09 · B-10 · C-15.
*
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
@@ -237,7 +237,7 @@ export const fnNebulaCreate = onCall(async (req) => {
});
/**
* `fn-fandom-join` — 행성/성운 가입. 원장 C-15 「복수 팬덤 가입」.
* `fn-fandom-join` — 행성/성운 가입. 정책 원장 C-15 「복수 팬덤 가입」.
*
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
@@ -276,10 +276,10 @@ export const fnFandomJoin = onCall(async (req) => {
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
* 순번은 `memberSeq` 를 되돌리지 않으므로 다른 사용자에게 다시 주지 않는다(C-100 · B-68).
*
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(정책 원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
* 가운데 `joinedAt` 이 가장 이른 사용자에게 넘기고 알림을 남긴다. 남은 멤버가 없으면 비워 두고
* 다음 가입자가 이어받는다(원장 S20 확인 전 유지 기준). `ownerUid`(행성 문서) 등 최초 생성자 기록은 그대로다.
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(원장 C-101). 자동 지정이라
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(정책 원장 C-101). 자동 지정이라
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
*/
export const fnFandomLeave = onCall(async (req) => {
@@ -343,7 +343,7 @@ export const fnFandomLeave = onCall(async (req) => {
});
/**
* `fn-primary-fandom-set` — 대표 팬덤 변경. 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
* `fn-primary-fandom-set` — 대표 팬덤 변경. 정책 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
*
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
@@ -369,11 +369,11 @@ export const fnPrimaryFandomSet = onCall(async (req) => {
});
/**
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 원장 B-09 · B-10 · C-50.
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 정책 원장 B-09 · B-10 · C-50.
*
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
* (Firestore 설계서 §2.4 정정). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
* (Firestore 설계서 §2.4). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
*
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
@@ -436,44 +436,83 @@ export const fnPlanetCreate = onCall(async (req) => {
});
/**
* `fn-consent-record` — 약관 동의 이력.
* 가입 이후 따로 받는 동의 — 위치기반 서비스(Q-E1 · 1차 검수 R02 「위치기능 최초 사용 전」)와 마케팅 수신.
* 가입 필수 항목(서비스 · 개인정보 수집·이용 · 만 14세)은 여기서 철회하지 않는다(탈퇴로만 끝난다).
*/
export const LATER_CONSENTS = ['location', 'marketing'] as const;
/**
* `fn-consent-record` — 약관 동의 · 철회 이력.
*
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다 — 행 ID 는
* `{항목}_{버전}_{grant|revoke}_{시각}` 이라 같은 항목을 동의 → 철회 → 재동의해도 앞 행이 덮이지 않는다.
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
*
* 지금 상태는 사용자 문서 `consentState.{항목}`(서버만 쓴다 — 규칙의 본인 수정 목록에 없음)에 함께 둔다.
* 위치 Quest 인증(`fnQuestVerifyPlace`)이 이 값으로 동의 여부를 본다.
*/
export const fnConsentRecord = onCall(async (req) => {
const uid = callerUid(req);
const docType = str(req.data, 'docType');
const docVersion = str(req.data, 'docVersion');
if (!(LATER_CONSENTS as readonly string[]).includes(docType)) {
throw new HttpsError('invalid-argument', '기록할 수 없는 동의 항목입니다.');
}
const granted = (req.data as Record<string, unknown>)?.granted === true;
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}`;
await getFirestore().doc(`users/${uid}/consents/${consentId}`).set({
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}_${Date.now()}`;
const db = getFirestore();
const batch = db.batch();
batch.set(db.doc(`users/${uid}/consents/${consentId}`), {
docType,
docVersion,
granted,
ip: req.rawRequest?.ip ?? null,
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
createdAt: FieldValue.serverTimestamp(),
});
batch.set(db.doc(`users/${uid}`), {
consentState: { [docType]: { granted, version: docVersion, at: FieldValue.serverTimestamp() } },
// 마케팅은 Push Marketing 구분과 같은 값이다(가입 때와 같은 규칙).
...(docType === 'marketing' ? { notificationPrefs: { marketing: granted } } : {}),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { consentId };
await batch.commit();
return { consentId, granted };
});
/**
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
* 위치기반 서비스에 동의한 상태인가. `consentState.location` 이 있으면 그 값(철회 포함)을 따르고,
* 없으면 예전 가입 화면(위치 약관이 가입 필수였던 0.2.0 이전)에서 남긴 `location_{가입 약관 버전}_grant`
* 행으로 본다 — 이미 동의한 사용자에게 다시 묻지 않는다.
*/
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
export async function hasLocationConsent(uid: string): Promise<boolean> {
const db = getFirestore();
const user = (await db.doc(`users/${uid}`).get()).data();
const state = (user?.consentState as Record<string, { granted?: unknown }> | undefined)?.location;
if (state) return state.granted === true;
const version = user?.termsVersion;
if (typeof version !== 'string' || !version) return false;
return (await db.doc(`users/${uid}/consents/location_${version}_grant`).get()).exists;
}
/**
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
* 가입 약관 필수 항목 — 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02**.
*
* 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인만 가입 필수다. **위치기반 서비스 약관은
* 가입 때 받지 않는다** — 위치기능을 처음 쓰기 직전에 따로 받는다([fnConsentRecord] `location`).
* 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF.
*/
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
/**
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 정책 원장 C-134).
*
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
* 예전 앱(0.2.0+10 이하)은 가입 화면에서 위치 약관을 받아 `location` 을 함께 보낸다. 사용자가 실제로
* 동의한 것이라 그대로 기록한다 — 버리면 그 앱으로 가입한 계정은 위치 동의 화면이 없어 위치 Quest 를 못 한다.
*/
export const fnSignupConsent = onCall(async (req) => {
const uid = callerUid(req);
@@ -497,10 +536,19 @@ export const fnSignupConsent = onCall(async (req) => {
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
{ docType, granted, ...meta }, { merge: true });
}
const legacyLocation = agreed.has('location');
if (legacyLocation) {
batch.set(db.doc(`users/${uid}/consents/location_${version}_grant`),
{ docType: 'location', granted: true, ...meta }, { merge: true });
}
batch.set(db.doc(`users/${uid}`), {
termsAgreedAt: FieldValue.serverTimestamp(),
termsVersion: version,
notificationPrefs: { marketing },
consentState: {
marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() },
...(legacyLocation ? { location: { granted: true, version, at: FieldValue.serverTimestamp() } } : {}),
},
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
+6 -77
View File
@@ -1,10 +1,9 @@
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { defineString } from 'firebase-functions/params';
import { onCall } from 'firebase-functions/v2/https';
import { str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
import { exchangeSocial, type SocialSpec } from './social_token.js';
/**
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
@@ -21,6 +20,7 @@ import { ERR, fail } from '../core/errors.js';
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
* UID 찾기 · SNS 연동 규칙(Q-E8)은 LINE 과 같아 `social_token.ts` 에 있다.
*
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
@@ -35,7 +35,7 @@ export interface KakaoProfile {
/** 카카오 회원번호. */
id: string;
nickname?: string;
/** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */
/** 동의했고 유효·인증된 이메일만. 없어도 가입 · 로그인 · 연결이 된다(R01 · Q-E8). */
email?: string;
}
@@ -74,80 +74,9 @@ export async function verifyKakaoToken(accessToken: string, appId: string): Prom
};
}
/** 이 카카오 계정이 붙은 Firebase UID. 없으면 null. */
async function ownerOf(kakaoId: string): Promise<string | null> {
try {
return (await getAuth().getUserByProviderUid(KAKAO_PROVIDER_ID, kakaoId)).uid;
} catch (e) {
if ((e as { code?: string }).code === 'auth/user-not-found') return null;
throw e;
}
}
function linkInfo(p: KakaoProfile) {
return {
providerId: KAKAO_PROVIDER_ID,
uid: p.id,
...(p.nickname ? { displayName: p.nickname } : {}),
...(p.email ? { email: p.email } : {}),
};
}
/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `kakao:<id>` 를 만들고 연결한다. */
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
const existing = await ownerOf(p.id);
if (existing) return existing;
// 탈퇴한 계정의 문서(원장 보존)가 `kakao:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
const base = kakaoUidOf(p.id);
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
const uid = withdrawn ? `${base}:${Date.now()}` : base;
const auth = getAuth();
try {
await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) });
} catch (e) {
// 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다.
if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e;
}
await auth.updateUser(uid, { providerToLink: linkInfo(p) });
return uid;
}
/**
* SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않는다.
*
* 「동일 이메일만 연동」(명세 `554:22797`)은 붙이기 **전에** 서버에서 본다. 둘 중 하나라도
* 이메일이 없으면 건너뛴다(앱과 같은 규칙 — 카카오 이메일은 비즈 앱 동의가 있을 때만 온다).
*/
export async function linkKakao(uid: string, p: KakaoProfile): Promise<void> {
const owner = await ownerOf(p.id);
if (owner === uid) return;
if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
const user = await getAuth().getUser(uid);
if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) {
fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.');
}
const mine = user.email?.trim().toLowerCase();
const theirs = p.email?.trim().toLowerCase();
if (mine && theirs && mine !== theirs) {
fail(ERR.authCredentialInUse, '같은 이메일의 계정만 연결할 수 있습니다.', { reason: 'different-email' });
}
await getAuth().updateUser(uid, { providerToLink: linkInfo(p) });
}
const KAKAO: SocialSpec = { providerId: KAKAO_PROVIDER_ID, uidPrefix: 'kakao', label: '카카오' };
export const fnAuthKakao = onCall(async (req) => {
const profile = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value());
if ((req.data as Record<string, unknown>).link === true) {
const uid = req.auth?.uid;
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
await linkKakao(uid, profile);
return { linked: true };
}
const uid = await resolveKakaoUid(profile);
// 동의한 이메일만 사용자 문서에 남긴다(10/5). Auth 이메일은 다른 제공자 계정과 겹치면 생성이
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
if (profile.email) {
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
}
return { token: await getAuth().createCustomToken(uid) };
const p = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value());
return exchangeSocial(KAKAO, { id: p.id, displayName: p.nickname, email: p.email }, req);
});
@@ -0,0 +1,88 @@
import { defineString } from 'firebase-functions/params';
import { onCall } from 'firebase-functions/v2/https';
import { optionalStr } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
import { exchangeSocial, type SocialProfile, type SocialSpec } from './social_token.js';
/**
* `fn-auth-line` — LINE 로그인 · SNS 연동을 Firebase 커스텀 토큰으로 잇는다. 카카오(`fnAuthKakao`)와 같은 구조다.
*
* **왜 OIDC 가 아니라 이 경로인가.** iOS 에서 Firebase OIDC(`oidc.line`) 웹 흐름은 첫 시도에 Firebase
* 핸들러가 「missing initial state」로 끝난다(10/11 iPhone 11 · iOS 26 실기기 — `disable_ios_auto_login` 을
* 붙여도 재현). 앱이 LINE 공식 SDK 로 로그인하고 서버가 그 토큰을 LINE API 로 검증해 커스텀 토큰을 내준다.
*
* **사용자 식별은 기존 `oidc.line` 연결을 그대로 쓴다** — OIDC 시절 가입자는 같은 UID 로 들어오고, SNS 연동
* 화면이 지금처럼 「연결됨」을 그린다. 신규 가입자의 UID 는 `line:<LINE userId>` 다.
*
* 입력: `{ idToken?, accessToken?, link? }` — 둘 중 하나는 있어야 한다. ID 토큰이 있으면 그것으로 검증한다
* (이메일은 ID 토큰에만 온다). `link: true` 면 로그인한 호출자 UID 에 LINE 을 붙이고 `{ linked: true }`,
* 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
*/
export const LINE_PROVIDER_ID = 'oidc.line';
/** LINE Login 채널 ID(숫자). 비밀값이 아니다 — 토큰이 우리 채널에서 발급됐는지 본다. */
const lineChannelId = defineString('LINE_CHANNEL_ID', { default: '' });
const LINE: SocialSpec = { providerId: LINE_PROVIDER_ID, uidPrefix: 'line', label: 'LINE' };
export const lineUidOf = (lineId: string) => `${LINE.uidPrefix}:${lineId}`;
const API = 'https://api.line.me';
/**
* LINE 토큰을 LINE API 로 검증한다. 남의 채널에서 받은 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을
* 막는 지점이다.
*
* - ID 토큰: `POST /oauth2/v2.1/verify` — 서명 · 만료 · `client_id`(우리 채널) 검증을 LINE 이 하고 `sub` ·
* `name` · `email`(동의했을 때만)을 돌려준다.
* - 액세스 토큰: `GET /oauth2/v2.1/verify` 로 우리 채널 발급 · 남은 유효 시간을 보고 `/v2/profile` 로 회원번호를 읽는다.
*/
export async function verifyLineToken(
token: { idToken?: string | undefined; accessToken?: string | undefined },
channelId: string,
): Promise<SocialProfile> {
if (!channelId) fail(ERR.authFailed, 'LINE 로그인 설정이 끝나지 않았습니다.');
const bad = (): never => fail(ERR.authFailed, 'LINE 인증을 확인하지 못했습니다.');
const json = async (res: Response) => {
if (!res.ok) bad();
return (await res.json()) as Record<string, unknown>;
};
if (token.idToken) {
const claims = await json(await fetch(`${API}/oauth2/v2.1/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id_token: token.idToken, client_id: channelId }).toString(),
}));
if (String(claims.aud) !== channelId || typeof claims.sub !== 'string' || !claims.sub) bad();
return {
id: claims.sub as string,
...(typeof claims.name === 'string' && claims.name ? { displayName: claims.name } : {}),
...(typeof claims.email === 'string' && claims.email ? { email: claims.email } : {}),
};
}
if (!token.accessToken) fail(ERR.authFailed, 'LINE 인증 정보가 없습니다.');
const info = await json(await fetch(
`${API}/oauth2/v2.1/verify?access_token=${encodeURIComponent(token.accessToken)}`,
));
if (String(info.client_id) !== channelId || !(Number(info.expires_in) > 0)) bad();
const me = await json(await fetch(`${API}/v2/profile`, {
headers: { Authorization: `Bearer ${token.accessToken}` },
}));
if (typeof me.userId !== 'string' || !me.userId) bad();
return {
id: me.userId as string,
...(typeof me.displayName === 'string' && me.displayName ? { displayName: me.displayName } : {}),
};
}
export const fnAuthLine = onCall(async (req) => {
const profile = await verifyLineToken(
{ idToken: optionalStr(req.data, 'idToken'), accessToken: optionalStr(req.data, 'accessToken') },
lineChannelId.value(),
);
return exchangeSocial(LINE, profile, req);
});
@@ -67,7 +67,7 @@ export async function inviteCodeOf(uid: string): Promise<string> {
/** `fn-invite-code-get` — 내 초대 코드(없으면 만들어서). 공유 링크 `?ref=` 에 붙는다. */
export const fnInviteCodeGet = onCall(async (req) => ({ code: await inviteCodeOf(callerUid(req)) }));
/** 추천 보상 보류 시간 — 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
/** 추천 보상 보류 시간 — 정책 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
/**
@@ -109,7 +109,7 @@ export const fnReferralAttribute = onCall(async (req) => {
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
}
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(원장 C-120).
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(정책 원장 C-120).
// 자기 · 상호 · 중복 · 이상 패턴은 아래 판정과 Risk Engine 이 맡는다.
const { referralAttachDays } = await loadPolicy();
@@ -244,7 +244,7 @@ export async function settleReferral(uid: string): Promise<'paid' | 'released' |
uid: referrerUid, fandomId: fandomOfUser(referrer), axis: 'referral', units: 1,
activity: 'referral', sourceId: referralFpiSource(fresh), occurredAt: new Date(),
refPath: `referrals/${fresh.get('attributionId')}`,
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다(리뷰 M5). FPI REFERRAL 축만.
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다. FPI REFERRAL 축만.
grantsExp: false,
}, referrer);
});
@@ -0,0 +1,132 @@
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { ERR, fail } from '../core/errors.js';
/**
* 공식 SDK 로그인 + 서버 커스텀 토큰 제공자(카카오 `fnAuthKakao` · LINE `fnAuthLine`)의 공통 부분 —
* 검증된 제공자 계정으로 로그인할 UID 를 찾거나 만들고, SNS 연동으로 붙인다.
*
* **사용자 식별은 OIDC 시절 제공자 ID(`oidc.kakao` · `oidc.line`)를 그대로 쓴다.** Firebase 사용자의
* 제공자 정보에 그 ID / 제공자 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다.
* OIDC 시절 가입자는 같은 UID 로 로그인되고, SNS 연동 화면이 지금처럼 「연결됨」을 그린다.
*/
export interface SocialSpec {
/** Firebase 제공자 ID — `oidc.kakao` · `oidc.line`. */
providerId: string;
/** 신규 가입자 UID 접두 — `kakao` · `line`. UID 는 `<접두>:<회원번호>`. */
uidPrefix: string;
/** 오류 문구에 쓰는 이름 — 「카카오」 · 「LINE」. */
label: string;
}
export interface SocialProfile {
/** 제공자 회원번호(OIDC `sub`). */
id: string;
displayName?: string | undefined;
/** 동의했고 제공자가 준 이메일만. 없어도 가입 · 로그인 · 연결이 된다(R01 · Q-E8 · C-143). */
email?: string | undefined;
}
/** 이 제공자 계정이 붙은 Firebase UID. 없으면 null. */
async function ownerOf(spec: SocialSpec, id: string): Promise<string | null> {
try {
return (await getAuth().getUserByProviderUid(spec.providerId, id)).uid;
} catch (e) {
if ((e as { code?: string }).code === 'auth/user-not-found') return null;
throw e;
}
}
function linkInfo(spec: SocialSpec, p: SocialProfile) {
return {
providerId: spec.providerId,
uid: p.id,
...(p.displayName ? { displayName: p.displayName } : {}),
...(p.email ? { email: p.email } : {}),
};
}
/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `<접두>:<id>` 를 만들고 연결한다. */
export async function resolveSocialUid(spec: SocialSpec, p: SocialProfile): Promise<string> {
const existing = await ownerOf(spec, p.id);
if (existing) return existing;
// 탈퇴한 계정의 문서(원장 보존)가 `<접두>:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
const base = `${spec.uidPrefix}:${p.id}`;
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
const uid = withdrawn ? `${base}:${Date.now()}` : base;
const auth = getAuth();
try {
await auth.createUser({ uid, ...(p.displayName ? { displayName: p.displayName } : {}) });
} catch (e) {
// 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다.
if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e;
}
await auth.updateUser(uid, { providerToLink: linkInfo(spec, p) });
return uid;
}
/**
* SNS 연동 — 로그인한 [uid] 에 제공자 계정을 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
*
* 통합검토회신 v2.3 **Q-E8**(C 안 보완) — 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을
* **서버에서 모두** 확인해 붙인다. 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다.
* - 새 Provider 자격: 호출부가 제공자 API 로 우리 앱(채널)에서 발급된 유효 토큰인지 먼저 본다.
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 · 탈퇴 상태가 아니고 토큰이
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
* - 그 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
*/
export async function linkSocial(
spec: SocialSpec, uid: string, p: SocialProfile, authTime?: number,
): Promise<void> {
const inUse = `이미 다른 계정에 연결된 ${spec.label} 계정입니다.`;
const owner = await ownerOf(spec, p.id);
if (owner === uid) return;
if (owner) fail(ERR.authCredentialInUse, inUse);
const user = await getAuth().getUser(uid);
const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0;
const withdrawn = (await getFirestore().doc(`users/${uid}`).get()).get('status') === 'withdrawn';
if (user.disabled || withdrawn || (authTime !== undefined && authTime * 1000 < revokedAt)) {
fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' });
}
if (user.providerData.some((d) => d.providerId === spec.providerId)) {
fail(ERR.authAlreadyLinked, `이미 다른 ${spec.label} 계정이 연결되어 있습니다.`);
}
try {
await getAuth().updateUser(uid, { providerToLink: linkInfo(spec, p) });
} catch (e) {
const code = (e as { code?: string }).code ?? '';
if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) {
fail(ERR.authCredentialInUse, inUse);
}
throw e;
}
if ((await ownerOf(spec, p.id)) !== uid) fail(ERR.authCredentialInUse, inUse);
}
/**
* callable 본문 — `link: true` 면 로그인한 호출자 UID 에 붙이고 `{ linked: true }`, 아니면 로그인할 UID 를
* 찾거나 만들어 `{ token }`(커스텀 토큰)을 돌려준다.
*/
export async function exchangeSocial(
spec: SocialSpec,
profile: SocialProfile,
req: { data: unknown; auth?: { uid: string; token?: { auth_time?: unknown } } },
): Promise<{ linked: true } | { token: string }> {
if ((req.data as Record<string, unknown>).link === true) {
const uid = req.auth?.uid;
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
const authTime = req.auth?.token?.auth_time;
await linkSocial(spec, uid, profile, typeof authTime === 'number' ? authTime : undefined);
return { linked: true };
}
const uid = await resolveSocialUid(spec, profile);
// 동의한 이메일만 사용자 문서에 남긴다(10/5 · C-143). Auth 이메일은 다른 제공자 계정과 겹치면 생성이
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
if (profile.email) {
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
}
return { token: await getAuth().createCustomToken(uid) };
}
@@ -66,7 +66,7 @@ export function parseTranslationInput(data: unknown): TranslationInput {
}
/**
* 등록 시점 판정 — 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
* 등록 시점 판정 — 정책 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
* 상한의 [TRANSLATION_HARD_LIMIT_FACTOR] 배를 넘으면 등록 자체를 거부(E-TRN-02),
* 상한까지는 보상, 그 사이는 등록만 받고 보상 0.
*/
+1 -1
View File
@@ -15,7 +15,7 @@ import { voidCompletions } from '../ledger/fpi.js';
*/
/**
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3).
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
*/
const FORBIDDEN = [
@@ -10,7 +10,7 @@ import { verifiedQuestCountOf } from '../ledger/fpi.js';
import { publicNameOf } from '../core/names.js';
/**
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
* `fn-campaign-create` — 사용자 캠페인 개설(정책 원장 B-56 자격 · B-57 등록비).
*
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
@@ -25,7 +25,7 @@ const REFUND_DEADLINE_HOURS = 72;
/**
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
* 자동 재시도를 멈춘다(정책 원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
*/
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
@@ -369,7 +369,7 @@ export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS },
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
});
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
/** 결과보고 사진·영수증 상한 — 정책 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
export const REPORT_MAX_PHOTOS = 5;
const REPORT_MAX_CHARS = 5000;
const BUDGET_MAX_LINES = 50;
@@ -379,7 +379,7 @@ const CAMPAIGN_ID = /^[A-Za-z0-9_-]{1,128}$/;
export interface BudgetLine { label: string; amount: number }
/**
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(원장 B-35 · FR-ADM-05 · FR-CMP-06).
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(정책 원장 B-35 · FR-ADM-05 · FR-CMP-06).
*
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
+7 -7
View File
@@ -10,22 +10,22 @@ export interface PolicyConfig {
/** 출석 1회 보상 활동 POP. */
attendPop: number;
/**
* 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정형 **기여 POP** / 회.
* 사용자 생성 Quest 의 광고 추가 보상 — 정책 원장 **C-02** 고정형 **기여 POP** / 회.
* 기본 보상액에 비례하지 않고 회사 재원이다(B-28).
*/
adRewardPop: number;
/**
* 운영(관리자) Quest 의 광고 배수 — 원장 **B-03** 기본 x2. 지정 Quest 는
* 운영(관리자) Quest 의 광고 배수 — 정책 원장 **B-03** 기본 x2. 지정 Quest 는
* `quests/{id}.adMultiplier`(예: 3)가 덮는다. 지급은 **증분만**(기본 보상 × (배수 − 1))
* 기여 POP 이다.
*/
adMultiplier: number;
/**
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 원장 **B-02** 「POP 자체 직접 판매 없음」
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 정책 원장 **B-02** 「POP 자체 직접 판매 없음」
* 이라 기본 false. 켜지 않으면 `fn-iap-verify` 가 E-PAY-01 로 거부한다.
*/
popPackSalesEnabled: boolean;
/** Focus 구독 일일 Claim 기여 POP — 원장 **B-04** 확정 40. */
/** Focus 구독 일일 Claim 기여 POP — 정책 원장 **B-04** 확정 40. */
focusDailyPop: number;
/** 광고 보상 일일 상한 횟수 — E-AD-02 판정 기준. */
adDailyLimit: number;
@@ -111,14 +111,14 @@ export interface PolicyConfig {
* HUD 「LIVE」 = 최근 이 분(分) 안에 앱을 쓴 팬덤 멤버 수 — 해석 확인 요청 **Q-GM-03** A안. 🟡 운영값 5.
*/
liveWindowMinutes: number;
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
/** 추천 보상 — 정책 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
referralReferrerPop: number;
referralReferredPop: number;
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
timeAttackMaxMinutes: number;
/** Music Shake 판정 · 성공 · 보상 운영값(원장 B-24). 아래 [MusicShakePolicy] 참고. */
/** Music Shake 판정 · 성공 · 보상 운영값(정책 원장 B-24). 아래 [MusicShakePolicy] 참고. */
musicShake: MusicShakePolicy;
}
@@ -128,7 +128,7 @@ export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
/**
* Music Shake 운영값 — `config/policy.musicShake`.
*
* **🟡 임시값 — 회신 대기. 근거 없음.** 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
* **🟡 임시값 — 회신 대기. 근거 없음.** 정책 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
* 보상 기준 · 완청 보상 아님 · 보상액은 운영값」뿐이다. 판정 창 · 성공 기준 · 등급(S/A/B/C) ·
* 등급 배수는 PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 어디에도 수치가 없어 **저희가 임시로 둔
* 값**이며, 해석 확인 요청 「2026-10-07 Music Shake」(Q-MS-04 · Q-MS-05) 회신으로 바꾼다.
+1 -1
View File
@@ -37,7 +37,7 @@ export const ERR = {
primaryCooldown: 'E-FDM-04',
permissionDenied: 'E-PERM-01',
authFailed: 'E-AUTH-03',
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「그 Provider 가 다른 계정에 이미 연결」(Q-E8 — 이메일 비교 없음).
authCredentialInUse: 'E-AUTH-08',
authAlreadyLinked: 'E-AUTH-09',
// 서비스 탈퇴 — SNS 연동이 남아 있으면 「비정상 탈퇴」(Figma `554:24885` · `554:24908`).
@@ -3,7 +3,7 @@ import { dayKey } from '../core/time.js';
/**
* 2026-09-16 EC02·EC05: 누락된 집계를 0으로 채우지 않는다.
* 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
* 정책 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
* C-21 집계 기간은 운영값(기본 7일).
*/
export async function eclipseTarget(
@@ -102,7 +102,7 @@ const FINAL_STAGE = 6;
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
/**
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 원장 **B-11 · B-53 · B-54**.
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 정책 원장 **B-11 · B-53 · B-54**.
*
* - 레벨업 POP 진행도(`levelPop`) = 생성 기준점 이후 Growth POP 증가분 × ZAP 참여도 가중치(`zapWeight`,
* 일일 마감이 정함). 가중치는 진행도에만 쓰고 `growthPop`(원장 누계)은 그대로 둔다.
@@ -156,7 +156,7 @@ export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]>
if (!result) continue;
const { name, ...promotion } = result;
promoted.push(promotion);
// 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
// 정책 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
await sendFandomPush({
fandomId: ref.id,
category: 'fandomEvent',
+13 -13
View File
@@ -156,7 +156,7 @@ export async function defendEclipse(input: {
const room = Math.max(0, settings.perUserMaxDefense - myTotal);
const accepted = Math.min(shakes, settings.perCallMaxShakes, room);
const clamped = shakes - accepted;
// 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
// 정책 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
// 회차 문서에 고정한 값을 쓴다 — 진행 중 운영값 변경이 같은 회차의 기준을 바꾸지 않는다.
const need = (eclipse.get('qualifyShakes') as number | undefined) ?? settings.qualifyShakes;
const qualified = myTotal < need && myTotal + accepted >= need;
@@ -204,7 +204,7 @@ export async function defendEclipse(input: {
});
if (result.accepted === 0) return result;
// **성공 판정을 1 분 집계에만 맡기지 않는다**(검수 D2). 목표에 닿은 순간 세워야
// **성공 판정을 1 분 집계에만 맡기지 않는다**. 목표에 닿은 순간 세워야
// 그 뒤의 참여가 헛돌지 않고, 화면이 성공을 곧 본다.
await markDefendedIfReached(eclipseRef);
return result;
@@ -300,22 +300,22 @@ export interface EclipseSettings {
/** 이 팬덤에 이클립스를 발동하는가. Admin 「활성화」. */
enabled: boolean;
/**
* 공통 발생 일정 — 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
* 공통 발생 일정 — 정책 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
* 배열 길이가 곧 하루 회차 수다(**하루 2회는 고정 제한이 아니다**). 팬덤 문서의
* `eclipseSchedule[]` 이 있으면 그것이 우선한다.
*/
schedule: EclipseSlot[];
/** 1회 발동당 FPI 침식률(%). Admin 「FPI 감소 수치」 · 수치표 v1.4 「침식 0.25% FPI」. */
erosionPercent: number;
/** 사전 알림 시간(초). 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
/** 사전 알림 시간(초). 정책 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
warnSeconds: number;
/** 평균 DAU 대비 고유 유효 참여자 목표율. 원장 C-25 기본 5%. */
/** 평균 DAU 대비 고유 유효 참여자 목표율. 정책 원장 C-25 기본 5%. */
targetRate: number;
/** 목표 분모의 DAU 평균 기간(일). 원장 C-21 기본 7일 · 운영값. */
/** 목표 분모의 DAU 평균 기간(일). 정책 원장 C-21 기본 7일 · 운영값. */
dauWindowDays: number;
/** 공통 방어 창(초). 원장 C-22 기본 60초 · 운영값. */
/** 공통 방어 창(초). 정책 원장 C-22 기본 60초 · 운영값. */
windowSec: number;
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 원장 C-25 기본 60회 · 운영값. */
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 정책 원장 C-25 기본 60회 · 운영값. */
qualifyShakes: number;
/** 한 호출이 실을 수 있는 최대 흔들기 수. 서버 상한이며 단말 값을 믿지 않는다. */
perCallMaxShakes: number;
@@ -391,7 +391,7 @@ function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
/**
* `fn-eclipse-trigger` — 이클립스 발동 · 사전 알림 · 종료.
*
* 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
* 정책 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
* 공통값. 하루 2회는 고정 제한이 아님. 일정 변경 시 푸시 · 종료 시각 함께 계산*.
*
* **중복 발동을 조건문으로 막지 않는다.** 문서 ID 를 `{KST 날짜}-{회차}` 로 고정하고
@@ -400,7 +400,7 @@ function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
*
* **사전 알림 시각부터 문서를 만들고 `status: 'scheduled'` 로 둔다.** 발동 시각에야
* 문서를 만들면 `warnAt` 이 이미 지난 시각이라 사전 알림을 보낼 수 없다. 푸시
* 예고(원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
* 예고(정책 원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
* 배너는 `active` 만 구독하므로 시작 전에 배너가 뜨지는 않는다.
*
* 시작 시 FPI 스냅샷만 남기고, 마감 후 실패 확정 때에만 그 기준으로 차감한다.
@@ -602,7 +602,7 @@ async function closeExpiredEclipses(
occurredAt, createdAt: FieldValue.serverTimestamp(),
});
tx.update(fandomRef, {
// FPI 는 0 아래로 내려가지 않는다(리뷰 M2). 감소량 기록(penalty)은 그대로 남긴다.
// FPI 는 0 아래로 내려가지 않는다. 감소량 기록(penalty)은 그대로 남긴다.
fpiScore: Math.max(0, (fandom.get('fpiScore') as number) - penalty),
eclipsePenaltyRevision: FieldValue.increment(1),
});
@@ -938,7 +938,7 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
});
}
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
// 정책 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
const promoted = await promoteStages();
return { day, fandoms: fandoms.size, promoted: promoted.length };
}
@@ -1134,7 +1134,7 @@ export const fnCampaignFund = onCall(async (req) => fundCampaign({
/**
* 후원 한 건의 코어. 테스트가 HTTP 왕복 없이 부르도록 떼어 둔다.
*
* - **기여 POP 만 쓴다**(원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
* - **기여 POP 만 쓴다**(정책 원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
* - 목표는 규칙·설계서의 스키마 이름 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만
* 읽는다 — 예전에는 `goalPop` 만 읽어 목표 초과 차단(E-CMP-01)이 한 번도 걸리지 않았다.
* - 성장 귀속 팬덤은 **캠페인 문서의 `fandomId`** 다. 클라이언트가 보낸 값을 믿지 않는다.
@@ -25,7 +25,7 @@ interface DropConfig {
* 총량 하나에 운영값(1인 상한 % · 시간)을 붙여 Drop 규격을 만든다.
*
* [totalPop] 을 받으면 운영 기본 총량 대신 그 값을 쓴다 — Hero Gift 구매는 상품별
* Admin 확정값이 총량이다(원장 B-84 · B-85).
* Admin 확정값이 총량이다(정책 원장 B-84 · B-85).
*/
export async function dropConfig(totalPop?: number): Promise<DropConfig> {
const snap = await getFirestore().doc('config/policy').get();
+1
View File
@@ -14,6 +14,7 @@ export {
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnSignupConsent, fnAccountWithdraw, fnAdminSetRole,
} from './account/account.js';
export { fnAuthKakao } from './account/kakao.js';
export { fnAuthLine } from './account/line.js';
export {
fnEclipseDefend, fnEclipseAggregate, fnEclipseTrigger,
fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot,
+1 -1
View File
@@ -522,7 +522,7 @@ async function spendParts(
if (growthSpend) {
// FPI 는 기여 **행위 단위**다(C-34). 금액이 아니라 Zap 횟수(`fn-pop-spend` 가 서버에서
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18).
writeFpiEvent(tx, {
uid: input.uid, fandomId: fandomId ?? null, axis: 'contribution',
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
+3 -3
View File
@@ -47,7 +47,7 @@ export const fnPopGrant = onCall(async (req) => {
* 사용자가 직접 부를 수 있는 차감 사유 — **허용 목록**.
*
* 다른 사유는 각자의 검증 함수를 지나야 한다. 예전에는 사유를 그대로 받아 `CAMPAIGN_FUND`
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다(리뷰 H1 · R03).
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다.
*/
const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
@@ -55,9 +55,9 @@ const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
*
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤은 Zap 한 행성
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다. 귀속 팬덤은 Zap 한 행성
* `fandomId` 다(상세정의 5장 · 10/3 #10). 원장이 같은 트랜잭션에서 가입 여부를 확인하므로
* 가입하지 않은 행성은 거부된다(리뷰 M1 — 남의 행성 성장을 올리는 길을 막는다).
* 가입하지 않은 행성은 거부된다(남의 행성 성장을 올리는 길을 막는다).
*/
export const fnPopSpend = onCall(async (req) => {
const uid = callerUid(req);
+1 -1
View File
@@ -126,7 +126,7 @@ export async function sendUserPush(
}
/**
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
* 이클립스 사전 알림 — 정책 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
*
* `fn-eclipse-trigger` 가 회차의 `warnAt`(시작 − `warnSeconds`) 이 지난 첫 점검에서
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
+3 -3
View File
@@ -48,7 +48,7 @@ function platformOf(data: unknown): Platform {
const iapKey = (claim: ReceiptClaim) =>
`iap:${claim.platform}:${claim.transactionId}`;
/** POP 묶음 상품 ID 접두 — 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
/** POP 묶음 상품 ID 접두 — 정책 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
const POP_PACK_PREFIX = 'pop.support.';
const PAY_METHOD: Readonly<Record<Platform, string>> = {
@@ -59,7 +59,7 @@ const PAY_METHOD: Readonly<Record<Platform, string>> = {
interface HeroGiftProduct { name: string; buyerPop: number; dropPop: number }
/**
* Hero Gift 상품 Catalog — 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
* Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
*
* Admin `관리 마스터_아이템 관리` 가 `fnAdminWrite` 로 쓴다. 상품을 늘려도 앱을
* 다시 배포하지 않는다(B-40 검증 기준). Catalog 에 없으면 null — 일반 상품 표로 넘어간다.
@@ -408,7 +408,7 @@ export const fnSubscriptionWebhook = onCall(async (req) => {
});
/**
* `fn-focus-daily-claim` — Focus 구독 혜택, 원장 **B-04**.
* `fn-focus-daily-claim` — Focus 구독 혜택, 정책 원장 **B-04**.
*
* *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음.* 하루는 서버 일 단위(KST
* `dayKey`, 퀘스트 한도와 같은 경계)다. 멱등키가 `focus:{uid}:{day}` 라 같은 날 두 번 지급되지
@@ -9,7 +9,7 @@ import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { callerUid, str } from '../core/callable.js';
/**
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(원장 **C-05** · R10).
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(정책 원장 **C-05** · R10).
*
* 서버가 사용자가 준 URL 을 대신 여는 경로라 **SSRF 방어가 이 파일의 본체**다.
* - http / https 만, 계정 정보(`user:pass@`) · 기본 외 포트 거부
+6 -6
View File
@@ -56,7 +56,7 @@ export async function dailyLimitOf(questId: string): Promise<number> {
}
/**
* 참여할 퀘스트 문서를 확인한다 — 리뷰 H2 · B2.
* 참여할 퀘스트 문서를 확인한다.
*
* **문서가 없거나 유형이 다르면 거부한다.** 예전에는 `questId` 가 진행 문서 ID 로만 쓰여
* 있지도 않은 ID 를 바꿔 가며 부르면 일일 한도 · 1회 제한이 매번 새로 열렸다.
@@ -140,7 +140,7 @@ function count(data: Record<string, unknown>, key: string, max: number): number
}
/**
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(원장 C-83).
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(정책 원장 C-83).
*
* 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 유지되게 하는
* **표시용 상태**다. 지급 판정은 여기서 하지 않는다 — 보상은 그대로 검증 함수가 증거로 판정한다.
@@ -271,7 +271,7 @@ export const fnQuestAttend = onCall(async (req) => {
});
/**
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(원장 C-04 · B-25).
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(정책 원장 C-04 · B-25).
*
* POP 원장을 쓰지 않는다(검증 기준 「POP 지급 원장 0건」). 참여 기록(`questProgress`)과 FPI 원천
* 이벤트만 같은 트랜잭션에 남긴다 — 보상이 없는 유효 활동도 FPI 에 들어간다(C-34).
@@ -320,7 +320,7 @@ export const fnQuestVerifySteps = onCall(async (req) => {
const at = occurredAt(req.data, now);
const key = dayKey(at);
// 퀘스트 문서가 있어야 하고 걸음 유형이어야 한다. 없는 ID 로 바꿔 부르면 진행 문서 · 멱등키가
// 새로 열려 구간 보상이 다시 나간다(리뷰 재검토 blocker). 대표 팬덤 자격(C-16)도 본다.
// 새로 열려 구간 보상이 다시 나간다. 대표 팬덤 자격(C-16)도 본다.
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['walk', 'steps', 'health']);
const steps = positiveInt(num(req.data, 'steps'), 'steps');
@@ -414,7 +414,7 @@ export const fnQuestVerifySteps = onCall(async (req) => {
});
/**
* 광고 증분 — 원장 B-03 · C-02(B-28).
* 광고 증분 — 정책 원장 B-03 · C-02(B-28).
*
* - 사용자 생성 Quest(`authorUid` 있음): 고정 [PolicyConfig.adRewardPop] 기여 POP.
* - 운영 Quest: 기본 보상 × (배수 − 1) 기여 POP. 배수는 Quest 지정값(`adMultiplier`,
@@ -449,7 +449,7 @@ function questIdOfKey(key: unknown): string | null {
* 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)만 보낸다. 서버는 그 원장 행이 본인 · 오늘 ·
* 퀘스트 보상인지 확인하고, **AdMob 서버측 검증(SSV) 기록**(`adSsv`, [fnAdSsv])이 그 기본 보상에
* 붙어 있을 때만 증분을 기여 POP 으로 지급한다. 앱이 보낸 노출 ID 는 근거로 쓰지 않는다 — 함수만
* 불러서는 기여 POP 이 나가지 않는다(원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」).
* 불러서는 기여 POP 이 나가지 않는다(정책 원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」).
* 기본 보상 한 건에 광고 보상은 한 번이다(`users/{uid}/adBonuses/{baseTxId}`).
*
* - SSV 콜백이 아직 오지 않았으면 E-AD-03(횟수 미차감). 앱은 잠시 뒤 다시 부른다.
+10 -4
View File
@@ -11,6 +11,7 @@ import { grantPop, type GrantInput } from '../ledger/ledger.js';
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
import { payFromPool } from './ugc.js';
import { hasLocationConsent } from '../account/account.js';
/**
* 검증이 필요한 퀘스트 둘 — Music Shake 와 위치.
@@ -51,7 +52,7 @@ export interface ShakeJudgement {
export type { ShakeGrade };
/**
* 판정표 → 등급. F 면 실패(보상 0 — 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
* 판정표 → 등급. F 면 실패(보상 0 — 정책 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
* 🟡 임시값 — 회신 대기다(근거 없음 · 해석 확인 요청 2026-10-07 Q-MS-05).
*/
export function gradeShake(
@@ -92,7 +93,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
const now = new Date();
const data = (req.data ?? {}) as Record<string, unknown>;
// 곡과 하루 한도는 **퀘스트 문서**가 정한다. 예전 멱등키는 `trackId · 날짜` 라 곡 ID 만
// 바꿔 부르면 하루에 무제한 지급됐다(리뷰 재검토 blocker). 곡은 퀘스트의 카탈로그와 대조하고,
// 바꿔 부르면 하루에 무제한 지급됐다. 곡은 퀘스트의 카탈로그와 대조하고,
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
@@ -159,7 +160,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.');
}
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
// 정책 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
// `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
// 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05).
const base = quest.get('rewardPop');
@@ -213,12 +214,17 @@ const proofPhotoExists = (path: string) => placeProofStorage.exists(path).catch(
export const fnQuestVerifyPlace = onCall(async (req) => {
const uid = callerUid(req);
// 위치기반 서비스 약관 동의(Q-E1 · R02) — 가입 때가 아니라 위치기능 최초 사용 전에 받는다. 앱이 먼저
// 묻지만 서버도 같은 기준을 지킨다. 좌표를 받기 전에 끊는다(동의 없이 위치를 처리하지 않는다).
if (!(await hasLocationConsent(uid))) {
fail(ERR.permissionDenied, '위치기반 서비스 이용약관에 동의한 뒤 인증할 수 있습니다.', { reason: 'location-consent' });
}
const placeId = str(req.data, 'placeId');
const lat = num(req.data, 'lat');
const lng = num(req.data, 'lng');
// 참여 횟수는 Quest 단위다(C-12 — 같은 장소의 다른 Quest 는 별개). 그래서 퀘스트 문서가
// 반드시 있어야 하고 **그 퀘스트의 장소**여야 한다 — 없는 ID 로 바꿔 부르면 1회 제한이
// 매번 새로 열린다(리뷰 B2). 대표 팬덤 자격(C-16)도 여기서 본다.
// 매번 새로 열린다. 대표 팬덤 자격(C-16)도 여기서 본다.
const questId = str(req.data, 'questId');
const questDoc = await questOf(uid, questId, ['place', 'location']);
if (questDoc.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.');
@@ -0,0 +1,71 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnAuthOnCreate } from '../src/account/account.js';
import { fnAuthKakao, kakaoUidOf } from '../src/account/kakao.js';
/** 이메일 선택 수집 — 동의한 이메일만 남기고, 이메일이 없어도 가입 · 로그인된다. */
const U = 'test-email-user';
const KAKAO_ID = '555000111';
const db = () => getFirestore();
type KakaoReq = Parameters<typeof fnAuthKakao.run>[0];
const onCreate = (uid: string, extra: Record<string, unknown> = {}) =>
(fnAuthOnCreate as unknown as { run: (u: unknown) => Promise<void> })
.run({ uid, email: undefined, displayName: undefined, photoURL: undefined, providerData: [], ...extra });
function stubKakao(email?: string) {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.endsWith('/v1/user/access_token_info')) return Response.json({ id: Number(KAKAO_ID), app_id: 123 });
return Response.json({
id: Number(KAKAO_ID),
kakao_account: { profile: { nickname: '카카오' }, ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}) },
});
}));
}
async function wipe() {
await getAuth().deleteUsers([U, kakaoUidOf(KAKAO_ID)]);
for (const uid of [U, kakaoUidOf(KAKAO_ID)]) await db().recursiveDelete(db().doc(`users/${uid}`));
}
beforeAll(() => {
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다.');
process.env.KAKAO_APP_ID = '123';
bootAdmin();
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(wipe);
afterEach(() => { vi.unstubAllGlobals(); });
describe('계정 생성 트리거', () => {
it('제공자 정보의 이메일을 쓰고 · 이미 쓴 값은 덮지 않는다', async () => {
await db().doc(`users/${U}`).set({ displayName: '먼저씀' });
await onCreate(U, { providerData: [{ providerId: 'oidc.line', email: 'line@fan.io' }] });
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.displayName).toBe('먼저씀');
expect(user.email).toBe('line@fan.io');
expect(user.status).toBe('active');
expect((await db().doc(`users/${U}/wallet/main`).get()).get('activityBalance')).toBe(0);
});
it('이메일이 없으면 null 로 만든다', async () => {
await onCreate(U, { providerData: [{ providerId: 'oidc.line' }] });
expect((await db().doc(`users/${U}`).get()).get('email')).toBeNull();
});
});
describe('카카오 이메일', () => {
it('동의한 이메일만 사용자 문서에 남기고 · 없어도 로그인된다', async () => {
stubKakao();
const first = await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq) as { token: string };
expect(first.token).toBeTruthy();
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBeUndefined();
stubKakao('fan@kakao.com');
await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq);
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com');
});
});
@@ -4,7 +4,9 @@ import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.js';
import {
fnAccountWithdraw, fnAuthOnCreate, fnConsentRecord, fnSignupConsent, hasLocationConsent,
} from '../src/account/account.js';
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
/** 가입 약관 기록(E-04 · E-05) · 계정 생성 트리거의 덮어쓰기 금지 · 국가 미고정(E-13) · 서비스 탈퇴(E-42) · 카카오 이메일(E-02). */
@@ -66,20 +68,57 @@ describe('계정 생성 트리거', () => {
});
});
describe('fnSignupConsent', () => {
it('필수 하나라도 빠지면 거부', async () => {
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'location', 'age'] }));
describe('fnSignupConsent — Q-E1 · R02', () => {
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부', async () => {
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }));
expect(await codeOf(r)).toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('항목별 행 · termsAgreedAt · 마케팅 = Push Marketing', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'location', 'age'] }));
it('위치기반 약관 없이 가입된다 · 항목별 행 · 마케팅 기본 OFF = Push Marketing', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age'] }));
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.termsAgreedAt).toBeDefined();
expect(user.notificationPrefs.marketing).toBe(false);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort();
expect(rows).toEqual(['age_v1_grant', 'location_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
expect(rows).toEqual(['age_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
expect(await hasLocationConsent(U)).toBe(false);
});
it('예전 앱(0.2.0+10)이 가입 화면에서 받은 location 동의는 기록해 위치 Quest 를 막지 않는다', async () => {
await fnSignupConsent.run(as(U, { version: 'v2', agreed: ['service', 'privacy', 'location', 'age'] }));
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id);
expect(rows).toContain('location_v2_grant');
expect(await hasLocationConsent(U)).toBe(true);
});
});
describe('fnConsentRecord — 위치기능 최초 사용 전 동의 · 철회', () => {
it('동의 → 철회 → 재동의가 각각 행으로 남고(덮어쓰지 않음) 현재 상태를 따른다', async () => {
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: true }));
expect(await hasLocationConsent(U)).toBe(true);
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: false }));
expect(await hasLocationConsent(U)).toBe(false);
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: true }));
expect(await hasLocationConsent(U)).toBe(true);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.data());
expect(rows.map((r) => r.granted).sort()).toEqual([false, true, true]);
expect(rows.every((r) => r.docType === 'location' && r.docVersion === 'loc-1' && r.createdAt)).toBe(true);
const state = (await db().doc(`users/${U}`).get()).get('consentState.location');
expect(state.granted).toBe(true);
expect(state.version).toBe('loc-1');
});
it('가입 필수 항목은 이 경로로 철회할 수 없다', async () => {
expect(await codeOf(fnConsentRecord.run(as(U, { docType: 'service', docVersion: 'v1', granted: false }))))
.toBe('invalid-argument');
});
it('마케팅 철회는 Push Marketing 구분과 같은 값이 된다', async () => {
await fnConsentRecord.run(as(U, { docType: 'marketing', docVersion: 'v1', granted: true }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
await fnConsentRecord.run(as(U, { docType: 'marketing', docVersion: 'v1', granted: false }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(false);
});
});
@@ -46,7 +46,7 @@ it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는
expect(await denials('admin-none')).toBe(1);
});
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만(리뷰 H3)', async () => {
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만', async () => {
const denied = (role: string, path: string, patch?: Record<string, unknown>) =>
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await denied('master', 'fandoms/f1/fpiEvents/e1');
+30 -3
View File
@@ -96,11 +96,38 @@ describe('SNS 연동', () => {
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
});
it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => {
it('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다(Q-E8 · R01)', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubKakao('other@kakao.com');
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email');
stubKakao('FAN@fanuzu.co.kr');
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
await getAuth().deleteUser(GOOGLE_UID);
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubKakao();
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
const user = await getAuth().getUser(GOOGLE_UID);
expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true);
});
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다(Q-E8 세션 만료 · 인증 취소)', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
const before = Math.floor(Date.now() / 1000) - 60;
await getAuth().revokeRefreshTokens(GOOGLE_UID);
const linkAs = (authTime: number) => fnAuthKakao.run(
{ data: { accessToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
);
expect(await codeOf(linkAs(before))).toBe('session');
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
});
it('이미 다른 카카오가 붙어 있으면 E-AUTH-09 · 같은 카카오면 그대로 성공', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: '111' } });
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
await getAuth().deleteUser(GOOGLE_UID);
await getAuth().createUser({ uid: GOOGLE_UID });
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
});
});
@@ -0,0 +1,160 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnAuthLine, LINE_PROVIDER_ID, lineUidOf } from '../src/account/line.js';
/** LINE 커스텀 토큰 — ID 토큰 · 액세스 토큰 검증 · 기존 oidc.line 사용자 매핑 · 신규 UID · 이메일(C-143) · SNS 연동. */
const CHANNEL = '2000000001';
const LINE_ID = 'U0123456789abcdef0123456789abcdef';
const OLD_UID = 'test-line-oidc-user';
const GOOGLE_UID = 'test-line-google-user';
type Req = Parameters<typeof fnAuthLine.run>[0];
const call = (data: Record<string, unknown>, uid?: string) =>
fnAuthLine.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) {
const d = (e as { details?: { code?: string; reason?: string } }).details;
return d?.reason ?? d?.code;
}
};
const uidOf = (token: string) => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string;
/**
* LINE API 흉내. ID 토큰 'good' · 액세스 토큰 'good' 만 우리 채널 것, 'other-app' 은 남의 채널, 'expired' 는 만료.
* 실제 LINE 처럼 ID 토큰 검증은 보낸 client_id 와 aud 가 다르면 400 이다.
*/
function stubLine(email?: string) {
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; body?: string; headers?: Record<string, string> }) => {
if (init?.method === 'POST' && url.endsWith('/oauth2/v2.1/verify')) {
const form = new URLSearchParams(init.body);
const token = form.get('id_token');
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
const aud = token === 'other-app' ? '1' : CHANNEL;
if (form.get('client_id') !== aud) return Response.json({ error: 'invalid_request' }, { status: 400 });
return Response.json({ iss: 'https://access.line.me', sub: LINE_ID, aud, name: 'LINE팬', ...(email ? { email } : {}) });
}
if (url.includes('/oauth2/v2.1/verify?access_token=')) {
const token = new URL(url).searchParams.get('access_token');
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
return Response.json({ scope: 'profile openid', client_id: token === 'other-app' ? '1' : CHANNEL, expires_in: 100 });
}
if (url.endsWith('/v2/profile')) return Response.json({ userId: LINE_ID, displayName: 'LINE팬' });
return new Response('{}', { status: 404 });
}));
}
async function wipe() {
await getAuth().deleteUsers([OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]);
for (const uid of [OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]) await getFirestore().doc(`users/${uid}`).delete();
}
beforeAll(() => {
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
}
process.env.LINE_CHANNEL_ID = CHANNEL;
bootAdmin();
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(async () => { await wipe(); stubLine(); });
afterEach(() => { vi.unstubAllGlobals(); });
describe('토큰 검증', () => {
it('만료 · 남의 채널 토큰(ID · 액세스 모두)과 토큰 없음은 E-AUTH-03', async () => {
for (const key of ['idToken', 'accessToken']) {
expect(await codeOf(call({ [key]: 'expired' }))).toBe('E-AUTH-03');
expect(await codeOf(call({ [key]: 'other-app' }))).toBe('E-AUTH-03');
}
expect(await codeOf(call({}))).toBe('E-AUTH-03');
});
it('ID 토큰이 있으면 ID 토큰으로 검증한다(액세스 토큰이 남의 채널 것이어도 보지 않는다)', async () => {
const { token } = await call({ idToken: 'good', accessToken: 'other-app' }) as { token: string };
expect(uidOf(token)).toBe(lineUidOf(LINE_ID));
});
});
describe('로그인', () => {
it('OIDC 시절 oidc.line 가입자는 같은 UID 로 들어온다', async () => {
await getAuth().createUser({ uid: OLD_UID });
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: LINE_ID } });
const { token } = await call({ idToken: 'good' }) as { token: string };
expect(uidOf(token)).toBe(OLD_UID);
});
it('신규는 line:<userId> 로 만들고 oidc.line 을 붙인다 · 액세스 토큰만으로도 같은 UID', async () => {
const first = await call({ idToken: 'good' }) as { token: string };
const second = await call({ accessToken: 'good' }) as { token: string };
expect(uidOf(first.token)).toBe(lineUidOf(LINE_ID));
expect(uidOf(second.token)).toBe(lineUidOf(LINE_ID));
const user = await getAuth().getUser(lineUidOf(LINE_ID));
expect(user.displayName).toBe('LINE팬');
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[LINE_PROVIDER_ID, LINE_ID]]);
});
it('이메일은 LINE 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다(C-143)', async () => {
await call({ idToken: 'good' });
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBeUndefined();
stubLine('fan@line.me');
await call({ idToken: 'good' });
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBe('fan@line.me');
});
it('탈퇴한 line:<id> 문서가 있으면 새 UID 로 만든다', async () => {
await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).set({ status: 'withdrawn' });
const { token } = await call({ idToken: 'good' }) as { token: string };
const uid = uidOf(token);
expect(uid.startsWith(`${lineUidOf(LINE_ID)}:`)).toBe(true);
await getAuth().deleteUser(uid);
});
});
describe('SNS 연동', () => {
it('로그인 없이 link 는 E-AUTH-03', async () => {
expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03');
});
it('Google 사용자에 붙인다(이메일이 달라도 · 없어도 — Q-E8) · 다른 UID 가 쓰는 LINE 은 E-AUTH-08', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubLine('other@line.me');
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
const user = await getAuth().getUser(GOOGLE_UID);
expect(user.providerData.some((d) => d.providerId === LINE_PROVIDER_ID && d.uid === LINE_ID)).toBe(true);
await getAuth().createUser({ uid: OLD_UID });
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
});
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
const before = Math.floor(Date.now() / 1000) - 60;
await getAuth().revokeRefreshTokens(GOOGLE_UID);
const linkAs = (authTime: number) => fnAuthLine.run(
{ data: { idToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
);
expect(await codeOf(linkAs(before))).toBe('session');
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
});
it('탈퇴 상태 계정으로는 붙이지 않는다', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
await getFirestore().doc(`users/${GOOGLE_UID}`).set({ status: 'withdrawn' });
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('session');
});
it('이미 다른 LINE 이 붙어 있으면 E-AUTH-09 · 같은 LINE 이면 그대로 성공', async () => {
await getAuth().createUser({ uid: GOOGLE_UID });
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: 'U-other' } });
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
await getAuth().deleteUser(GOOGLE_UID);
await getAuth().createUser({ uid: GOOGLE_UID });
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
});
});
+1 -1
View File
@@ -360,7 +360,7 @@ describe('푸시 토픽', () => {
});
});
describe('이클립스 사전 알림 — 시작 − 3분 (원장 C-22)', () => {
describe('이클립스 사전 알림 — 시작 − 3분 (정책 원장 C-22)', () => {
/// 예약 회차 한 건을 심는다. [startInMin] 분 뒤 시작, 예고는 그 3분 전이다.
async function round(id: string, startInMin: number | null, extra: Record<string, unknown> = {}) {
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${id}`);
+5 -5
View File
@@ -16,9 +16,9 @@ import { aggregateActiveEclipses, defendEclipse } from '../src/fandom/fandom.js'
* 보는 것
* 1. **POP 이 움직이지 않는다** — 지갑과 원장이 그대로다.
* 2. **분산 카운터 유실 0** — 샤드를 흩어도 합이 참여 총량과 같다(별첨 1 ① 예외 처리).
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다(검수 D1·D6).
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다.
* 4. **서버 검증** — 단말이 보낸 흔들기 수를 상한과 속도로 다시 판정한다(별첨 1 ②).
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다(검수 D2).
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다.
*/
const FANDOM = 'test-ecl-fandom';
@@ -195,7 +195,7 @@ describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)',
});
});
describe('멱등 — 재시도가 이중 계산되지 않는다 (검수 D1·D6)', () => {
describe('멱등 — 재시도가 이중 계산되지 않는다', () => {
it('같은 clientSeq 를 두 번 보내도 샤드·로그·티커가 한 번만 늘어난다', async () => {
await seed();
const first = await defendEclipse({
@@ -338,7 +338,7 @@ describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
});
});
describe('성공 판정 — 목표에 닿은 즉시 (검수 D2)', () => {
describe('성공 판정 — 목표에 닿은 즉시', () => {
it('한 사람의 추가 Shake는 목표 두 명을 채울 수 없고 두 번째 60회에서만 성공한다', async () => {
await seed({ target: 2, perCallMaxShakes: 60 });
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
@@ -485,7 +485,7 @@ describe('v1.5 유효 참여의 원자적 기록', () => {
});
});
describe('개인 인정 횟수는 운영값이다 (원장 C-25)', () => {
describe('개인 인정 횟수는 운영값이다 (정책 원장 C-25)', () => {
it('회차에 고정된 인정 횟수로 판정하고 운영값 문서만 바꿔서는 진행 중 회차 기준이 바뀌지 않는다', async () => {
await seed({ target: 1, perCallMaxShakes: 60, qualifyShakes: 60 });
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
@@ -146,7 +146,7 @@ beforeEach(async () => {
await wipeFandom();
});
describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
describe('발동 — 하루 정확히 2 회 (정책 원장 B-55)', () => {
it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => {
await seedFandom();
await aggregateFpi();
@@ -278,7 +278,7 @@ describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
});
it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => {
// 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
// 정책 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 });
await aggregateFpi();
@@ -318,14 +318,14 @@ describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
.get('status')).toBe('active');
await runEclipseSchedule(kst(DAY, '13:31'));
// 검수 D3 — 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
// 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
.get('status')).toBe('ended');
});
});
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (원장 C-24)', () => {
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (정책 원장 C-24)', () => {
const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get())
.docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort();
@@ -62,7 +62,7 @@ it('KST 자정 실행은 전날 키로 스냅샷 · DAU 를 남기고 DAU 는
expect((await ref.get()).get('sfpi')).toBe(50 + 125 * 3);
});
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 M2)', async () => {
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다', async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
await ref.set({ stage: 1 }, { merge: true });
await ref.collection('fpiEvents').add({
+2 -2
View File
@@ -155,7 +155,7 @@ it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
});
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다', async () => {
const db = getFirestore();
const { fnPopSpend } = await import('../src/ledger/pop.js');
const run = (data: Record<string, unknown>) => fnPopSpend.run(
@@ -168,7 +168,7 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다(리뷰 M1).
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다.
await expect(run({ popType: 'activity', amount: 30, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b0' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
+2 -2
View File
@@ -118,7 +118,7 @@ describe('차감', () => {
const user = await db.doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(40);
// 기여 POP 문턱 등급은 폐기됐다(C-18). 후원 · Zap 은 1 POP 단위로 쪼개 반복할 수 있어
// EXP 를 주지 않는다(리뷰 B1) — EXP 는 Quest 인정 활동에서만 쌓인다.
// EXP 를 주지 않는다 — EXP 는 Quest 인정 활동에서만 쌓인다.
expect(user.get('tier')).toBeUndefined();
expect(user.get('userExp')).toBeUndefined();
const member = await db.doc(`fandoms/fandom-a/members/${UID}`).get();
@@ -279,7 +279,7 @@ describe('User EXP · Level · Passport 갱신 (C-18 · B-65)', () => {
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(3_000);
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지 · 리뷰 B1).
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지).
expect(user.get('userExp')).toBeUndefined();
expect(user.get('passportTier')).toBeUndefined();
expect(user.get('tier')).toBeUndefined();
@@ -6,7 +6,7 @@ import {
fnFandomJoin, fnFandomLeave, fnNebulaCreate, fnPlanetCreate, fnPrimaryFandomSet, isValidNebulaName,
} from '../src/account/account.js';
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 정책 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
const UID = 'test-planet-create';
const OTHER = 'test-planet-create-other';
const db = () => getFirestore();
@@ -143,7 +143,7 @@ describe('가입 · 탈퇴 · 대표 팬덤', () => {
});
});
describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(원장 C-100 · C-101 · S20)', () => {
describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(정책 원장 C-100 · C-101 · S20)', () => {
const setPolicy = (patch: Record<string, unknown>) => db().doc('config/policy').set(patch, { merge: true });
const THIRD = 'test-planet-create-third';
afterEach(async () => {
+1 -1
View File
@@ -98,7 +98,7 @@ describe('참여형 번역 등록 (FR-I18N-02 · B-63)', () => {
expect(translationSettleOf({ ...base, valid: 3, invalid: 5, ageHours: 80 })).toBe('release');
});
it('보상 기본값은 원장 B-63 의 5 POP 이다', () => {
it('보상 기본값은 정책 원장 B-63 의 5 POP 이다', () => {
expect(DEFAULT_POLICY.translationPop).toBe(5);
});
+27 -7
View File
@@ -87,7 +87,11 @@ beforeEach(async () => {
}
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
await Promise.all(events.docs.map((d) => d.ref.delete()));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
await db().doc(`users/${UID}`).set({
uid: UID, fandomId: FANDOM, totalContributedPop: 0,
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02). 동의 자체는 아래 별도 시험이 본다.
consentState: { location: { granted: true, version: 'v1' } },
});
// 운영값은 문서로 둔다 — 코드에 박으면 회신마다 재배포다(설계서 §10).
await db().doc('config/policy').set({
attendPop: 10, stepsGoal: 10_000, stepsPop: 50, adRewardPop: 10, adMultiplier: 2, adDailyLimit: 5,
@@ -124,7 +128,7 @@ beforeEach(async () => {
});
describe('출석 — S-H-ATTEND (fn-quest-attend)', () => {
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다(리뷰 H2)', async () => {
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
expect(await codeOf(() => call(fnQuestAttend, { questId: 'no-such-quest' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestAttend, { questId: STEPS_QUEST }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
@@ -310,7 +314,7 @@ describe('만보기 — S-H-WALK (fn-quest-verify-steps · 정책 원장 B-26)',
});
});
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리뷰 재검토)', () => {
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다', () => {
it('걸음: 없는 퀘스트 · 걸음이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: 'no-such-walk', steps: 5_200 })))
.toBe('E-QST-01');
@@ -459,6 +463,22 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
const inRange = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 };
const farAway = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.4979, lng: 127.0276 };
it('위치기반 서비스 동의가 없으면 좌표를 처리하지 않고 E-PERM-01 (Q-E1 · R02)', async () => {
await db().doc(`users/${UID}`).update({ consentState: FieldValue.delete() });
expect(await codeOf(() => call(fnQuestVerifyPlace, inRange))).toBe('E-PERM-01');
expect((await db().collection(`users/${UID}/activityLogs`).get()).size).toBe(0);
// 철회 상태도 마찬가지다.
await db().doc(`users/${UID}`).update({ consentState: { location: { granted: false, version: 'v1' } } });
expect(await codeOf(() => call(fnQuestVerifyPlace, inRange))).toBe('E-PERM-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('예전 가입 화면에서 위치 약관에 동의한 사용자는 다시 묻지 않는다', async () => {
await db().doc(`users/${UID}`).update({ consentState: FieldValue.delete(), termsVersion: 'draft-1' });
await db().doc(`users/${UID}/consents/location_draft-1_grant`).set({ docType: 'location', granted: true });
expect((await call(fnQuestVerifyPlace, inRange)).rewarded).toBe(100);
});
it('반경 밖이면 거부하고 거리를 증거에 남긴다', async () => {
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...farAway, proofKind: 'qr', proof: 'tok-far' }),
@@ -514,7 +534,7 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
expect((await progress.get()).get('count')).toBe(2);
});
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(리뷰 B2 · C-16)', async () => {
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(C-16)', async () => {
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'no-such-quest' })))
.toBe('E-QST-01');
await db().doc('quests/place-elsewhere').set({ type: 'location', placeId: 'other-place' });
@@ -705,7 +725,7 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
// 후원할 금액을 만든다. 지갑만 올리지 않고 원장을 지난다 — 3자 대조가 깨지면
// 차감이 E-POP-02 로 막힌다.
await grantPop({
// 후원은 기여 POP 만 쓴다(원장 B-33) — 기여 잔액을 만든다.
// 후원은 기여 POP 만 쓴다(정책 원장 B-33) — 기여 잔액을 만든다.
uid: UID, popType: 'support', amount: 3_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${UID}`,
});
@@ -726,7 +746,7 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
expect(user.get('totalContributedPop')).toBe(3_000);
// EXP 는 원장이 움직일 때 함께 갱신된다 — 출석 보상 10 + 후원 3,000(C-18 단순안).
expect(user.get('tier')).toBeUndefined();
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다(리뷰 B1).
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다.
expect(user.get('userExp')).toBe(100);
expect(user.get('userLevel')).toBe(1);
const member = await db().doc(`fandoms/${FANDOM}/members/${UID}`).get();
@@ -745,7 +765,7 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
});
});
describe('진행 상태 — 일시 정지 · 이어하기 · 진행완료 (fn-quest-progress · 원장 C-83)', () => {
describe('진행 상태 — 일시 정지 · 이어하기 · 진행완료 (fn-quest-progress · 정책 원장 C-83)', () => {
const ref = () => db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`);
it('진행중 → 진행완료를 남기고, 일일 한도 · 완료 횟수는 건드리지 않는다', async () => {
@@ -48,6 +48,8 @@ const call = (fn: { run(request: never): unknown }, uid: string, data: Record<st
async function user(uid: string, fandom: string, ageHours = 48) {
await db().doc(`users/${uid}`).set({
fandomId: fandom, primaryFandomId: fandom, nickname: uid, status: 'active',
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02).
consentState: { location: { granted: true, version: 'v1' } },
createdAt: Timestamp.fromMillis(Date.now() - ageHours * HOUR),
});
}
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnSignupConsent } from '../src/account/account.js';
/** 가입 약관 동의 — 필수 3항 검사 · 항목별 동의 행 · 동의 시각 기록. */
const U = 'test-consent-user';
const db = () => getFirestore();
type CallReq = Parameters<typeof fnSignupConsent.run>[0];
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) { return (e as { code?: string }).code; }
};
const wipe = () => db().recursiveDelete(db().doc(`users/${U}`));
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(wipe);
describe('fnSignupConsent', () => {
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부하고 기록하지 않는다', async () => {
expect(await codeOf(fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }))))
.toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('필수만 동의 — 항목별 행 · 마케팅 기본 OFF · termsAgreedAt', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age'] }));
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.termsAgreedAt).toBeDefined();
expect(user.termsVersion).toBe('v1');
expect(user.notificationPrefs.marketing).toBe(false);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort();
expect(rows).toEqual(['age_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
});
it('마케팅 선택 동의는 알림 마케팅 구분과 같은 값이 된다', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age', 'marketing'] }));
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
expect((await db().doc(`users/${U}/consents/marketing_v1_grant`).get()).get('granted')).toBe(true);
});
it('로그인하지 않은 호출은 거부', async () => {
expect(await codeOf(fnSignupConsent.run({ data: { version: 'v1', agreed: [] } } as CallReq)))
.not.toBe('ok');
});
});
@@ -9,7 +9,7 @@ import { grantPop, reconcileUser, settlePendingGrant } from '../src/ledger/ledge
/**
* 참여형 번역 왕복 — 등록(보류 지급) → 마킹(자격 판정) → 24시간 뒤 정산(확정/해제).
* 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
* 정책 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
*/
const AUTHOR = 'test-trn-author';
+2 -2
View File
@@ -203,7 +203,7 @@ def main() -> None:
print("▸ 기능 스위치(config/features)")
put(token, "config/features", {"stationEnabled": False})
# 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값).
# 정책 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값).
# 서버 `DEFAULT_QUEST_CATALOG`(functions/src/quest/ugc.ts)와 같은 값이다. 운영 변경은 fn-admin-write.
print("▸ 퀘스트 분류 마스터(config/questCatalog)")
field = lambda f: [{"field": f, "required": True}] if f else []
@@ -409,7 +409,7 @@ def main() -> None:
# 활성이 된다. 발동 스케줄을 확인할 때 이 값을 켠다.
put(token, "config/eclipse", {
"enabled": False,
# 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[].
# 정책 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[].
"schedule": [{"hhmm": "07:50"}, {"hhmm": "17:50"}],
"erosionPercent": 0.25, # C-23 실패 감소율(%)
"warnSeconds": 180, # C-22 발생 3분 전 푸시
+2 -2
View File
@@ -38,7 +38,7 @@ service firebase.storage {
allow delete: if false;
}
// 캠페인 결과보고 집행 사진 · 영수증 — 원장 B-35 · FR-ADM-05 「결과보고는 Admin 수동 운영」.
// 캠페인 결과보고 집행 사진 · 영수증 — 정책 원장 B-35 · FR-ADM-05 「결과보고는 Admin 수동 운영」.
// 운영(master·ops)만 올린다. `fn-campaign-report-submit` 이 이 경로 접두와 파일 존재를
// 다시 본다. 결과보고는 공개 캠페인 상세에 뜨므로 읽기는 열어 둔다. 상한은 B-44 10MB.
match /campaignReports/{campaignId}/{fileName} {
@@ -69,7 +69,7 @@ service firebase.storage {
allow delete: if isSelf(uid);
}
// 위치 Quest 방문 인증 사진 — 원장 C-03 · PRD-05 「GPS · 사진 · QR 인증」. `fn-quest-verify-place` 가
// 위치 Quest 방문 인증 사진 — 정책 원장 C-03 · PRD-05 「GPS · 사진 · QR 인증」. `fn-quest-verify-place` 가
// 이 경로 접두(`questProofs/{본인}/{questId}/`)와 파일 존재를 다시 본다. 운영 검토용이라 본인 · 서버만 읽는다.
// 덮어쓰기 · 삭제 금지 — 제출한 증거가 바뀌면 안 된다. 상한은 B-44 이미지 10MB.
match /questProofs/{uid}/{questId}/{fileName} {
@@ -1785,7 +1785,7 @@ describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540
});
});
describe('Quest 진행 세션 — 일시 정지 · 진행완료(원장 C-83 · fn-quest-progress 전용)', () => {
describe('Quest 진행 세션 — 일시 정지 · 진행완료(정책 원장 C-83 · fn-quest-progress 전용)', () => {
test('본인도 진행 세션을 직접 쓰거나 지울 수 없다 — 함수로만', async () => {
await seedDocuments(testEnvironment, {
'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } },
@@ -126,6 +126,15 @@
"version" : "1.22.5"
}
},
{
"identity" : "line-sdk-ios-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/line/line-sdk-ios-swift.git",
"state" : {
"revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352",
"version" : "5.17.0"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
@@ -126,6 +126,15 @@
"version" : "1.22.5"
}
},
{
"identity" : "line-sdk-ios-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/line/line-sdk-ios-swift.git",
"state" : {
"revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352",
"version" : "5.17.0"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
+13
View File
@@ -301,6 +301,17 @@
<string>kakao$(FANUZU_KAKAO_NATIVE_APP_KEY)</string>
</array>
</dict>
<!-- LINE 로그인 복귀 스킴 line3rdp.{번들 ID}(flutter_line_sdk 공식 설정). -->
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>line-login</string>
<key>CFBundleURLSchemes</key>
<array>
<string>line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
<key>GMSApiKey</key>
@@ -309,6 +320,8 @@
<key>LSApplicationQueriesSchemes</key>
<array>
<string>kakaokompassauth</string>
<!-- LINE 앱으로 로그인 — LINE 설치 여부 확인·호출(flutter_line_sdk 공식 설정). -->
<string>lineauth2</string>
</array>
<key>UIApplicationSceneManifest</key>
+1 -1
View File
@@ -211,7 +211,7 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
Future<void> _startPush(PushMessaging push) async {
// 알림 권한은 여기서 묻지 않는다 — 온보딩 ⑥ 권한 안내가 쓰임을 설명한 뒤 묻는다
// (FR-CMN-08 · 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
// (FR-CMN-08 · 정책 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
// 사용자가 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
await push.syncToken();
+1 -1
View File
@@ -1,6 +1,6 @@
import 'backend.dart';
/// 내 초대 코드 — 공유 링크 `?ref=` 에 붙는다(원장 B-69 추천).
/// 내 초대 코드 — 공유 링크 `?ref=` 에 붙는다(정책 원장 B-69 추천).
///
/// 세션당 한 번 `fnInviteCodeGet` 을 부른다(없으면 서버가 만든다). 공유는 이 값을 기다리지
/// 않는다 — 아직 없거나 실패했으면 `?ref=` 없이 나간다. 계정이 바뀌면 다시 받는다.
+1 -1
View File
@@ -34,7 +34,7 @@ abstract interface class RewardedAdHandle {
/// Google Mobile Ads 보상형 광고를 불러온다.
///
/// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 —
/// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
/// 정책 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
///
/// [userId] · [customData] 는 **서버측 시청 검증(AdMob SSV)** 에 실린다 — Google 이 시청 완료를
/// 서버(`fn-ad-ssv`)로 직접 알릴 때 이 두 값이 「누가 · 어느 기본 보상(txId)에 붙여」 본 광고인지
+2 -2
View File
@@ -350,10 +350,10 @@ class PushNotice {
/// 라이브 띠 · Push 가 같은 사건을 가리키는 키 — Push `kind` 표기 + 팬덤.
String liveTickerKey(String kind, String fandomId) => '$kind:$fandomId';
/// 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤.
/// 정책 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤.
enum PushTier { crisis, collective, achievement, result, other }
/// 원장 C-78 단계 배정 — Push 와 알림함이 같은 표를 쓴다.
/// 정책 원장 C-78 단계 배정 — Push 와 알림함이 같은 표를 쓴다.
///
/// - 위기: 이클립스 사전 경고 · 발생
/// - 공동행동: TIME ATTACK 시작 — **임시값(해석 확인 Q-C-10 A안)**
+2 -2
View File
@@ -257,7 +257,7 @@ class AppShell extends StatelessWidget {
final StatefulNavigationShell shell;
final FeatureFlags flags;
/// 일시 정지 Quest 캡슐(원장 C-83)을 읽는 저장소. null 이면 캡슐이 없다.
/// 일시 정지 Quest 캡슐(정책 원장 C-83)을 읽는 저장소. null 이면 캡슐이 없다.
final QuestRepository? quests;
@override
@@ -271,7 +271,7 @@ class AppShell extends StatelessWidget {
return Scaffold(
body: shell,
// Figma 탭 줄은 좌우 16 을 비우고 칸을 나눈다(350:215853).
// 그 바로 위에 일시 정지 Quest 캡슐 한 줄(원장 C-83) — 없으면 높이 0.
// 그 바로 위에 일시 정지 Quest 캡슐 한 줄(정책 원장 C-83) — 없으면 높이 0.
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
+5
View File
@@ -18,8 +18,12 @@ class LivePlanet25D extends StatelessWidget {
this.glow = AppColors.neonViolet,
this.animate = true,
this.lowEffects = false,
this.spinBoost = false,
});
final VoidCallback? onOpenHistory;
/// [Planet25D.spinBoost].
final bool spinBoost;
final String fandomId;
final PlanetWatch watch;
final String image;
@@ -54,6 +58,7 @@ class LivePlanet25D extends StatelessWidget {
heroEventIds: state?.heroEventIds,
animate: animate,
lowEffects: lowEffects,
spinBoost: spinBoost,
),
);
}
+77 -23
View File
@@ -145,8 +145,13 @@ class Planet25D extends StatefulWidget {
this.onOpenHistory,
this.lowEffects = false,
this.animate = true,
this.spinBoost = false,
});
/// GIVE 10회 이상 연타 중 — Figma `2033:15818` 「자전속도 1.5 배」. 본체는 자전하지 않으므로(C-185)
/// 구름 · 궤도 · 링 먼지 · 펄스가 도는 주기를 1.5배 빠르게 한다.
final bool spinBoost;
/// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]).
final bool evolutionImminent;
@@ -173,7 +178,7 @@ class Planet25D extends StatefulWidget {
/// 이클립스 성공 회차. 바뀌면 광량 복원 뒤 짧은 Orbit Burst(v1.5 §5 「그림자 이탈 → 복원 → Orbit Burst」).
final String? eclipseSuccessId;
/// 사전 경고 창(서버 회차 `warnAt` → `startAt`, 원장 C-22 발생 3분 전). 이 사이에만
/// 사전 경고 창(서버 회차 `warnAt` → `startAt`, 정책 원장 C-22 발생 3분 전). 이 사이에만
/// v1.5 §5 「행성 뒤 어두운 그림자 접근, 별빛·Glow 점진 감소」를 그린다([eclipseApproach]).
final DateTime? eclipseWarnAt;
final DateTime? eclipseStartAt;
@@ -218,9 +223,12 @@ class _Planet25DState extends State<Planet25D>
// 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목).
? eclipseLight
: 1;
Duration get _cycle =>
Duration(milliseconds: (_period * 1000 / (widget.spinBoost ? 1.5 : 1)).round());
late final AnimationController _c = AnimationController(
vsync: this,
duration: Duration(seconds: _period),
duration: _cycle,
);
bool get _motionEnabled =>
@@ -245,8 +253,9 @@ class _Planet25DState extends State<Planet25D>
super.didUpdateWidget(old);
if (old.isHibernating != widget.isHibernating ||
old.isEclipsed != widget.isEclipsed ||
old.isHeroTime != widget.isHeroTime) {
_c.duration = Duration(seconds: _period);
old.isHeroTime != widget.isHeroTime ||
old.spinBoost != widget.spinBoost) {
_c.duration = _cycle;
if (_motionEnabled) _c.repeat();
}
_syncMotion();
@@ -463,6 +472,21 @@ class _Planet25DState extends State<Planet25D>
),
),
),
// Stage 6 — Figma `2024:20821` 「자기장(Field) 라인이 행성을 감싸며 외곽으로 방사됨」.
// 본체 뒤에 깔아 구면 바깥으로 뻗는 부분만 보이게 한다.
if (stage == 6)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
key: const ValueKey('planet-field-lines'),
painter: _FieldLinePainter(
t: t,
color: glow,
lowEffects: _lowEffects,
),
),
),
),
// 6. 링 — 뒤쪽 절반. 본체 뒤에 깔린다.
if (ring)
Positioned.fill(
@@ -893,31 +917,61 @@ class _OrbitPainter extends CustomPainter {
old.lowEffects != lowEffects;
}
/// Stage 6 자기장 — 양극을 잇는 고리를 좌우로 겹겹이 그리고, 밝은 띠가 안쪽에서 바깥 고리로
/// 번져 나간다(외곽 방사). 저사양은 고리 수만 줄인다.
class _FieldLinePainter extends CustomPainter {
const _FieldLinePainter({
required this.t,
required this.color,
required this.lowEffects,
});
final double t;
final Color color;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final c = size.center(Offset.zero);
final r = size.shortestSide * 0.42;
final loops = lowEffects ? 2 : 4;
// 방사 위치 0~1 — 한 주기에 네 번 안쪽에서 바깥으로.
final wave = (t * 4) % 1;
for (var i = 0; i < loops; i++) {
final k = (i + 1) / loops;
final w = r * (0.55 + 0.75 * k);
final h = r * (1.05 + 0.25 * k);
final glow = 1 - ((k - wave).abs() * 2.5).clamp(0.0, 1.0);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.0 + glow * 1.2
..color = Color.lerp(color, Colors.white, 0.35)!
.withValues(alpha: (0.14 + 0.36 * glow) * (1 - k * 0.4));
for (final side in const [-1.0, 1.0]) {
canvas.drawOval(
Rect.fromCenter(
center: c + Offset(side * w / 2, 0),
width: w,
height: h * 2,
),
paint,
);
}
}
}
@override
bool shouldRepaint(_FieldLinePainter old) =>
old.t != t || old.color != color || old.lowEffects != lowEffects;
}
/// Stage 6: 폭죽 대신 구면 주위에 머무는 넓고 일정한 대기광.
class _SustainedAura extends CustomPainter {
const _SustainedAura({required this.color, required this.lowEffects});
final Color color;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final c = size.center(Offset.zero);
final r = size.shortestSide * 0.82;
canvas.drawCircle(
c,
r,
Paint()
..shader = RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
const Color(0xFFC1B9FF).withValues(alpha: lowEffects ? 0.19 : 0.3),
color.withValues(alpha: lowEffects ? 0.1 : 0.17),
Colors.transparent,
],
stops: const [0, 0.50, 0.62, 0.76, 1],
).createShader(Rect.fromCircle(center: c, radius: r)),
);
}
void paint(Canvas canvas, Size size) =>
paintSurfaceAura(canvas, size, color, lowEffects: lowEffects);
@override
bool shouldRepaint(_SustainedAura old) =>
+29
View File
@@ -346,6 +346,8 @@ class _EvolutionPainter extends CustomPainter {
const color = AppColors.cosmicAccent;
final p = progress;
if (!evolution) {
// Figma `2024:20758` 「lv up 시 : 행성에 표면 아우라 발생」 — 진화 5% 전과 같은 표면 아우라가 떴다 사라진다.
paintSurfaceAura(canvas, size, color, lowEffects: lowEffects, opacity: 1 - p);
canvas.drawCircle(
c,
radius * (1 + p * 0.5),
@@ -482,6 +484,33 @@ class _HeroPainter extends CustomPainter {
/// Orbit Burst — 기울어진 궤도 고리가 바깥으로 퍼지고 입자가 흩어진다. [burst] 0 → 1.
/// HERO 발동의 Ring Wave 와 이클립스 성공(v1.5 §5 「짧은 Orbit Burst」)이 같이 쓴다.
/// 구면에 붙은 표면 아우라 — Stage 6 지속 아우라 · 진화 5% 전 · Level Up 이 같이 쓴다.
void paintSurfaceAura(
Canvas canvas,
Size size,
Color color, {
required bool lowEffects,
double opacity = 1,
}) {
final c = size.center(Offset.zero);
final r = size.shortestSide * 0.82;
canvas.drawCircle(
c,
r,
Paint()
..shader = RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
const Color(0xFFC1B9FF).withValues(alpha: (lowEffects ? 0.19 : 0.3) * opacity),
color.withValues(alpha: (lowEffects ? 0.1 : 0.17) * opacity),
Colors.transparent,
],
stops: const [0, 0.50, 0.62, 0.76, 1],
).createShader(Rect.fromCircle(center: c, radius: r)),
);
}
void paintOrbitBurst(
Canvas canvas,
Size size,
+5 -13
View File
@@ -133,19 +133,6 @@ abstract interface class AuthSession {
Future<void> signOut();
}
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
///
/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다.
/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이
/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false.
///
/// Figma 예시(앞부분 같으면 O)는 **2026-09-16 회신 3절 7항**(「다른 도메인 이메일의 앞부분만으로
/// 동일 소유자를 판단하지 않습니다」)이 대체했다. 이메일을 주지 않는 제공자의 연결은 해석 확인 Q-E8.
bool sameAccountIdentity(String a, String b) {
final x = a.trim().toLowerCase();
return x.contains('@') && x == b.trim().toLowerCase();
}
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
/// 이메일은 QA 내부 경로라 목록에 없다.
const List<AuthProviderKind> linkableProviders = [
@@ -159,6 +146,11 @@ const List<AuthProviderKind> linkableProviders = [
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
///
/// 통합검토회신 v2.3 **Q-E8**(C 안 보완) — 연결은 현재 계정의 유효한 인증 상태와 새 Provider 인증 자격을
/// **서버가** 모두 확인해 붙인다(Firebase Auth `linkWithCredential` · 카카오는 `fnAuthKakao`). 이메일 일치는
/// 소유 확인이 아니어서 비교하지 않는다 — 이메일을 주지 않는 카카오 · LINE 계정도 붙는다. 그 Provider 가
/// 다른 FANUZU UID 에 붙어 있으면 거부하고(E-AUTH-08) 자동 병합하지 않는다.
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
abstract interface class AccountLinker {
+3 -13
View File
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import '../../core/firebase/firebase_boot.dart';
import 'auth.dart';
import 'firebase_account_linker.dart';
import 'social_providers.dart';
import '../../core/i18n/app_locale.dart';
@@ -120,9 +119,7 @@ class AuthRepository extends ChangeNotifier {
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
///
/// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC
/// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를
/// 돌려준다.
/// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다.
static List<SocialAuthProvider> _defaultProviders() => [
const GoogleSignInProvider(),
// Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 —
@@ -138,15 +135,8 @@ class AuthRepository extends ChangeNotifier {
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱
// 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다.
KakaoSignInProvider(),
// LINE 동의 항목: profile·email(이메일 권한 신청 승인 후).
OidcSignInProvider(
AuthProviderKind.line,
providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!,
scopes: const ['profile', 'email'],
// LINE 앱 자동 로그인으로 넘어가면 콜백이 Custom Tab 밖에서 열려 로그인이
// 깨진다(Android). LINE Login 공식 파라미터로 끈다.
androidParameters: const {'disable_auto_login': 'true'},
),
// LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다).
LineSignInProvider(),
];
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
@@ -27,8 +27,8 @@ final class FirebaseAccountLinker implements AccountLinker {
AuthProviderKind.google: 'google.com',
AuthProviderKind.apple: 'apple.com',
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다.
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 두 제공자 모두 로그인을
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao · fnAuthLine)가 같은 ID 로 연결을 남긴다.
AuthProviderKind.kakao: 'oidc.kakao',
AuthProviderKind.line: 'oidc.line',
AuthProviderKind.email: 'password',
@@ -59,38 +59,28 @@ final class FirebaseAccountLinker implements AccountLinker {
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
if (impl is! CredentialProvider &&
impl is! OidcSignInProvider &&
impl is! KakaoSignInProvider) {
impl is! KakaoSignInProvider &&
impl is! LineSignInProvider) {
return AuthNotConfigured(provider, const ['자격증명 구현']);
}
try {
if (impl is KakaoSignInProvider) return await impl.link(user);
if (impl is LineSignInProvider) return await impl.link(user);
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
final source = impl as CredentialProvider;
final obtained = await source.obtainCredential();
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
if (obtained == null) return const AuthCancelled();
// 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다.
// 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다.
//
// ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가
// 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서
// 다시 보려면 Functions 층에 같은 판정을 둔다.
final mine = user.email;
final theirs = obtained.email;
if (mine != null &&
theirs != null &&
!sameAccountIdentity(mine, theirs)) {
return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail);
}
// 이메일을 비교하지 않는다(Q-E8). Firebase 가 지금 로그인 토큰과 새 자격증명을 서버에서 함께
// 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면 credential-already-in-use 로 거부한다.
final result = await user.linkWithCredential(obtained.credential);
return AuthSucceeded(_accountOf(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
if (isWebFlowCancelled(error)) return const AuthCancelled();
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.link),
authMessage(error, flow: AuthFlow.link),
);
} on Object catch (error) {
@@ -120,39 +110,20 @@ final class FirebaseAccountLinker implements AccountLinker {
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.link),
authMessage(error, flow: AuthFlow.link),
);
}
}
/// 카카오·LINE(OIDC) 연결.
///
/// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다.
/// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다.
/// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의
/// blocking 함수(beforeSignIn)로 서버에서 거른다.
/// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다(Q-E8).
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
final mine = user.email;
final result = await untilAbandoned(
user.linkWithProvider(impl.authProvider),
final result = await retryStaleWebContext(
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
);
// 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때).
final theirs = _emailOf(result.user, impl.providerId);
if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) {
await user.unlink(impl.providerId);
return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail);
}
return AuthSucceeded(_accountOf(result.user, impl.provider));
}
static String? _emailOf(fb.User? user, String providerId) {
for (final info in user?.providerData ?? const <fb.UserInfo>[]) {
if (info.providerId == providerId) return info.email;
}
return null;
}
SocialAuthProvider? _implOf(AuthProviderKind provider) {
for (final candidate in _providers) {
if (candidate.provider == provider) return candidate;
+24 -2
View File
@@ -215,6 +215,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
bool _busy = false;
Future<void> _signIn(SocialProvider provider) async {
if (_busy) return;
if (widget.signUp) {
final agreed = await Navigator.of(context).push<Set<String>>(
MaterialPageRoute(builder: (_) => const TermsScreen()),
@@ -223,6 +224,8 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
// 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다).
widget.auth.pendingConsent = agreed;
}
// 제공자 화면에서 돌아온 뒤에도 서버 토큰 교환 · 로그인에 몇 초 걸린다(Kakao · LINE).
// 끝날 때까지 화면 전체를 막고 진행 표시를 띄운다 — 다시 누르지 못하게.
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider, signUp: widget.signUp);
if (!mounted) return;
@@ -264,7 +267,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
for (final kind in _figmaOrder)
...widget.auth.providers.where((p) => p.provider == kind),
];
return Scaffold(
final scaffold = Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
actions: [
@@ -347,9 +350,26 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
),
),
);
return PopScope(
canPop: !_busy,
child: Stack(children: [scaffold, if (_busy) const BlockingProgress()]),
);
}
}
/// 진행 중 화면 전체를 막는 반투명 막 + 가운데 진행 표시.
class BlockingProgress extends StatelessWidget {
const BlockingProgress({super.key});
@override
Widget build(BuildContext context) => const Stack(
children: [
ModalBarrier(dismissible: false, color: Color(0x8A000000)),
Center(child: CircularProgressIndicator()),
],
);
}
const _figmaOrder = [
AuthProviderKind.apple,
AuthProviderKind.google,
@@ -371,10 +391,12 @@ String providerButtonLabel(
: t.authProviderLogin(kind.name);
}
/// 로그인 결과 안내. 취소 · 중단도 안내한다 — 1차 검수 R01 「취소 · 실패 시 로그인 성공으로 처리하지 않음 ·
/// 오류 안내와 정상 재시도 가능」. 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다.
String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
switch (outcome) {
AuthSucceeded() => null,
AuthCancelled() => null,
AuthCancelled() => (l10n ?? koL10n).authSocialCancelled,
AuthFailed(:final code, :final message) => '[$code] $message',
AuthNotConfigured(:final provider) =>
(l10n ?? koL10n).authNotConfiguredNotice(provider.shortName),
+74 -21
View File
@@ -2,17 +2,24 @@ import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
///
/// 필수 4항을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
/// 필수 항목을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 온보딩은 [onAgreed] 로 받는다.
/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다.
///
/// 필수 항목은 Figma 의 서비스 · 위치기반 · 만 14세에 개인정보 처리방침을 더했다(10/3 통합회신
/// 3절 4 「이용약관 · 개인정보처리방침 문구 제공」). 구성은 해석 확인 요청 E-Q1 회신 대기다.
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와
/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
/// 항목은 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02** 를 따른다(공통 기준 2항 — Figma 의
/// 과거 구성보다 확정 회신이 우선).
/// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상.
/// - 선택: 마케팅 수신(기본 OFF).
/// - **위치기반 서비스 약관은 여기서 받지 않는다** — Figma 에는 있으나 위치기능 최초 사용 직전에
/// 따로 받는다(`features/quest/location_consent.dart` 의 `ensureLocationConsent`).
/// - 개인정보처리방침은 **동의 항목이 아니라 고지**다 — 체크 칸 없이 하단 버튼 위에 안내와 보기 링크만 둔다.
///
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면
/// [termsPlaceholderBody] · l10n `terms*Body` 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
class TermsScreen extends StatefulWidget {
const TermsScreen({
super.key,
@@ -39,27 +46,33 @@ const signupTermsVersion = 'draft-1';
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
enum _Term {
service(required: true, body: true),
privacy(required: true, body: true),
location(required: true, body: true),
age(required: true, body: false),
marketing(required: false, body: true);
service(required: true),
privacy(required: true),
age(required: true),
marketing(required: false);
const _Term({required this.required, required this.body});
const _Term({required this.required});
final bool required;
final bool body;
String label(AppLocalizations l10n) => switch (this) {
service => l10n.termsService,
privacy => l10n.termsPrivacy,
location => l10n.termsLocation,
age => l10n.termsAge,
marketing => l10n.termsMarketing,
};
/// 항목 아래에 보이는 본문 — 항목마다 제목이 다르다. 만 14세는 본문 없이 체크 줄만 그린다.
String? body(AppLocalizations l10n) => switch (this) {
service => termsPlaceholderBody,
privacy => l10n.termsPrivacyBody,
age => null,
marketing => l10n.termsMarketingBody,
};
}
const _body =
/// 서비스 이용약관 본문 자리 표시 문구(시안 문구). 다른 약관 · 방침은 각자의 자리 표시 문구(l10n)를 쓴다.
const termsPlaceholderBody =
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
@@ -140,7 +153,43 @@ class _TermsScreenState extends State<TermsScreen> {
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: _termsButtons(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 개인정보처리방침 — 동의 항목과 구분한 고지(Q-E1). 체크 칸이 없고 「보기」로 본문을 연다.
// 목록 끝에 두면 iPhone 11 높이에서 하단 버튼 뒤로 가려지므로 버튼 위에 고정한다.
Padding(
key: const ValueKey('privacy-policy-notice'),
padding: const EdgeInsets.only(bottom: AppSpace.s2),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.termsPrivacyPolicyNotice,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.36,
color: AppColors.onCosmicMuted,
),
),
),
TextButton(
key: const ValueKey('privacy-policy-link'),
onPressed: () => showAlertDialog(
context,
context.l10n.termsPrivacyPolicy,
body: context.l10n.termsPrivacyPolicyBody,
),
child: Text(context.l10n.commonView),
),
],
),
),
_termsButtons(context),
],
),
),
),
body: ListView(
@@ -156,10 +205,12 @@ class _TermsScreenState extends State<TermsScreen> {
),
),
),
for (final t in _Term.values) ...[
for (final (t, body) in [
for (final t in _Term.values) (t, t.body(context.l10n)),
]) ...[
const SizedBox(height: AppSpace.s3),
_Box(
child: t.body
child: body != null
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -174,15 +225,17 @@ class _TermsScreenState extends State<TermsScreen> {
onTap: () => _toggle(t),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpace.s4),
Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpace.s4,
),
child: SizedBox(
height: 80,
child: ClipRect(
child: Text(
_body,
body,
overflow: TextOverflow.clip,
style: TextStyle(
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
+258 -55
View File
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:google_sign_in/google_sign_in.dart' as gsi;
import 'package:flutter_line_sdk/flutter_line_sdk.dart' as line;
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao;
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
@@ -26,12 +27,12 @@ abstract interface class CredentialProvider {
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
///
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
/// 쓰지 않는다(Q-E8 — 이메일 일치는 소유 확인이 아니다).
Future<SocialCredential?> obtainCredential();
}
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
///
/// [name] 은 제공자가 자격증명과 함께만 주는 표시 이름(Apple fullName — 최초 인가 때만). Firebase 가
/// 이 값을 계정 이름으로 옮기지 않아, 로그인 직후 우리가 계정에 써 둔다(온보딩 닉네임 기본값).
@@ -99,7 +100,7 @@ final class GoogleSignInProvider
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -165,13 +166,12 @@ final class AppleSignInProvider
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
),
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
email: credential.email,
name: [credential.givenName, credential.familyName]
.whereType<String>()
.where((part) => part.isNotEmpty)
.join(' '),
name: [
credential.givenName,
credential.familyName,
].whereType<String>().where((part) => part.isNotEmpty).join(' '),
);
}
@@ -192,7 +192,9 @@ final class AppleSignInProvider
obtained.credential,
);
final name = obtained.name;
if (name != null && name.isNotEmpty && (result.user?.displayName ?? '').isEmpty) {
if (name != null &&
name.isNotEmpty &&
(result.user?.displayName ?? '').isEmpty) {
await result.user?.updateDisplayName(name);
}
return AuthSucceeded(_toAccount(result.user, provider));
@@ -206,7 +208,7 @@ final class AppleSignInProvider
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -221,8 +223,9 @@ final class AppleSignInProvider
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이
/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀
/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
///
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
@@ -242,7 +245,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
this.provider, {
required this.providerId,
this.scopes = const [],
this.androidParameters = const {},
this.firebaseAuth,
});
@@ -257,12 +259,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
/// KOE205 로 로그인 자체를 막는다.
final List<String> scopes;
/// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로
/// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가
/// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다.
/// iOS 는 정상이라 건드리지 않는다.
final Map<String, String> androidParameters;
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
final fb.FirebaseAuth? firebaseAuth;
@@ -278,9 +274,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
for (final scope in scopes) {
p.addScope(scope);
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
p.setCustomParameters(androidParameters);
}
return p;
}
@@ -289,16 +282,18 @@ final class OidcSignInProvider implements SocialAuthProvider {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
final result = await untilAbandoned(
final result = await retryStaleWebContext(
() => untilAbandoned(
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
authProvider,
),
),
);
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
if (isWebFlowCancelled(error)) return const AuthCancelled();
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -389,7 +384,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
});
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다.
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다(Q-E8).
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
await _exchange({'accessToken': await _accessToken(), 'link': true});
await user.reload();
@@ -415,28 +411,20 @@ final class KakaoSignInProvider implements SocialAuthProvider {
}
// 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다 —
// 10/6 릴리스 빌드 E-AUTH-03 은 이 분기로 들어와 아무 로그도 남지 않았다.
debugPrint('[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}');
debugPrint(
'[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}',
);
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on kakao.KakaoException catch (error) {
debugPrint('[KakaoSignInProvider] sdk error: $error');
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on FirebaseFunctionsException catch (error) {
final details = error.details;
final code = details is Map && details['code'] is String
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'different-email' =>
appL10n.authDifferentEmail,
'E-AUTH-08' => appL10n.authCredentialInUse,
'E-AUTH-09' => appL10n.authAlreadyLinked,
_ =>
flow == AuthFlow.link
? appL10n.authLinkFailed
: appL10n.authSocialFailed,
});
return _serverFailure(error, flow);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
return AuthFailed(
authCatalogCode(error, flow: flow),
authMessage(error, flow: flow),
);
}
}
@@ -453,10 +441,195 @@ final class KakaoSignInProvider implements SocialAuthProvider {
}
}
/// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다.
AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) {
final details = error.details;
final code = details is Map && details['code'] is String
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'session' =>
appL10n.authRecentLogin,
'E-AUTH-08' => appL10n.authCredentialInUse,
'E-AUTH-09' => appL10n.authAlreadyLinked,
_ =>
flow == AuthFlow.link ? appL10n.authLinkFailed : appL10n.authSocialFailed,
});
}
/// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다.
const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID');
/// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다.
typedef LineTokens = ({String? idToken, String accessToken});
/// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다.
///
/// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다(10/11
/// iPhone 11 · iOS 26 — `disable_ios_auto_login` 을 붙여도 재현). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로
/// 로그인해 받은 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다.
///
/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고,
/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
///
/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에
/// 오고, 서버가 그때만 남긴다(C-143). 없어도 로그인 · 연결이 된다.
final class LineSignInProvider implements SocialAuthProvider {
LineSignInProvider({
Future<LineTokens> Function()? login,
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
exchange,
this.firebaseAuth,
this.channelId = lineChannelId,
}) : _login = login ?? _sdkLogin,
_exchange = exchange ?? _callServer;
final Future<LineTokens> Function() _login;
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
final fb.FirebaseAuth? firebaseAuth;
final String channelId;
static Future<void>? _setup;
/// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled).
static const _cancelCodes = {'CANCEL', '3003'};
@override
SocialProvider get provider => SocialProvider.line;
@override
bool get isLive => true;
@override
List<String> get missingConfig => [
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
if (channelId.isEmpty) 'LINE 채널 ID',
];
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
static Future<LineTokens> _sdkLogin() async {
await (_setup ??= line.LineSDK.instance.setup(lineChannelId));
final result = await line.LineSDK.instance.login(
scopes: const ['profile', 'openid', 'email'],
);
return (
idToken: result.accessToken.idTokenRaw,
accessToken: result.accessToken.value,
);
}
static Future<Map<String, dynamic>> _callServer(
Map<String, dynamic> payload,
) async {
final result = await FirebaseFunctions.instanceFor(
region: Backend.region,
).httpsCallable('fnAuthLine').call<Object?>(payload);
final data = result.data;
return data is Map ? Map<String, dynamic>.from(data) : const {};
}
Future<Map<String, dynamic>> _tokens() async {
final t = await _login();
return {
if (t.idToken != null) 'idToken': t.idToken,
'accessToken': t.accessToken,
};
}
@override
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
final data = await _exchange(await _tokens());
final result = await _auth.signInWithCustomToken(data['token'] as String);
return AuthSucceeded(_toAccount(result.user, provider));
});
/// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는
/// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다(Q-E8).
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
await _exchange({...await _tokens(), 'link': true});
await user.reload();
return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider));
});
Future<AuthOutcome> _run(
AuthFlow flow,
Future<AuthOutcome> Function() body,
) async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
return await body();
} on PlatformException catch (error) {
if (_cancelCodes.contains(error.code)) return const AuthCancelled();
debugPrint(
'[LineSignInProvider] sdk failed: ${error.code} ${error.message}',
);
return AuthFailed(
'E-AUTH-03',
flow == AuthFlow.link
? appL10n.authLinkFailed
: appL10n.authSocialFailed,
);
} on FirebaseFunctionsException catch (error) {
return _serverFailure(error, flow);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error, flow: flow),
authMessage(error, flow: flow),
);
}
}
@override
Future<void> signOut() async {
// SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다.
if (_setup == null) return;
try {
await line.LineSDK.instance.logout();
} on Object catch (error) {
// 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다.
debugPrint('[LineSignInProvider] logout: ${error.runtimeType}');
}
}
}
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
error.code == 'web-context-canceled' ||
error.code == 'web-context-cancelled';
error.code == 'web-context-cancelled' ||
error.code == 'user-cancelled';
const _authChannel = MethodChannel('kr.co.fanuzu.app/auth');
/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`).
///
/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을
/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(10/8 실기기 — 다시 눌러도 로그인이 안 됨).
/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다.
Future<void> clearStaleWebSignIn() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
try {
await _authChannel.invokeMethod<void>('cancelPendingWebSignIn');
} on Object catch (error) {
debugPrint('[Auth] clear stale web sign-in skipped: $error');
}
}
/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다.
/// 1차 검수 R01 「취소 · 실패 후 정상 재시도 가능」.
Future<T> retryStaleWebContext<T>(Future<T> Function() attempt) async {
try {
return await attempt();
} on fb.FirebaseAuthException catch (error) {
if (error.code != 'web-context-already-presented') rethrow;
await clearStaleWebSignIn();
return attempt();
}
}
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
///
@@ -481,6 +654,8 @@ Future<T> untilAbandoned<T>(
if (state != AppLifecycleState.resumed) return;
timer = Timer(grace, () {
if (done.isCompleted) return;
// Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다.
unawaited(clearStaleWebSignIn());
done.completeError(
fb.FirebaseAuthException(code: 'web-context-canceled'),
);
@@ -569,8 +744,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
return const AuthCancelled();
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authMessage(error, flow: AuthFlow.signIn),
authCatalogCode(error, flow: AuthFlow.email),
authMessage(error, flow: AuthFlow.email),
);
}
}
@@ -583,8 +758,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authMessage(error, flow: AuthFlow.signIn),
authCatalogCode(error, flow: AuthFlow.email),
authMessage(error, flow: AuthFlow.email),
);
}
}
@@ -599,7 +774,14 @@ final class EmailPasswordProvider implements EmailAuthProvider {
///
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
///
/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은
/// 소셜 실패 E-AUTH-03 이다. 예전에는 하나로 E-AUTH-01 이라 LINE 웹 로그인이 끊기면(`web-*`)
/// 「이메일 또는 비밀번호를 확인해 주세요」가 떴다(10/8 실기기 · 1차 검수 R01).
String authCatalogCode(
fb.FirebaseAuthException error, {
required AuthFlow flow,
}) => switch (error.code) {
'email-already-in-use' => 'E-AUTH-04',
'weak-password' => 'E-AUTH-05',
'invalid-email' => 'E-AUTH-06',
@@ -613,8 +795,13 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
'no-such-provider' => 'E-AUTH-10',
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
'operation-not-allowed' => 'E-AUTH-11',
'requires-recent-login' => 'E-AUTH-12',
_ => 'E-AUTH-01',
// 현재 계정의 세션이 만료 · 폐기됐다(Q-E8 「세션 만료 · 인증 취소」). 다시 로그인해야 한다.
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => 'E-AUTH-12',
_ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03',
};
/// 오류 문구가 갈리는 경로.
@@ -623,9 +810,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
enum AuthFlow {
/// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`).
/// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`).
signIn,
/// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다.
email,
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
link,
@@ -645,7 +835,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'invalid-email' => appL10n.authInvalidEmail,
'too-many-requests' => appL10n.commonTryLater,
'account-exists-with-different-credential' => switch (flow) {
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
AuthFlow.signIn ||
AuthFlow.email => _otherProviderSignInGuide(error.email),
AuthFlow.link => appL10n.authCredentialInUse,
},
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
@@ -653,8 +844,20 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'provider-already-linked' => appL10n.authAlreadyLinked,
'no-such-provider' => appL10n.authNotLinked,
'operation-not-allowed' => appL10n.authMethodUnavailable,
'requires-recent-login' => appL10n.authRecentLogin,
_ => appL10n.authCheckCredentials,
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => appL10n.authRecentLogin,
// 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다(R01).
'web-context-already-presented' => appL10n.authSocialCancelled,
'web-network-request-failed' ||
'network-request-failed' => appL10n.authSocialNetwork,
_ => switch (flow) {
AuthFlow.email => appL10n.authCheckCredentials,
AuthFlow.signIn => appL10n.authSocialFailed,
AuthFlow.link => appL10n.authLinkFailed,
},
};
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
+3 -3
View File
@@ -244,7 +244,7 @@ class CampaignDetail {
}
/// 결과보고 — 요구사항 FR-CMP-06 「집행 사진 · 영수증 · 예산 사용 내역 공개」.
/// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(원장 B-35).
/// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(정책 원장 B-35).
class CampaignReport {
const CampaignReport({
required this.body,
@@ -263,10 +263,10 @@ class CampaignReport {
int get budgetTotal => budget.fold(0, (sum, l) => sum + l.amount);
}
/// 사용자 캠페인 등록비 — 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다.
/// 사용자 캠페인 등록비 — 정책 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다.
const int campaignRegistrationFeePop = 100;
/// Campaign Product Catalog 상품 하나 — 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간).
/// Campaign Product Catalog 상품 하나 — 정책 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간).
class CampaignProduct {
const CampaignProduct(this.name, this.goalPop, this.recommendedDays);
+2 -2
View File
@@ -74,7 +74,7 @@ class EclipseState {
/// 없으면 이미 시작한 회차로 본다.
final DateTime? startAt;
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 원장 C-22 3분). 행성 그림자 접근의 출발점.
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 정책 원장 C-22 3분). 행성 그림자 접근의 출발점.
final DateTime? warnAt;
/// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다.
@@ -94,7 +94,7 @@ class EclipseState {
double get progress =>
targetDefense <= 0 ? 0 : (defense / targetDefense).clamp(0.0, 1.0);
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 D3) —
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다** —
/// 서버가 `ended` 로 닫기 전이라도 화면이 먼저 접는다.
bool isExpired(DateTime now) {
final at = endsAt;
@@ -187,7 +187,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
fandomName: data.strOr('fandomName', fandomId),
erosionRate: data.intOr('erosionRate', 100),
defense: shards.isEmpty ? data.intOr('qualifiedParticipants') : live.$1,
// 원장 C-25·C-26: 인정 횟수 이상인 고유 참여자 수 / DAU 기반 목표.
// 정책 원장 C-25·C-26: 인정 횟수 이상인 고유 참여자 수 / DAU 기반 목표.
targetDefense: data.intOr('target'),
qualifyShakes: data.intOr('qualifyShakes', 60),
defenseCount: shards.isEmpty ? data.intOr('defenseCount') : live.$2,
@@ -16,7 +16,7 @@ import '../../../core/i18n/app_locale.dart';
/// 배너는 세 상태를 그린다.
/// - 활성 이클립스 없음(또는 마감이 지남) → 아무것도 그리지 않는다(빈 [SizedBox]).
/// - 침식 진행 중 → 침식률 · 방어 진행률 · 남은 시간.
/// - 방어 성공(`defended`) → 회복 문구(원장 B-55 「방어 성공 시 침식분 회복」).
/// - 방어 성공(`defended`) → 회복 문구(정책 원장 B-55 「방어 성공 시 침식분 회복」).
///
/// 방어는 **공동 Shake 참여**이며 POP 을 소모하지 않는다. 눌러 들어가는 곳은
/// 「흔들어 방어」 화면이다.
@@ -10,7 +10,7 @@ import '../eclipse_defend_screen.dart';
///
/// 명세 `516:88125` *데이터: 이클립스 발생 잔여 시간 · 규칙: 성운(행성) 아래 중앙 정렬,
/// 어드민에서 설정된 이클립스 이벤트 발생 시간의 N분 전 노출 · 동작: 클릭 시 이클립스 상세로
/// 이동*. N분은 서버 회차의 `warnAt`(원장 C-22 발생 3분 전)이다 — 회차 문서가 그때부터
/// 이동*. N분은 서버 회차의 `warnAt`(정책 원장 C-22 발생 3분 전)이다 — 회차 문서가 그때부터
/// `scheduled` 로 있으므로 그 문서가 보이는 동안 띄운다. 발생 뒤 방어 창(60초) 동안은
/// 남은 초로 바뀌고, 결과가 나거나 창이 닫히면 접는다.
///
@@ -46,7 +46,7 @@ typedef TickerLine = ({String text, bool hero, String? push});
/// ponytail: 전역 하나. HOME 이 띠를 하나만 그리므로 충분하다 — 띠가 여러 곳에 생기면 집합으로.
String? liveTickerShowing;
/// 띠 한 줄 고르기 — 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다.
/// 띠 한 줄 고르기 — 정책 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다.
///
/// 1. 이클립스 발생 소식 — 그 뒤 이벤트(방어 참여 등)가 아직 없을 때.
/// 2. 방어 중 집약 — UX·FX v1.5 §1.1 「개인 이벤트를 연속 티커로 쌓기보다 집단 상태로 집약」 ·
+4 -4
View File
@@ -42,7 +42,7 @@ class PlanetNode {
/// 갤럭시 전체 순위. HUD 의 `Rank`.
///
/// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고
/// Stage0 성운은 랭킹 모집단이 아니다(정책 원장 B-08 · B-17). 성운에는 순위를 매기지 않고
/// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다.
/// 최근 7 일 유효 활동이 없는 정식 행성도 같다([ranked]).
final int rank;
@@ -65,7 +65,7 @@ class PlanetNode {
/// 내 행성. 명세 규칙 「행성 — 나의행성 한가운데」.
final bool isMine;
/// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(원장 B-21).
/// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(정책 원장 B-21).
final bool isCenter;
}
@@ -128,7 +128,7 @@ class GalaxySnapshot {
PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull;
/// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// Ranking View — 정책 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로
/// (B-17) 중심 후보에서 뺀다. 공동 1위면 목록 순서(C-19 고정 정렬키)의 첫 행성이다.
GalaxySnapshot get rankingView {
@@ -201,7 +201,7 @@ abstract interface class GalaxyRepository {
required String clientSeq,
});
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(정책 원장 C-15). 30일 대기 중이면
/// E-FDM-04 로 거부된다.
Future<void> setPrimary(String fandomId);
+4 -5
View File
@@ -98,7 +98,7 @@ class _GalaxyScreenState extends State<GalaxyScreen>
PlanetNode? _selected;
int _selectedIndex = 0;
/// Ranking View(원장 B-21). 끄면 기본 보기 — 내 행성 중심.
/// Ranking View(정책 원장 B-21). 끄면 기본 보기 — 내 행성 중심.
bool _ranking = false;
/// 보기를 바꾸면 **새 중심 행성으로 날아간다** — 「Ranking View 전환 시 FPI 상위 중심으로
@@ -297,7 +297,8 @@ class _GalaxyScreenState extends State<GalaxyScreen>
.clamp(0, 1 << 30)
: _selectedIndex,
balance: _zap.balance,
pending: _zap.pending,
// 보내는 중인 묶음까지 — 전송 중에도 연타가 쌓인다.
pending: _zap.queued,
sending: _zap.sending,
touchSerial: _zap.touchSerial,
zapResult: _zap.result,
@@ -671,9 +672,7 @@ class _Map extends StatelessWidget {
myPop: (balance ?? data.myPop) - pending * data.zapPop,
// **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는
// 「흔들어 방어」 화면에서 POP 없이 참여한다.
blockedReason: sending
? context.l10n.zapApplying
: retryZap != null
blockedReason: retryZap != null
? context.l10n.zapNeedsCheck
: data.mine == null
? context.l10n.zapNoPlanet
+37 -3
View File
@@ -17,9 +17,23 @@ class ZapController extends ChangeNotifier {
ZapResult? result;
Timer? _burst;
bool _disposed = false;
({String fandomId, int count, String key})? _batch;
({String fandomId, int count, String key, int streak})? _batch;
bool get needsRetry => _batch != null && !sending;
/// 아직 확정되지 않은 연타 — 보내는 중인 묶음 + 다음 묶음. 표시 잔액은 이만큼 미리 뺀다.
int get queued => pending + (sending ? _batch?.count ?? 0 : 0);
/// 이어서 누른 횟수 — Figma `2024:20758` 「0.5초간 [+1] 표시(연속 가능)」: [+1] 이 떠 있는
/// [streakWindow] 안에 다시 누르면 이어지고, 넘기면 1 부터 다시 센다.
int streak = 0;
Timer? _streakTimer;
/// 마지막으로 확정된 묶음의 끝 탭이 몇 번째 연속이었나 — 확정 결과 [+1] · [+10] 은 이것으로 정한다.
/// 확정 안 된 뒤 연타는 세지 않는다.
int confirmedStreak = 0;
String? _pendingFandom;
static const streakWindow = Duration(milliseconds: 500);
/// 지금 결과가 ZAP 이 아니라 퀘스트 보상 확정의 행성 반응인가([react]).
bool questReaction = false;
@@ -41,14 +55,25 @@ class ZapController extends ChangeNotifier {
notifyListeners();
}
/// 보내는 중에도 받는다 — 다음 묶음으로 쌓였다가 앞 묶음이 확정되면 보낸다. 실패한 묶음이
/// 있으면([needsRetry]) 같은 키로 다시 확인하기 전까지 받지 않는다.
void tap(String fandomId) {
if (_disposed || sending || _batch != null || fandomId.isEmpty) return;
if (_disposed || needsRetry || fandomId.isEmpty) return;
// 쌓인 연타는 한 행성 몫이다. 다른 행성 탭은 쌓인 묶음이 나간 뒤에 받는다.
if (pending > 0 && fandomId != _pendingFandom) return;
_pendingFandom = fandomId;
questReaction = false;
if (pending == 0) fxHaptic(FxIntensity.small);
streak = _streakTimer?.isActive == true ? streak + 1 : 1;
_streakTimer?.cancel();
_streakTimer = Timer(streakWindow, () {});
pending++;
touchSerial++;
notifyListeners();
_burst ??= Timer(const Duration(milliseconds: 450), () => _flush(fandomId));
_burst ??= Timer(const Duration(milliseconds: 450), () {
_burst = null;
_flush(_pendingFandom!);
});
}
void retry() {
@@ -56,6 +81,7 @@ class ZapController extends ChangeNotifier {
}
Future<void> _flush(String fandomId) async {
// 보내는 중이면 확정 뒤(finally)에 이어 보낸다.
if (_disposed || sending) return;
final batch =
_batch ??
@@ -63,6 +89,7 @@ class ZapController extends ChangeNotifier {
fandomId: fandomId,
count: pending,
key: 'z${DateTime.now().microsecondsSinceEpoch}',
streak: streak,
);
if (batch.count == 0) return;
_burst?.cancel();
@@ -80,10 +107,15 @@ class ZapController extends ChangeNotifier {
if (_disposed) return;
if (receipt.txId.isEmpty) throw StateError('Missing ZAP receipt');
if (receipt.balanceAfter >= 0) balance = receipt.balanceAfter;
confirmedStreak = batch.streak;
result = receipt;
_batch = null;
} catch (error) {
if (_disposed) return;
// 실패하면 뒤에 쌓인 연타는 보내지 않는다 — 실패 묶음을 같은 키로 다시 확인하는 것이 먼저다.
pending = 0;
_burst?.cancel();
_burst = null;
onFailure(switch (error) {
BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop,
BackendException(code: 'E-POP-02') => appL10n.zapFailLedger,
@@ -94,6 +126,7 @@ class ZapController extends ChangeNotifier {
if (!_disposed) {
sending = false;
notifyListeners();
if (_batch == null && pending > 0 && _burst == null) _flush(_pendingFandom!);
}
}
}
@@ -102,6 +135,7 @@ class ZapController extends ChangeNotifier {
void dispose() {
_disposed = true;
_burst?.cancel();
_streakTimer?.cancel();
super.dispose();
}
}
+4 -4
View File
@@ -100,7 +100,7 @@ class PlanetGrowth {
/// 「행성 성장률 N%」 · 「현재 / 목표」의 기준 — 두 조건 중 **진행률이 낮은 쪽(병목)**.
///
/// Figma 「홈」(`2097:16428` `Frame 1984081073`)은 성장률 하나 · 수치 한 쌍이고, 정책은
/// 두 조건 동시 충족이다(원장 B-11 · 성운은 POP + 창립 인원 B-10). 다음 진화는 늦은 쪽이
/// 두 조건 동시 충족이다(정책 원장 B-11 · 성운은 POP + 창립 인원 B-10). 다음 진화는 늦은 쪽이
/// 채워질 때 오므로 그 축을 보인다. 두 조건 상세는 말풍선을 누르면 연다.
/// ponytail: 해석 확인 전 임시 기준(HOME 정보위계 v1.0 §3 은 「단일 백분율로 합치지 않음」).
/// 값을 모르는 축은 건너뛰고, 둘 다 모르면 null.
@@ -185,7 +185,7 @@ class QuestSummary {
this.pausedSession,
});
/// 일시 정지 세션(원장 C-83). 있으면 보조 줄이 「카테고리 · 일시 정지 · n/m」.
/// 일시 정지 세션(정책 원장 C-83). 있으면 보조 줄이 「카테고리 · 일시 정지 · n/m」.
final QuestSession? pausedSession;
final String title;
@@ -464,7 +464,7 @@ class HomeWeekly {
/// 아이템(Hero Gift) 상품 한 줄 — 사본 `아이템 구입`(`252:160233`) 카드.
///
/// 구매하면 구매자에게 기여 POP, 팬덤 Drop Pool 에 같은 수량의 일반 POP 이 생긴다
/// (원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다.
/// (정책 원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다.
class ItemProduct {
const ItemProduct({
this.productId = '',
@@ -504,7 +504,7 @@ abstract interface class HomeRepository {
/// 보상)이 HOME 헤더에도 바로 반영된다.
Stream<int> watchPopBalance();
/// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
/// 성운(Stage0) 생성 — `fn-nebula-create`(정책 원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
/// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다.
Future<({String fandomId, bool primaryChanged})> createNebula(String name);
@@ -158,7 +158,7 @@ class FirestoreHomeRepository implements HomeRepository {
.limit(30)
.get();
final blocksF = db.collection('users/$uid/blocks').get();
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
// Hero Gift Catalog(정책 원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
// 결제 창을 열기 전에 막는다(`fn-iap-verify` 도 거부한다).
final itemsF = db
.collection('heroGiftProducts')
@@ -13,7 +13,7 @@ import '../../core/i18n/app_locale.dart';
/// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다.
/// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식
/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정
/// 표시를 둔다. 근거는 발주사 10/3 통합회신(원장 C-112) 「성운 생성은 10 명 + 1,000 POP +
/// 표시를 둔다. 근거는 발주사 10/3 통합회신(정책 원장 C-112) 「성운 생성은 10 명 + 1,000 POP +
/// 기본 목표 7 일, 사용자 목표 POP 조작 없음」 — Figma `516:87407` 목표 POP · 창립멤버 핸들러를
/// 이 회신이 대체한다. B-19 — 진화 참여자에게는
/// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다.
@@ -14,7 +14,7 @@ import '../../core/i18n/app_locale.dart';
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
/// 직접 색 고르기(스포이트) 칩은 두지 않는다. 사용자화(컬러 팔레트)를 빼는 근거는 발주사
/// 10/3 통합회신 #6(원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
/// 10/3 통합회신 #6(정책 원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
/// `2066:17910` 의 「사용자화」를 이 회신이 대체한다. 「오브젝트 색」 줄은 Q-A14 회신 대기.
///
/// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14).
+157 -25
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import '../../../core/ui/planet_fx_scope.dart';
import '../../galaxy/galaxy_data.dart';
import '../../galaxy/zap_controller.dart';
@@ -225,9 +226,14 @@ class _HomeHeroState extends State<HomeHero> {
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: _TapShake(
child: _PlanetReaction(
onTap: widget.onTapPlanet,
child: LivePlanet25D(
zap: hasZap ? widget.zap : null,
planetSize: math.min(
planet,
box.maxHeight,
),
builder: (spinBoost) => LivePlanet25D(
onOpenHistory:
widget.onOpenHistory,
key: _planetKey,
@@ -243,6 +249,7 @@ class _HomeHeroState extends State<HomeHero> {
box.maxHeight,
),
glow: AppColors.neonViolet,
spinBoost: spinBoost,
),
),
),
@@ -755,9 +762,10 @@ class _Nebula extends StatelessWidget {
label: context.l10n.heroNebula,
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
child: _TapShake(
child: _PlanetReaction(
onTap: ready && onBirth != null ? onBirth : () {},
child: Image.asset(
planetSize: 212,
builder: (_) => Image.asset(
'assets/figma/nebula-home.png',
width: 212,
height: 212,
@@ -1086,16 +1094,13 @@ class HomeZap extends StatelessWidget {
showLabel: false,
onItem: onItem,
zapPop: data.zapPop,
myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop,
myPop: (zap.balance ?? data.myPop) - zap.queued * data.zapPop,
// Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 ·
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
isHeroTime: hero,
blockedReason: zap.sending
? context.l10n.zapApplying
: zap.needsRetry
? context.l10n.zapNeedsCheck
: null,
// 보내는 중에도 누를 수 있다 — 연타는 다음 묶음으로 쌓인다(Figma `2033:15818` 10회 이상 연타).
blockedReason: zap.needsRetry ? context.l10n.zapNeedsCheck : null,
onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId),
),
],
@@ -1104,54 +1109,181 @@ class HomeZap extends StatelessWidget {
);
}
/// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic.
/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다.
class _TapShake extends StatefulWidget {
const _TapShake({required this.child, this.onTap});
/// 행성 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · `2033:15818` 행성 리액션 1.
///
/// - 행성 터치: Light Haptic · 흔들림 1번이 끝나면 [onTap](커뮤니티 이동).
/// - GIVE 입력 반응(즉시): 누를 때마다 흔들림 1번, 이어서 10회 이상이면 흔들림 x2 · 자전 1.5배
/// ([Planet25D.spinBoost]).
/// - GIVE 결과(서버 확정 뒤, v1.5 §1.2 · §3): 행성 우하단 [+1] 0.5초(연속 가능), 10회 이상 이어진
/// 연타의 확정이면 [+10].
class _PlanetReaction extends StatefulWidget {
const _PlanetReaction({
required this.builder,
required this.planetSize,
this.onTap,
this.zap,
});
final Widget child;
final Widget Function(bool spinBoost) builder;
final double planetSize;
final VoidCallback? onTap;
final ZapController? zap;
/// 「10 회 이상 터치시」.
static const boostStreak = 10;
/// 흔들림 한 번 길이.
static const shakeOnce = Duration(milliseconds: 320);
@override
State<_TapShake> createState() => _TapShakeState();
State<_PlanetReaction> createState() => _PlanetReactionState();
}
class _TapShakeState extends State<_TapShake>
class _PlanetReactionState extends State<_PlanetReaction>
with SingleTickerProviderStateMixin {
late final _shake = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
duration: _PlanetReaction.shakeOnce,
);
int _shakes = 1;
int? _seenSerial;
int _streak = 0;
Timer? _countTimer;
String? _seenTx;
/// 확정 결과 표시 — 「+1」 · 「+10」. null 이면 숨김.
String? _count;
Timer? _resultTimer;
@override
void initState() {
super.initState();
_seenSerial = widget.zap?.touchSerial;
_seenTx = widget.zap?.result?.txId;
widget.zap?.addListener(_onZap);
}
@override
void didUpdateWidget(_PlanetReaction old) {
super.didUpdateWidget(old);
if (old.zap != widget.zap) {
old.zap?.removeListener(_onZap);
_seenSerial = widget.zap?.touchSerial;
_seenTx = widget.zap?.result?.txId;
widget.zap?.addListener(_onZap);
}
}
@override
void dispose() {
widget.zap?.removeListener(_onZap);
_countTimer?.cancel();
_resultTimer?.cancel();
_shake.dispose();
super.dispose();
}
void _onZap() {
final zap = widget.zap!;
final tx = zap.result?.txId;
if (tx != null && tx.isNotEmpty && tx != _seenTx) {
_seenTx = tx;
if (!zap.questReaction) {
setState(() => _count =
zap.confirmedStreak >= _PlanetReaction.boostStreak ? '+10' : '+1');
_resultTimer?.cancel();
_resultTimer = Timer(ZapController.streakWindow, () {
if (mounted) setState(() => _count = null);
});
}
}
if (zap.touchSerial == _seenSerial) return;
_seenSerial = zap.touchSerial;
if (zap.questReaction) return;
setState(() => _streak = zap.streak);
_countTimer?.cancel();
_countTimer = Timer(ZapController.streakWindow, () {
if (mounted) setState(() => _streak = 0);
});
_play(_streak >= _PlanetReaction.boostStreak ? 2 : 1);
}
TickerFuture? _play(int shakes) {
// 동작 줄이기면 흔들지 않는다 — 숫자 표시와 이동은 그대로.
if (MediaQuery.disableAnimationsOf(context)) return null;
_shakes = shakes;
_shake.duration = _PlanetReaction.shakeOnce * shakes;
return _shake.forward(from: 0);
}
bool _opening = false;
/// 흔들림 한 번을 보여 준 뒤 이동한다. 그사이 GIVE 가 흔들림을 다시 시작해도 이동은 그대로 간다.
Future<void> _tap() async {
final onTap = widget.onTap;
if (onTap == null || _shake.isAnimating) return;
if (onTap == null || _opening) return;
_opening = true;
fxHaptic(FxIntensity.micro);
if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0);
if (_play(1) != null) await Future<void>.delayed(_PlanetReaction.shakeOnce);
_opening = false;
if (mounted) onTap();
}
@override
Widget build(BuildContext context) => GestureDetector(
Widget build(BuildContext context) {
final boost = _streak >= _PlanetReaction.boostStreak;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onTap == null ? null : _tap,
child: AnimatedBuilder(
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _shake,
// 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠.
// 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. 한 번에 [_shakes] 왕복.
builder: (context, child) => Transform.translate(
key: const ValueKey('planet-give-shake'),
offset: Offset(
math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value),
math.sin(_shake.value * math.pi * 4 * _shakes) *
6 *
(1 - _shake.value),
0,
),
child: child,
),
child: widget.child,
child: widget.builder(boost),
),
if (_count case final count?)
// 행성 우측 하단 — 행성 그림 상자([planetSize]) 안 오른쪽 아래. x 0.6 은 360 폭에서도 [+10] 이
// 오른쪽 GALAXY 미니맵에 가려지지 않는 자리다(10/22 iPhone 캡처에서 0.85 는 끝자리가 가려졌다).
Positioned.fill(
child: IgnorePointer(
child: Center(
child: SizedBox.square(
dimension: widget.planetSize,
child: Align(
alignment: const Alignment(0.6, 0.85),
child: Text(
count,
key: const ValueKey('planet-give-count'),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.onCosmic,
shadows: [
Shadow(color: AppColors.neonViolet, blurRadius: 8),
],
),
),
),
),
),
),
),
],
),
);
}
}
@@ -13,7 +13,7 @@ import '../../../core/i18n/app_locale.dart';
/// 「아이템 선택」(ZAP 옵션 「아이템」 · `343:204868`).
///
/// 두 시트는 제목과 카드 아랫줄만 다르다 — 구입은 「보유 : N」 + 가격, 선택은 「보유 : N개」.
/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(원장 B-84)이고
/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(정책 원장 B-84)이고
/// 결과는 앱 전역 알림으로 온다([Store.messages]). 사본의 결제 시트(「앱 내 구입」)는 OS 결제
/// 창이라 앱이 그리지 않는다. 구매가 곧 발동이라 보유 재고가 없어 「선택」(사용)은 준비 중이다.
Future<void> showItemSheet(
+2 -2
View File
@@ -44,7 +44,7 @@ class QuestRows extends StatelessWidget {
///
/// 보조 줄은 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」)다. 「홈」 프레임에는 보상 숫자 ·
/// 진행 상태가 없어 그리지 않는다. 타임어택 남은 시간도 그리지 않는다 — 설명 프레임
/// `2097:16397` 데이터는 「어드민에서 등록된 퀘스트」뿐이고, FR-QST-06 · 원장 C-94 의 카운트다운
/// `2097:16397` 데이터는 「어드민에서 등록된 퀘스트」뿐이고, FR-QST-06 · 정책 원장 C-94 의 카운트다운
/// 표시 위치는 퀘스트 목록 · 상세다.
class QuestRow extends StatelessWidget {
const QuestRow({
@@ -98,7 +98,7 @@ class QuestRow extends StatelessWidget {
),
),
const SizedBox(height: 4),
// 진행중 탭 카드와 같은 일시 정지 표기(원장 C-83).
// 진행중 탭 카드와 같은 일시 정지 표기(정책 원장 C-83).
_sub(questSubLine(quest.category ?? '', quest.pausedSession)),
],
),
+1 -1
View File
@@ -81,7 +81,7 @@ class InsideQuestion {
/// 답변이 채택되어 현상금이 지급된 상태.
final bool isSolved;
/// 채택 기한(원장 B-39 · 7일)이 지난 뒤의 환불 단계.
/// 채택 기한(정책 원장 B-39 · 7일)이 지난 뒤의 환불 단계.
final InsideRefund refund;
/// 채택을 받을 수 있는 상태 — 채택도 만료도 아니다.
+3 -5
View File
@@ -14,9 +14,9 @@ import '../../core/ui/bottom_inset.dart';
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// X(트위터)는 확정 4종에 없어 목록에서 빠졌다([linkableProviders]).
///
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영 · **동일 이메일만 연동 가능***
/// 판정은 [sameAccountIdentity] 가 하고, 실제 연결은 [AccountLinker] 구현체
/// (Firebase `linkWithCredential`)가 한다.
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영*. 명세의 「동일 이메일만 연동 가능」은 통합검토회신
/// v2.3 **Q-E8** 이 대체했다(이메일 일치는 소유 확인이 아니다). 연결 검증은 [AccountLinker] 구현체
/// (Firebase `linkWithCredential` · 카카오 `fnAuthKakao`)가 서버에서 한다.
class AccountLinkScreen extends StatefulWidget {
const AccountLinkScreen({
super.key,
@@ -68,8 +68,6 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
],
),
),
const SizedBox(height: AppSpace.s4),
Text(context.l10n.linkSameEmailOnly, style: t.labelSmall),
const SizedBox(height: AppSpace.s5),
FilledButton(
onPressed: _busy ? null : _toggle,
+9 -2
View File
@@ -13,6 +13,7 @@ import 'dart:typed_data';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
import '../quest/location_consent.dart';
/// POP 종류 — 정책 원장 **B-01**.
///
@@ -523,7 +524,7 @@ PaymentState paymentStateOf(String state) => switch (state) {
_ => PaymentState.paid,
};
/// Focus 구독 — 원장 **B-04** *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음*.
/// Focus 구독 — 정책 원장 **B-04** *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음*.
class FocusStatus {
const FocusStatus({required this.expiresAt, required this.claimedToday});
@@ -775,6 +776,9 @@ void checkProfilePhoto(PickedPhoto photo) {
}
abstract interface class MyRepository {
/// 위치기반 서비스 약관 동의 상태 — 설정의 동의 · 철회 토글(Q-E1 · R02).
LocationConsent get locationConsent;
Future<MyProfile> profile();
/// 명세 — *디폴트 최신순으로 20개 노출*.
@@ -792,7 +796,7 @@ abstract interface class MyRepository {
/// Focus 구독 상태. 구독 중(만료 전)이 아니면 null — MY 에 카드를 그리지 않는다.
Future<FocusStatus?> focus();
/// Focus 오늘 몫 받기(원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03.
/// Focus 오늘 몫 받기(정책 원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03.
Future<int> claimFocusDaily();
Future<Map<PushTopic, bool>> pushSettings();
@@ -836,6 +840,9 @@ abstract interface class MyRepository {
class SampleMyRepository implements MyRepository {
SampleMyRepository();
@override
LocationConsent get locationConsent => SampleLocationConsent.shared;
@override
Future<void> withdraw() async {}
@@ -6,6 +6,7 @@ import 'package:firebase_storage/firebase_storage.dart';
import '../../core/data/backend.dart';
import '../home/home_repository_firestore.dart' show kstDayKey;
import 'my_data.dart';
import '../quest/location_consent.dart';
import '../../core/i18n/app_locale.dart';
/// MY 의 실 서버 구현 — 화면 대조 원장 F6.
@@ -42,6 +43,9 @@ import '../../core/i18n/app_locale.dart';
class FirestoreMyRepository extends SampleMyRepository {
FirestoreMyRepository(this._backend);
@override
late final LocationConsent locationConsent = FirestoreLocationConsent(_backend);
final Backend _backend;
@override
+2 -2
View File
@@ -102,7 +102,7 @@ class _MyScreenState extends State<MyScreen> {
}
}
/// Focus 오늘 몫 받기(원장 B-04). 지급은 서버가 하고 화면은 결과만 알린다.
/// Focus 오늘 몫 받기(정책 원장 B-04). 지급은 서버가 하고 화면은 결과만 알린다.
Future<void> _claimFocus() async {
if (_claiming) return;
setState(() => _claiming = true);
@@ -501,7 +501,7 @@ class _MyHeader extends StatelessWidget {
}
/// 활동 탭의 한 줄 — 글자 16/600 · 끝 chevron. 줄 높이 24 · 간격 24.
/// Focus 구독 혜택 카드 — 원장 B-04 *일 40 기여 POP · 당일 Claim · 미수령 누적 없음*.
/// Focus 구독 혜택 카드 — 정책 원장 B-04 *일 40 기여 POP · 당일 Claim · 미수령 누적 없음*.
///
/// Figma 에는 이 Claim 자리가 없다(퀘스트 완료 화면의 「광고 제거 보상 · 퀘스트 보상 × 2」
/// `518:88748` 은 B-04 가 대체한 과거 안). 반영 화면이 `MY` 로 정해져 있어 통계 카드 아래에 둔다.
+1 -1
View File
@@ -12,7 +12,7 @@ import '../../core/ui/bottom_inset.dart';
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
///
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 원장 B-52)** 이 대체했다 —
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 정책 원장 B-52)** 이 대체했다 —
/// 중복을 이유로 막지 않는다. 헬프 텍스트 자리는 길이 · 예약어 오류에 쓴다.
///
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
+75 -4
View File
@@ -12,6 +12,7 @@ import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
import '../home/home_screen.dart' show formatPop;
import '../quest/location_consent.dart';
import 'my_data.dart';
import 'notice_screens.dart';
import 'payment_screens.dart';
@@ -24,7 +25,7 @@ import '../../core/ui/bottom_inset.dart';
/// 설정 — 화면 대조 원장 F6 · Figma `354:222008`.
///
/// 명세 `554:22634` — 계정 정보(닉네임 · 가입 이메일)
/// 명세 `554:22797` — SNS 연동. *동일 이메일만 연동 가능*. 행의 「연결」에서 바로 제공자
/// 명세 `554:22797` — SNS 연동. 행의 「연결」에서 바로 제공자
/// 시트를 띄운다(시안 F6-05~07 · 대표 결정 — 별도 연동 화면을 거치지 않는다).
/// 명세 `554:22831` — 푸시 알림 · 차단 · 언어
/// 명세 `554:22857` — 공지사항 · 1:1 문의접수 · 앱 버전
@@ -65,6 +66,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Map<AuthProviderKind, String?> _linked = const {};
({String current, bool latest})? _version;
/// 위치기반 서비스 약관 동의 상태(Q-E1 · R02). 못 읽었으면 null — 토글을 그리지 않는다.
bool? _locationConsent;
@override
void initState() {
super.initState();
@@ -74,8 +78,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _load() async {
final profile = await widget.repository.profile();
final version = await widget.repository.appVersion();
bool? location;
try {
location = await widget.repository.locationConsent.granted();
} catch (_) {
location = null;
}
if (!mounted) return;
setState(() {
_locationConsent = location;
_profile = profile;
_linked = widget.linker.linkedAccounts;
_version = version;
@@ -166,6 +177,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
down: true,
onTap: _pickLanguage,
),
if (_locationConsent case final on?)
_SwitchRow(
label: l10n.settingsLocationConsent,
value: on,
onChanged: _setLocationConsent,
),
],
),
MyGroup(
@@ -254,7 +271,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// 설정 행에서 바로 연결 · 해제한다(시안 F6-05~07 — 설정 위에 OS 제공자 시트가 뜬다).
///
/// 연결은 제공자 시트로 곧장 가고, 해제는 확인 팝업(시안 `554:23257`)을 거친다.
/// 이메일이 다르면(E-AUTH-08 · [sameAccountIdentity]) 시안 `554:23103` 안내 팝업.
/// 그 소셜 계정이 다른 FANUZU 계정에 이미 붙어 있으면(E-AUTH-08) 시안 `554:23103` 안내 팝업.
/// 명세의 「동일 이메일만 연동 가능」은 통합검토회신 v2.3 **Q-E8** 이 대체했다 — 이메일은 소유 확인이 아니다.
Future<void> _toggleLink(AuthProviderKind provider) async {
final connected = _linked.containsKey(provider);
if (connected) {
@@ -274,8 +292,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
setState(() => _linked = widget.linker.linkedAccounts);
switch (outcome) {
case AuthFailed(code: 'E-AUTH-08'):
await _notice(context.l10n.settingsSameEmailOnly);
case AuthFailed(code: 'E-AUTH-08', :final message):
await _notice(message);
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured():
@@ -335,6 +353,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
await LocaleScope.maybeOf(context)?.select(picked);
}
/// 위치기반 서비스 약관 동의 · 철회(Q-E1 · R02). 켜면 동의 창을 거쳐 기록하고, 끄면 철회 행을 남긴다.
/// Figma 설정 시안에 없는 줄이다 — 동의를 철회할 수단이 있어야 해 언어 아래에 토글 하나를 둔다.
Future<void> _setLocationConsent(bool on) async {
final consent = widget.repository.locationConsent;
var now = on;
if (on) {
now = await ensureLocationConsent(context, consent);
} else {
try {
await consent.record(false);
} catch (_) {
now = true;
if (mounted) _toast(context.l10n.commonRequestFailed);
}
}
if (mounted) setState(() => _locationConsent = now);
}
/// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 ·
/// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다.
/// 서버도 같은 조건을 다시 본다(E-AUTH-14).
@@ -400,6 +436,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// 켜고 끄는 줄 — 푸시 알림 설정 화면의 토글과 같은 모양.
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => SizedBox(
height: 24,
child: Row(
children: [
Expanded(
child: Text(label, style: myText(16, FontWeight.w600, height: 1)),
),
Switch(
value: value,
activeThumbColor: AppColors.onCosmic,
activeTrackColor: AppColors.cosmicAccent,
inactiveThumbColor: const Color(0xFF3A3A3A),
inactiveTrackColor: const Color(0xFF2C2C2C),
trackOutlineColor: const WidgetStatePropertyAll(Colors.transparent),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: onChanged,
),
],
),
);
}
/// 「닉네임 · BTS_Hong」 — 둘 다 16/600, 값은 #bbbbbb.
class _ValueRow extends StatelessWidget {
const _ValueRow({required this.label, required this.value});
@@ -117,7 +117,7 @@ class AppNotification {
/// 이클립스 항목의 단계 — `start` · `defended`.
final String phase;
/// 원장 C-78 단계 — 앱 안 Push 와 같은 표([pushTierOf]). 알림함은 이 단계로 시각 위계를 준다(v1.5 8장).
/// 정책 원장 C-78 단계 — 앱 안 Push 와 같은 표([pushTierOf]). 알림함은 이 단계로 시각 위계를 준다(v1.5 8장).
PushTier get tier => pushTierOf(
eventType ??
switch (kind) {
@@ -229,7 +229,7 @@ TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
),
};
/// 원장 C-78 위계 — 위기 · 공동행동 · 성취 · 결과(v1.5 8장 「알림 우선순위 표현」). Figma 는 배지 획이 모두
/// 정책 원장 C-78 위계 — 위기 · 공동행동 · 성취 · 결과(v1.5 8장 「알림 우선순위 표현」). Figma 는 배지 획이 모두
/// #333333 이라 결과 · 그 밖은 그대로 두고, 위 세 단계만 획 색으로 올린다.
/// 색은 **임시값** — 자료에 위계별 색 정의가 없어 기존 토큰(위기 = 경고 적색 · 공동행동 = 강조 · 성취 = 금)을 쓴다.
Color _tierRing(PushTier tier) => switch (tier) {
+2 -2
View File
@@ -308,7 +308,7 @@ class PlanetDetail {
/// hide」 · `543:19721` 「(타인 행성) 신고/차단 · (내 행성) 편집」 — 편집은 C-50 으로 두지 않는다.
final bool isOwner;
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(정책 원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
final int nebulaTargetPop;
final int nebulaTargetMembers;
@@ -319,7 +319,7 @@ class PlanetDetail {
/// 「가입중 / 가입하기」 알약이 이 값을 그린다(명세 §2.72 「가입 버튼 - 소속 상태」).
final bool joined;
/// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 원장 B-15). 없으면 null.
/// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 정책 원장 B-15). 없으면 null.
final DateTime? fpiUpdatedAt;
/// 개설자 표시명 · 사진. `fandoms` 문서에 아직 자리가 없어 서버 상세는 null 이고,
@@ -214,7 +214,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
);
/// Figma `543:19721` — 더보기: (타인 행성) 신고/차단 · (내 행성) 편집. 편집은 행성 사용자 커스텀이라
/// 두지 않는다(원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
/// 두지 않는다(정책 원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
void _showMore(BuildContext context, String name, {required bool mine}) =>
showActionSheet(context, [
// 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다.
@@ -466,7 +466,7 @@ class _FpiTab extends StatelessWidget {
children: [
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다.
// TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다
// (원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
// (정책 원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
EclipseBanner(
repository: eclipse,
fandomId: detail.fandomId,
@@ -544,7 +544,7 @@ class _FpiTab extends StatelessWidget {
///
/// 「기여 현황」 카드(#1a1a1c r12 · 안쪽 12/16) — 성운 성장률 · 진행 막대 · 0 ~ 목표,
/// 「실시간 기여 랭킹」 — 1위 왕관 · 빈 원 80 · 「아직 기여자가 없어요」.
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
/// 목표는 정책 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
class _NebulaFpi extends StatelessWidget {
const _NebulaFpi({
@@ -0,0 +1,146 @@
/// 위치기반 서비스 약관 — 위치기능 **최초 사용 직전**에 받는 동의.
///
/// 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02** — 가입 때 위치기반 약관을
/// 일괄 필수로 받지 않는다. 위치 약관 동의와 OS 위치권한은 위치기능을 처음 쓰기 전에 **각각** 처리하고,
/// 거부해도 일반 기능은 막지 않는다. 그래서 이 동의는 위치 Quest 인증 · 장소 선택 지도의 현재 위치처럼
/// 기기 위치를 쓰는 지점에서만 묻고, OS 권한은 그 뒤 [devicePlaceFix] 가 따로 요청한다.
///
/// 기록은 서버 `fnConsentRecord`(`users/{uid}/consents` append-only 행 + `consentState.location`)가 하고,
/// 위치 Quest 인증(`fnQuestVerifyPlace`)도 같은 상태를 다시 본다. 철회는 설정 화면의 토글이다.
///
/// Figma 에는 이 시점의 동의 화면이 없다(`179:24230` 은 가입 약관). 앱 공통 확인 창([showConfirmDialog])
/// 틀에 약관 칸을 넣어 쓴다.
library;
import 'package:flutter/material.dart';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
/// 동의 기록에 남는 위치기반 약관 버전. 🟡 임시 — 발주사 확정 문안(R02) 수령 전 자리 표시 문구다.
const locationTermsVersion = 'draft-1';
/// 위치기반 서비스 동의 상태 · 기록 경계.
abstract interface class LocationConsent {
Future<bool> granted();
/// 동의(true) · 철회(false)를 기록한다. 철회도 새 행으로 남는다(서버 append-only).
Future<void> record(bool granted);
}
class FirestoreLocationConsent implements LocationConsent {
FirestoreLocationConsent(this._backend);
final Backend _backend;
/// 서버 `hasLocationConsent` 와 같은 판정 — `consentState.location` 이 없으면 예전 가입 화면
/// (위치 약관이 가입 필수였던 0.2.0 이전)의 동의 행으로 본다.
@override
Future<bool> granted() async {
final uid = _backend.requireUid;
final user =
(await _backend.db.doc('users/$uid').get()).data() ??
const <String, dynamic>{};
final state = user['consentState'];
if (state is Map && state['location'] is Map) {
return (state['location'] as Map)['granted'] == true;
}
final version = user['termsVersion'];
if (version is! String || version.isEmpty) return false;
return (await _backend.db
.doc('users/$uid/consents/location_${version}_grant')
.get())
.exists;
}
@override
Future<void> record(bool granted) => _backend.call('fnConsentRecord', {
'docType': 'location',
'docVersion': locationTermsVersion,
'granted': granted,
});
}
/// 목 모드 · 테스트용 — 앱 안에서 하나를 같이 쓴다(퀘스트 · 설정 화면이 같은 상태를 본다).
class SampleLocationConsent implements LocationConsent {
SampleLocationConsent([this.value = false]);
static final shared = SampleLocationConsent();
bool value;
final records = <bool>[];
@override
Future<bool> granted() async => value;
@override
Future<void> record(bool granted) async {
records.add(granted);
value = granted;
}
}
/// 위치기능을 쓰기 직전에 부른다. 이미 동의했으면 묻지 않고 true, 처음이면 동의 창을 띄워
/// 동의를 기록한 뒤 true. 「나중에」 · 기록 실패면 false — 호출부는 그 위치기능만 건너뛴다.
Future<bool> ensureLocationConsent(
BuildContext context,
LocationConsent consent,
) async {
try {
if (await consent.granted()) return true;
} catch (_) {
// 상태를 못 읽었으면 묻는다 — 동의는 다시 기록해도 행이 하나 더 남을 뿐이다.
}
if (!context.mounted) return false;
final agreed = await showLocationConsentDialog(context);
if (!agreed) return false;
try {
await consent.record(true);
return true;
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(context.l10n.commonRequestFailed),
behavior: SnackBarBehavior.floating,
),
);
}
return false;
}
}
/// 동의 창 — 제목 · 사용 목적 · 약관 본문 칸 · 「나중에 / 동의」. 동의면 true.
Future<bool> showLocationConsentDialog(BuildContext context) {
final l10n = context.l10n;
return showConfirmDialog(
context,
l10n.termsLocation,
body: l10n.locationConsentBody,
content: Container(
key: const ValueKey('location-terms-body'),
constraints: const BoxConstraints(maxHeight: 120),
padding: const EdgeInsets.all(AppSpace.s3),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF545454)),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: SingleChildScrollView(
child: Text(
l10n.termsLocationBody,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.36,
color: AppColors.onCosmic,
),
),
),
),
cancelLabel: l10n.locationConsentLater,
confirmLabel: l10n.locationConsentAgree,
);
}
@@ -23,7 +23,7 @@ typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
/// 판정표를 돌려준다. 중간에 나가면 null 이다 — 호출자는 넘긴 [judge] 의 판정 · 영상 시각을
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(원장 C-83).
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(정책 원장 C-83).
///
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
@@ -26,8 +26,13 @@ class PlacePickerScreen extends StatefulWidget {
super.key,
this.mapBuilder = _googleMap,
this.lastKnown = _deviceLastKnown,
this.useDeviceLocation = true,
});
/// 기기 위치(마지막 위치 · 현재 위치)를 쓸지. 위치기반 서비스 약관에 동의하지 않았으면 false 다 —
/// 지도에서 직접 고르는 것은 그대로 된다(Q-E1 · R02 「거부해도 일반 기능 이용 가능」).
final bool useDeviceLocation;
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
final PickerMapBuilder mapBuilder;
@@ -68,6 +73,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
@override
void initState() {
super.initState();
if (!widget.useDeviceLocation) {
_start = PlacePickerScreen.fallbackCenter;
return;
}
_startFromLastKnown();
_locate();
}
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
/// 위치 Quest QR 인증 — 현장에 붙은 QR 을 스캔해 그 값을 돌려준다(원장 C-03 · PRD-05 「QR 인증」).
/// 위치 Quest QR 인증 — 현장에 붙은 QR 을 스캔해 그 값을 돌려준다(정책 원장 C-03 · PRD-05 「QR 인증」).
///
/// 판정은 서버가 한다(생성 때 발급한 코드와 대조). 이 화면은 첫 번째로 읽힌 값으로 닫힐 뿐이다.
/// 🟡 Figma 에 스캔 화면이 없어 카메라 전체 화면 + 닫기 버튼만 둔다(해석 확인 요청 영역B Q-B-10).
+2 -2
View File
@@ -97,7 +97,7 @@ class _AdBonusDialog extends StatelessWidget {
child: Text(
bonusPop == null
? context.l10n.adBonusPlaceholder
// 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
// 정책 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
style: TextStyle(
fontSize: 36,
@@ -166,7 +166,7 @@ class _AdBonusDialog extends StatelessWidget {
}
}
/// Focus(광고 제거 구독 · 원장 B-04) 구입 버튼. 가격은 스토어가 준 현지 가격이다 —
/// Focus(광고 제거 구독 · 정책 원장 B-04) 구입 버튼. 가격은 스토어가 준 현지 가격이다 —
/// 시안의 「₩3,900/월」은 확정가(US$1.99)와 달라 쓰지 않는다(공통 기준 2항).
/// 스토어에 상품이 없으면 「스토어 상품 준비 중」으로 멈춘다.
class _FocusOffer extends StatefulWidget {
@@ -7,6 +7,7 @@ import '../../core/data/server_clock.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'place_picker_screen.dart';
import 'location_consent.dart';
import 'quest_data.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
@@ -318,7 +319,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: _pickPlace,
),
// 추가 인증 수단(원장 C-03 「Location 의 추가 인증 수단」 · PRD-05 「GPS · 사진 · QR 인증」).
// 추가 인증 수단(정책 원장 C-03 「Location 의 추가 인증 수단」 · PRD-05 「GPS · 사진 · QR 인증」).
// GPS 는 항상 들어간다. 🟡 Figma 생성 화면에 이 줄이 없어 배치는 임시다(해석 확인 요청 영역B Q-B-10).
Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: AppSpace.s2),
@@ -441,9 +442,13 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
final _extraVerify = <String>{};
Future<void> _pickPlace() async {
// 지도의 현재 위치는 위치기능이다 — 위치기반 약관에 먼저 동의받는다(Q-E1 · R02). 거부해도 지도에서
// 직접 고를 수 있다.
final located = await ensureLocationConsent(context, widget.repository.locationConsent);
if (!mounted) return;
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
SheetRoute(builder: (_) => const PlacePickerScreen()),
SheetRoute(builder: (_) => PlacePickerScreen(useDeviceLocation: located)),
);
if (picked == null || !mounted) return;
setState(() => _place = picked);

Some files were not shown because too many files have changed in this diff Show More