Complete Phase 1 C: decisions, invite ops, release path, prototype anchor

- Confirm Q1~Q7 in decision-log (Master-approved Phase C)
- Invite ops doc + note/expiry/batch/revoke APIs and tests
- Android release docs/scripts + optional key.properties signing
- Fix prototype share-link docs anchor (SHARE_URL for Master)

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-30 05:48:50 +00:00
parent f2d7ac8054
commit 4f2c9df160
No known key found for this signature in database
19 changed files with 515 additions and 72 deletions

View File

@ -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) - 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) - 로드맵 (작업 체크리스트): [`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) - PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
## AI 에이전트 규칙 ## AI 에이전트 규칙
@ -26,9 +27,9 @@
## 현재 단계 ## 현재 단계
- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 - Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로)
- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 - 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막)
- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
## 로컬 실행 (요약) ## 로컬 실행 (요약)

View File

@ -29,8 +29,10 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
- 가입 `POST /auth/signup` / 로그인 `POST /auth/login``{token}` (Bearer) - 가입 `POST /auth/signup` / 로그인 `POST /auth/login``{token}` (Bearer)
- 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요 - 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요
- `/invites`, `/admin/metrics`, `/admin/dashboard``Authorization: Bearer $ADMIN_API_TOKEN` - `/invites`, `/admin/metrics`, `/admin/dashboard`, `/admin/push-test`
(`/admin/dashboard`는 `?token=` 쿼리로도 토큰을 넘길 수 있음) `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을 반환한다 메시지 생성(`POST /conversations/:id/messages`)은 전체 메시지 JSON을 반환한다
(`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`). (`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`).

148
core-backend/invite_ops.go Normal file
View File

@ -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))
})
}

View File

@ -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)
}
}

View File

@ -58,23 +58,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
registerA1A2Routes(r, db) registerA1A2Routes(r, db)
registerBRoutes(r, db) registerBRoutes(r, db)
registerInviteOpsRoutes(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})
})
r.GET("/admin/metrics", func(c *gin.Context) { r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) { 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"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
return return
} }
if invite.UsedAt != nil { if ok, detail := inviteUsable(invite, time.Now()); !ok {
c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) status := http.StatusBadRequest
if detail == "invite code already used" {
status = http.StatusConflict
}
c.JSON(status, gin.H{"detail": detail})
return return
} }

View File

@ -41,7 +41,10 @@ type Session struct {
type InviteCode struct { type InviteCode struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex;not null"` Code string `gorm:"uniqueIndex;not null"`
Note string // operator memo (recipient/channel) — roadmap C invite ops
CreatedAt time.Time CreatedAt time.Time
ExpiresAt *time.Time
RevokedAt *time.Time
UsedAt *time.Time UsedAt *time.Time
UsedByUserID *uint UsedByUserID *uint
} }

View File

@ -143,8 +143,8 @@
검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md`
"말투 검색기" 참고) "말투 검색기" 참고)
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`).
PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 PoC #3 역할극 자극재로 사용
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영)

64
docs/android-release.md Normal file
View File

@ -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회

View File

@ -1,41 +1,42 @@
# 결정 로그 # 결정 로그
회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다. 회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다.
상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를
쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진 **상태 범례**
하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를 - **제안**: 문서 작성용 잠정안 (회의 전)
그대로 실천하는 것이기도 하다.) - **확정**: Master가 Phase 1 C(베타 직전)에서 승인·고정한 값. 이후 번복 시 이 로그와
근거로 만든 Vision/PRD/기술설계를 함께 갱신한다.
| # | 질문 | 결정 | 근거 | 상태 | | # | 질문 | 결정 | 근거 | 상태 |
|---|------|------|------|------| |---|------|------|------|------|
| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 | | Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 |
| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 | | Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 |
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 | | Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 |
| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 | | Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 |
| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 | | Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 |
| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) | | Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 | | Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 |
확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정).
## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가 ## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가
`PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을 `PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을
제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다. 제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 다.
- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은 - 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은
처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가, 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에
신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다. 검증하면 실패 원인을 구분할 수 없다.
- 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 - 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다. 구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다.
- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 — - 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능.
버리는 작업이 아니다.
**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면 **순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은 ③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
L3·L4 등 완전한 기능의 최종 목적지로 유지.
## 아직 열려 있는 하위 질문 ## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후)
- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은 - 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
실사용 데이터로 계속 조정 필요 - 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
- 상표/네이밍 최종안 (Q6은 가칭) - 상표/네이밍 최종안 (Q6은 가칭 확정)
- 베타 참가자 모집 규모와 방식 (§로드맵 참고) - 베타 참가자 모집 규모와 방식

