Merge pull request #9 from o0kuma/cursor/phase1-c-beta-ready-382d
Phase 1 C: Q1–Q7 confirmed, invite ops, Android release path
This commit is contained in:
commit
c46c5263de
15
AGENTS.md
15
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**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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가 기입
|
||||
|
||||
## 로컬 실행 (요약)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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명, 스크리닝 → 본 인터뷰 → 결과 반영)
|
||||
|
|
|
|||
|
|
@ -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회
|
||||
|
|
@ -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은 가칭 확정)
|
||||
- 베타 참가자 모집 규모와 방식
|
||||
|
|
|
|||
|
|
@ -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/이슈)에 올리지 않음
|
||||
- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트
|
||||
|
|
@ -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 확정 외)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
|
||||
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
|
||||
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
|
||||
|
||||
### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`과 이 앵커 문서는 유지한다.
|
||||
|
|
@ -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 완료 후에만 착수.
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java
|
|||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
*.jks
|
||||
*.keystore
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
storePassword=replace-me
|
||||
keyPassword=replace-me
|
||||
keyAlias=bunsin
|
||||
storeFile=/absolute/path/to/bunsin-release.jks
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue