diff --git a/AGENTS.md b/AGENTS.md index f10ddc7..d9f85e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Project Instructions — hikikomori / 분신 (가칭) -This repository is currently a **planning-docs repo** for an AI-twin messenger -("나를 대신해 남과 대화하는 AI 분신"). There is no application code yet. +This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 분신") with +Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs. -Canonical docs live under `docs/`. Prefer linking to them over copying content -into prompts or new files. +Canonical product decisions live under `docs/`. Prefer linking to them over +copying content into prompts or new files. ## Document authority @@ -18,9 +18,10 @@ When documents disagree, follow this order: Notes: -- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting - decisions. Do not silently reverse them. If a change is required, update - `decision-log.md` and all derived docs in the same change. +- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent + sub-questions (default autonomy level, whitelist defaults, final branding) + stay open — do not invent those. To reverse a Q, update `decision-log.md` and + derived docs in the same change. - Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for current working answers. - Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later** diff --git a/CLAUDE.md b/CLAUDE.md index c17c295..4eb633c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md). Quick context: -- This repo started as planning docs only; Phase 1 app-build has now begun. - Check `docs/roadmap.md` Phase 1 checklist before starting app-build work. -- Working product name: **분신** (tentative). -- Source of working decisions: `docs/decision-log.md` (status: 제안). +- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is + **D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults. +- Working product name: **분신** (가칭 확정). +- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open). - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. - Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. - Never weaken escalation, twin badge, peer veto, or undo. diff --git a/README.md b/README.md index ceea6e4..68c4735 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ ## 문서 -- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) +- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**) - Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md) - 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md) +- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md) - PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md) ## AI 에이전트 규칙 @@ -26,9 +27,9 @@ ## 현재 단계 -- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 -- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 -- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) +- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로) +- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막) +- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 ## 로컬 실행 (요약) diff --git a/core-backend/README.md b/core-backend/README.md index ce924b3..1f9bebc 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -29,8 +29,10 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 - 가입 `POST /auth/signup` / 로그인 `POST /auth/login` → `{token}` (Bearer) - 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요 -- `/invites`, `/admin/metrics`, `/admin/dashboard`는 `Authorization: Bearer $ADMIN_API_TOKEN` - (`/admin/dashboard`는 `?token=` 쿼리로도 토큰을 넘길 수 있음) +- `/invites`, `/admin/metrics`, `/admin/dashboard`, `/admin/push-test`는 + `Authorization: Bearer $ADMIN_API_TOKEN` (`/admin/dashboard`는 `?token=` 쿼리 가능) +- 초대 운영: `POST /invites`(`note`, `expires_in_days`, `count`), `GET /invites`, + `POST /invites/:code/revoke` — 절차는 `docs/invite-ops.md` 메시지 생성(`POST /conversations/:id/messages`)은 전체 메시지 JSON을 반환한다 (`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`). diff --git a/core-backend/invite_ops.go b/core-backend/invite_ops.go new file mode 100644 index 0000000..8af970d --- /dev/null +++ b/core-backend/invite_ops.go @@ -0,0 +1,148 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type mintInvitesRequest struct { + Note string `json:"note"` + ExpiresInDays int `json:"expires_in_days"` // 0 → default 14; <0 → no expiry + Count int `json:"count"` // default 1, max 20 +} + +func inviteStatus(inv InviteCode, now time.Time) string { + if inv.RevokedAt != nil { + return "revoked" + } + if inv.UsedAt != nil { + return "used" + } + if inv.ExpiresAt != nil && !inv.ExpiresAt.After(now) { + return "expired" + } + return "unused" +} + +func inviteUsable(inv InviteCode, now time.Time) (ok bool, detail string) { + switch inviteStatus(inv, now) { + case "unused": + return true, "" + case "used": + return false, "invite code already used" + case "revoked": + return false, "invite code revoked" + case "expired": + return false, "invite code expired" + default: + return false, "invite code not usable" + } +} + +func inviteJSON(inv InviteCode, now time.Time) gin.H { + out := gin.H{ + "code": inv.Code, + "note": inv.Note, + "status": inviteStatus(inv, now), + "created_at": inv.CreatedAt, + } + if inv.ExpiresAt != nil { + out["expires_at"] = inv.ExpiresAt + } + if inv.RevokedAt != nil { + out["revoked_at"] = inv.RevokedAt + } + if inv.UsedAt != nil { + out["used_at"] = inv.UsedAt + out["used_by_user_id"] = inv.UsedByUserID + } + return out +} + +func registerInviteOpsRoutes(r *gin.Engine, db *gorm.DB) { + r.POST("/invites", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req mintInvitesRequest + _ = c.ShouldBindJSON(&req) // body optional for backward compat + count := req.Count + if count <= 0 { + count = 1 + } + if count > 20 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "count must be <= 20"}) + return + } + expiresIn := req.ExpiresInDays + if expiresIn == 0 { + expiresIn = 14 + } + + now := time.Now() + codes := make([]gin.H, 0, count) + for i := 0; i < count; i++ { + code, err := generateInviteCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + invite := InviteCode{Code: code, Note: req.Note} + if expiresIn > 0 { + exp := now.Add(time.Duration(expiresIn) * 24 * time.Hour) + invite.ExpiresAt = &exp + } + if err := db.Create(&invite).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + codes = append(codes, inviteJSON(invite, now)) + } + if count == 1 { + c.JSON(http.StatusOK, codes[0]) + return + } + c.JSON(http.StatusOK, gin.H{"invites": codes}) + }) + + r.GET("/invites", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var invites []InviteCode + db.Order("id desc").Limit(200).Find(&invites) + now := time.Now() + out := make([]gin.H, 0, len(invites)) + for _, inv := range invites { + out = append(out, inviteJSON(inv, now)) + } + c.JSON(http.StatusOK, gin.H{"invites": out}) + }) + + r.POST("/invites/:code/revoke", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + code := c.Param("code") + var invite InviteCode + if err := db.Where("code = ?", code).First(&invite).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "invite not found"}) + return + } + if invite.UsedAt != nil { + c.JSON(http.StatusConflict, gin.H{"detail": "cannot revoke a used invite"}) + return + } + if invite.RevokedAt != nil { + c.JSON(http.StatusOK, inviteJSON(invite, time.Now())) + return + } + now := time.Now() + invite.RevokedAt = &now + db.Save(&invite) + c.JSON(http.StatusOK, inviteJSON(invite, now)) + }) +} diff --git a/core-backend/invite_ops_test.go b/core-backend/invite_ops_test.go new file mode 100644 index 0000000..770ca42 --- /dev/null +++ b/core-backend/invite_ops_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestMintInviteWithNoteExpiryAndRevoke(t *testing.T) { + server, _ := setupTestServer(t) + + resp := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{ + Note: "e2e-friend", + ExpiresInDays: 7, + Count: 1, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("mint: %d", resp.StatusCode) + } + var one map[string]interface{} + json.NewDecoder(resp.Body).Decode(&one) + code, _ := one["code"].(string) + if code == "" || one["note"] != "e2e-friend" || one["status"] != "unused" { + t.Fatalf("unexpected mint payload %v", one) + } + if one["expires_at"] == nil { + t.Fatalf("expected expires_at, got %v", one) + } + + batch := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{ + Note: "batch", + Count: 3, + }) + if batch.StatusCode != http.StatusOK { + t.Fatalf("batch: %d", batch.StatusCode) + } + var bout map[string]interface{} + json.NewDecoder(batch.Body).Decode(&bout) + if len(bout["invites"].([]interface{})) != 3 { + t.Fatalf("expected 3 invites, got %v", bout) + } + + listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/invites", nil) + listReq.Header.Set("Authorization", "Bearer test-admin-token") + listResp, err := http.DefaultClient.Do(listReq) + if err != nil { + t.Fatal(err) + } + if listResp.StatusCode != http.StatusOK { + t.Fatalf("list: %d", listResp.StatusCode) + } + + rev := postJSONAuth(t, server.URL+"/invites/"+code+"/revoke", "test-admin-token", nil) + if rev.StatusCode != http.StatusOK { + t.Fatalf("revoke: %d", rev.StatusCode) + } + var revOut map[string]interface{} + json.NewDecoder(rev.Body).Decode(&revOut) + if revOut["status"] != "revoked" { + t.Fatalf("expected revoked, got %v", revOut) + } + + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "X"}) + if signup.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for revoked invite, got %d", signup.StatusCode) + } +} + +func TestExpiredInviteRejected(t *testing.T) { + server, db := setupTestServer(t) + code := mintInvite(t, server.URL) + past := time.Now().Add(-time.Hour) + db.Model(&InviteCode{}).Where("code = ?", code).Update("expires_at", past) + + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "Late"}) + if signup.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for expired invite, got %d", signup.StatusCode) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index 7343000..58d08d6 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -58,23 +58,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi registerA1A2Routes(r, db) registerBRoutes(r, db) - - r.POST("/invites", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - code, err := generateInviteCode() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - invite := InviteCode{Code: code} - if err := db.Create(&invite).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": invite.Code}) - }) + registerInviteOpsRoutes(r, db) r.GET("/admin/metrics", func(c *gin.Context) { if !requireAdmin(c) { @@ -159,8 +143,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) return } - if invite.UsedAt != nil { - c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) + if ok, detail := inviteUsable(invite, time.Now()); !ok { + status := http.StatusBadRequest + if detail == "invite code already used" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"detail": detail}) return } diff --git a/core-backend/models.go b/core-backend/models.go index c18e58e..c5938a7 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -41,7 +41,10 @@ type Session struct { type InviteCode struct { ID uint `gorm:"primaryKey"` Code string `gorm:"uniqueIndex;not null"` + Note string // operator memo (recipient/channel) — roadmap C invite ops CreatedAt time.Time + ExpiresAt *time.Time + RevokedAt *time.Time UsedAt *time.Time UsedByUserID *uint } diff --git a/docs/PLANNING.md b/docs/PLANNING.md index cf86a00..10adf6d 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -143,8 +143,8 @@ 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` "말투 검색기" 참고) - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 - 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). - PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 + 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`). + PoC #3 역할극 자극재로 사용 - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) diff --git a/docs/android-release.md b/docs/android-release.md new file mode 100644 index 0000000..26307fa --- /dev/null +++ b/docs/android-release.md @@ -0,0 +1,64 @@ +# Android 릴리즈·내부 배포 경로 (Phase 1 C) + +v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아니라 +**지인 클로즈드 베타용 내부 APK/AAB** 경로를 먼저 고정한다. + +## 산출물 + +| 산출물 | 명령 | 용도 | +|--------|------|------| +| 내부 APK | `./scripts/build_release_apk.sh` | 직접 설치·메신저 공유 | +| AAB | `./scripts/build_release_aab.sh` | Play 내부 테스트 트랙 (선택) | + +## 서명 키 (Master 로컬 전용) + +1. 키스토어는 **절대 git에 넣지 않는다** (`mobile/android/key.properties`, `*.jks`, `*.keystore`는 gitignore). +2. Master 머신에서 한 번 생성: + +```bash +keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin +``` + +3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성: + +``` +storePassword=... +keyPassword=... +keyAlias=bunsin +storeFile=/absolute/path/to/bunsin-release.jks +``` + +4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다 + (내부 스모크용). 베타 배포본은 반드시 release keystore로 서명한다. + +## 빌드 + +```bash +cd mobile +flutter pub get +../scripts/build_release_apk.sh +# → build/app/outputs/flutter-apk/app-release.apk + +../scripts/build_release_aab.sh # Play 내부 테스트용 (선택) +``` + +API 베이스 URL은 배포 대상 서버에 맞게: + +```bash +flutter build apk --release \ + --dart-define=CORE_API_BASE=https://api.example.com +``` + +## 배포 채널 (권장 순서) + +1. **직접 APK** — 초대받은 지인에게만 파일 전달 (1차 클로즈드 베타) +2. **Play 내부 테스트** — 규모가 커지면 AAB + 테스터 이메일 목록 +3. 공개 트랙 — Phase 게이트(`vision.md`) 통과 전 금지 + +## 체크리스트 + +- [ ] `key.properties` + jks가 Master 백업에만 존재 +- [ ] `CORE_API_BASE`가 베타 서버를 가리킴 +- [ ] `ADMIN_API_TOKEN` / `GEMINI_API_KEY` / `FCM_SERVER_KEY`가 서버에만 설정 +- [ ] 초대 코드 운영 (`docs/invite-ops.md`) 숙지 +- [ ] 실기기에서 `mobile/README.md` E2E UI 체크 1회 diff --git a/docs/decision-log.md b/docs/decision-log.md index f50ce29..2c783d3 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -1,41 +1,42 @@ # 결정 로그 회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다. -상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를 -쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진 -하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를 -그대로 실천하는 것이기도 하다.) + +**상태 범례** +- **제안**: 문서 작성용 잠정안 (회의 전) +- **확정**: Master가 Phase 1 C(베타 직전)에서 승인·고정한 값. 이후 번복 시 이 로그와 + 근거로 만든 Vision/PRD/기술설계를 함께 갱신한다. | # | 질문 | 결정 | 근거 | 상태 | |---|------|------|------|------| -| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 | -| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 | -| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 | -| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 | -| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 | -| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) | -| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 | +| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 | +| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 | +| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 | +| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 | +| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 | +| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) | +| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 | + +확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정). ## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가 `PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을 -제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다. +제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 둔다. - 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은 - 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가, - 신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다. + 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에 + 검증하면 실패 원인을 구분할 수 없다. - 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다. -- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 — - 버리는 작업이 아니다. +- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능. -**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면 -OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은 -L3·L4 등 완전한 기능의 최종 목적지로 유지. +**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 → +③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지. -## 아직 열려 있는 하위 질문 +## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후) -- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은 - 실사용 데이터로 계속 조정 필요 -- 상표/네이밍 최종안 (Q6은 가칭) -- 베타 참가자 모집 규모와 방식 (§로드맵 참고) +- 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터) +- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰) +- 상표/네이밍 최종안 (Q6은 가칭 확정) +- 베타 참가자 모집 규모와 방식 diff --git a/docs/invite-ops.md b/docs/invite-ops.md new file mode 100644 index 0000000..8d4ab5b --- /dev/null +++ b/docs/invite-ops.md @@ -0,0 +1,57 @@ +# 초대 코드 운영 절차 (Phase 1 C) + +클로즈드 베타 가입은 **미리 발급된 1회용 초대 코드**로만 가능하다 +(`POST /auth/signup` → `InviteCode` 검증). + +## 발급자 권한 + +| 역할 | 권한 | +|------|------| +| **발급자 (Issuer)** | Master(또는 Master가 지정한 1명). `ADMIN_API_TOKEN`을 가진 주체만 발급·목록·회수 | +| 일반 사용자 | 초대 발급 불가. 받은 코드로 가입만 가능 | +| CI / 에이전트 | 프로덕션 `ADMIN_API_TOKEN`을 저장·공유하지 않음 | + +토큰은 서버 환경변수 `ADMIN_API_TOKEN`으로만 주입한다. 레포·채팅·커밋에 넣지 않는다. + +## 발급 규칙 + +1. **용도 메모 필수 권장** — `note`에 수신자/채널(예: `지인-김ㅇㅇ`, `PoC#3-참가자3`)을 남긴다. +2. **만료** — 기본 14일. 장기 코드는 만들지 않는다 (`expires_in_days`). +3. **배치** — 한 번에 최대 20개 (`count`). 대량 발급 전 Master 확인. +4. **1코드 = 1가입**. 재사용 불가. 계정 삭제 후에도 코드는 “사용됨”으로 남는다. +5. **회수** — 전달 전 유출·오배포 시 `POST /invites/:code/revoke`. 이미 사용된 코드는 회수 불가. + +## 운영 커맨드 + +```bash +export ADMIN_API_TOKEN=... # 로컬/서버에만 +export CORE=http://127.0.0.1:8080 + +# 단일 발급 (14일, 메모) +curl -sS -X POST "$CORE/invites" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"note":"지인-테스트","expires_in_days":14}' + +# 배치 5개 +curl -sS -X POST "$CORE/invites" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"note":"베타-1차","expires_in_days":14,"count":5}' + +# 목록 +curl -sS "$CORE/invites" -H "Authorization: Bearer $ADMIN_API_TOKEN" + +# 회수 +curl -sS -X POST "$CORE/invites//revoke" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" +``` + +사용 현황은 `GET /admin/metrics`의 `invites_minted` / `invites_used`와 목록의 +`status`(`unused`|`used`|`expired`|`revoked`)로 본다. + +## 배포 전 체크 + +- [ ] 프로덕션 `ADMIN_API_TOKEN` 회전·길이 충분(≥32자 권장) +- [ ] 초대 코드를 공개 채널(SNS/이슈)에 올리지 않음 +- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트 diff --git a/docs/meeting-review-summary.md b/docs/meeting-review-summary.md index 5a387ec..895d4a7 100644 --- a/docs/meeting-review-summary.md +++ b/docs/meeting-review-summary.md @@ -16,7 +16,7 @@ | `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 | | `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** | | `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** | -| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 | +| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) | **아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행, 그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다. @@ -25,13 +25,13 @@ 각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다. -- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예 -- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후) -- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설) -- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행) -- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예 -- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전 -- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예 +- [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`) +- [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후) +- [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후) +- [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증 +- [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예 +- [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지 +- [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예 ## 회의에서 정할 것 (Q1~Q7 확정 외) diff --git a/docs/poc-materials.md b/docs/poc-materials.md index ccd7665..b8313b2 100644 --- a/docs/poc-materials.md +++ b/docs/poc-materials.md @@ -34,6 +34,7 @@ `bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다. +공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`). ### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함) diff --git a/docs/prototype.md b/docs/prototype.md new file mode 100644 index 0000000..1609159 --- /dev/null +++ b/docs/prototype.md @@ -0,0 +1,36 @@ +# 클릭 프로토타입 (PoC #3 자극재) — 공유 링크 고정 + +Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식 참조 위치다. +장면: 읽씹 종결 · 본인확인 · 거부권 · 에스컬레이션 (+ 자율성 설정). + +## 공식 링크 + +| 항목 | 값 | +|------|-----| +| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) | +| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) | +| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 | + +URL을 받으면 아래 한 줄만 채운다 (추측으로 가짜 링크를 넣지 않는다): + +``` +SHARE_URL= +``` + +현재 값: + +``` +SHARE_URL= +``` + +## 사용처 + +- [`poc-plan.md`](./poc-plan.md) — PoC #3 자극재 +- [`poc-materials.md`](./poc-materials.md) §2 — 역할극 시연 순서 +- [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의 산출물 표 + +## 갱신 규칙 + +1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다. +2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다. +3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 52dbbe4..da3b70d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -115,8 +115,8 @@ - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고 `/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드 - 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자 - 인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것) + 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은 + `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`) - [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 `/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 @@ -155,7 +155,8 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다. - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 §3(사람 PoC) 이후** - 5-2. [x] 화이트리스트 규칙 CRUD API -6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**) +6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로 +7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**) #### 5. 앞으로의 개발 계획 (우선순위 체크리스트) @@ -200,10 +201,14 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase - [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트 ##### C. 베타 직전 -- [ ] Android 릴리즈 빌드·서명·배포 경로 -- [ ] 초대 코드 운영 절차(발급자 권한) -- [ ] Q1~Q7 회의 확정 (제안 → 확정) -- [ ] 클릭 프로토타입 공유 링크 docs 고정 +- [x] Android 릴리즈 빌드·서명·배포 경로 — `docs/android-release.md`, + `scripts/build_release_apk.sh` / `build_release_aab.sh`, `key.properties` 서명 훅. + keystore는 Master 로컬 전용(내부 APK 우선) +- [x] 초대 코드 운영 절차(발급자 권한) — `docs/invite-ops.md` + note/만료/배치/회수 API + (`GET|POST /invites`, `POST /invites/:code/revoke`) +- [x] Q1~Q7 회의 확정 (제안 → 확정) — `docs/decision-log.md` (2026-07-30). PoC 의존 하위 + 질문만 열어둠 +- [x] 클릭 프로토타입 공유 링크 docs 고정 — `docs/prototype.md` 앵커. `SHARE_URL`은 Master 기입 ##### D. 맨 마지막 — 사람 PoC (지금 안 함) - §3 항목과 동일. A~C 완료 후에만 착수. diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index be3943c..a291f3c 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +*.jks +*.keystore diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 9ed1068..5617d6a 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +val hasReleaseKeystore = keystorePropertiesFile.exists() +if (hasReleaseKeystore) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.bunsin.bunsin_mobile" compileSdk = flutter.compileSdkVersion @@ -20,21 +30,33 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.bunsin.bunsin_mobile" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Prefer release keystore when key.properties exists; else debug + // (smoke only — see docs/android-release.md). + signingConfig = if (hasReleaseKeystore) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/mobile/android/key.properties.example b/mobile/android/key.properties.example new file mode 100644 index 0000000..7565a5b --- /dev/null +++ b/mobile/android/key.properties.example @@ -0,0 +1,4 @@ +storePassword=replace-me +keyPassword=replace-me +keyAlias=bunsin +storeFile=/absolute/path/to/bunsin-release.jks diff --git a/scripts/build_release_aab.sh b/scripts/build_release_aab.sh new file mode 100755 index 0000000..c2c8d78 --- /dev/null +++ b/scripts/build_release_aab.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Optional Play internal-testing bundle (docs/android-release.md). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/mobile" + +if [[ ! -f android/key.properties ]]; then + echo "WARN: android/key.properties missing — AAB will use debug signing." >&2 +fi + +flutter pub get +flutter build appbundle --release "$@" +echo +echo "AAB: $ROOT/mobile/build/app/outputs/bundle/release/app-release.aab" diff --git a/scripts/build_release_apk.sh b/scripts/build_release_apk.sh new file mode 100755 index 0000000..4790571 --- /dev/null +++ b/scripts/build_release_apk.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Internal closed-beta APK (docs/android-release.md). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/mobile" + +if [[ ! -f android/key.properties ]]; then + echo "WARN: android/key.properties missing — release APK will use debug signing." >&2 + echo " Copy android/key.properties.example for a real beta build." >&2 +fi + +flutter pub get +flutter build apk --release "$@" +echo +echo "APK: $ROOT/mobile/build/app/outputs/flutter-apk/app-release.apk"