57
docs/invite-ops.md Normal file
View File

@ -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/<CODE>/revoke" \
-H "Authorization: Bearer $ADMIN_API_TOKEN"
```
사용 현황은 `GET /admin/metrics``invites_minted` / `invites_used`와 목록의
`status`(`unused`|`used`|`expired`|`revoked`)로 본다.
## 배포 전 체크
- [ ] 프로덕션 `ADMIN_API_TOKEN` 회전·길이 충분(≥32자 권장)
- [ ] 초대 코드를 공개 채널(SNS/이슈)에 올리지 않음
- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트

View File

@ -16,7 +16,7 @@
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 | | `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** | | `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** | | `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** |
| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 | | 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) |
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행, **아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다. 그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다.
@ -25,13 +25,13 @@
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다. 각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예 - [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`)
- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후) - [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후)
- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설) - [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후)
- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행) - [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증
- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예 - [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예
- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전 - [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지
- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예 - [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예
## 회의에서 정할 것 (Q1~Q7 확정 외) ## 회의에서 정할 것 (Q1~Q7 확정 외)

View File

@ -34,6 +34,7 @@
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 `bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다. 다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함) ### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함)

36
docs/prototype.md Normal file
View File

@ -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`과 이 앵커 문서는 유지한다.

View File

@ -115,8 +115,8 @@
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고 실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고
`/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드 `/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것) `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 - [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
`/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 `/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
@ -155,7 +155,8 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
§3(사람 PoC) 이후** §3(사람 PoC) 이후**
- 5-2. [x] 화이트리스트 규칙 CRUD API - 5-2. [x] 화이트리스트 규칙 CRUD API
6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**) 6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)
#### 5. 앞으로의 개발 계획 (우선순위 체크리스트) #### 5. 앞으로의 개발 계획 (우선순위 체크리스트)
@ -200,10 +201,14 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트 - [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트
##### C. 베타 직전 ##### C. 베타 직전
- [ ] Android 릴리즈 빌드·서명·배포 경로 - [x] Android 릴리즈 빌드·서명·배포 경로 — `docs/android-release.md`,
- [ ] 초대 코드 운영 절차(발급자 권한) `scripts/build_release_apk.sh` / `build_release_aab.sh`, `key.properties` 서명 훅.
- [ ] Q1~Q7 회의 확정 (제안 → 확정) keystore는 Master 로컬 전용(내부 APK 우선)
- [ ] 클릭 프로토타입 공유 링크 docs 고정 - [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 (지금 안 함) ##### D. 맨 마지막 — 사람 PoC (지금 안 함)
- §3 항목과 동일. A~C 완료 후에만 착수. - §3 항목과 동일. A~C 완료 후에만 착수.

View File

@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
*.jks
*.keystore

View File

@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "com.bunsin.bunsin_mobile" namespace = "com.bunsin.bunsin_mobile"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -20,21 +30,33 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.bunsin.bunsin_mobile" 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 minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName 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 { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Prefer release keystore when key.properties exists; else debug
// Signing with the debug keys for now, so `flutter run --release` works. // (smoke only — see docs/android-release.md).
signingConfig = signingConfigs.getByName("debug") signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
} }

View File

@ -0,0 +1,4 @@
storePassword=replace-me
keyPassword=replace-me
keyAlias=bunsin
storeFile=/absolute/path/to/bunsin-release.jks

14
scripts/build_release_aab.sh Executable file
View File

@ -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"

15
scripts/build_release_apk.sh Executable file
View File

@ -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"