Merge remote-tracking branch 'origin/main'
# Conflicts: # mobile/lib/screens/conversation_list_screen.dart # mobile/pubspec.lock
This commit is contained in:
commit
bb3106434b
|
|
@ -0,0 +1,24 @@
|
|||
.git
|
||||
**/.git
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.pytest_cache
|
||||
**/.dart_tool
|
||||
**/.env
|
||||
.env
|
||||
**/*.db
|
||||
poc/tone-corpus/data
|
||||
**/node_modules
|
||||
mobile/android/.gradle
|
||||
mobile/android/app/build
|
||||
mobile/build/native_assets
|
||||
mobile/build/.last_build_id
|
||||
**/.idea
|
||||
**/.vscode
|
||||
mobile/devtools_options.yaml
|
||||
mobile/test/goldens
|
||||
|
||||
# Allow mobile/build/web for Dockerfile.prebuilt
|
||||
!mobile/build/
|
||||
!mobile/build/web/
|
||||
!mobile/build/web/**
|
||||
22
.env.example
22
.env.example
|
|
@ -5,9 +5,25 @@ GEMINI_API_KEY=
|
|||
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
||||
ADMIN_API_TOKEN=
|
||||
|
||||
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 in production.
|
||||
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable.
|
||||
ALLOW_DEMO_INVITE=1
|
||||
|
||||
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
||||
# FCM push (escalation notify + /admin/push-test). Prefer HTTP v1 service account:
|
||||
# place JSON at secrets/firebase-service-account.json (see docs/fcm-setup.md)
|
||||
# Optional legacy key (often disabled in new Firebase projects):
|
||||
FCM_SERVER_KEY=
|
||||
FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json
|
||||
# FCM_SERVICE_ACCOUNT_JSON= # alternative: paste minified JSON (avoid if possible)
|
||||
|
||||
# --- docker compose (N2-B / msn.iykyka.com) ---
|
||||
POSTGRES_USER=ykavu
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=ykavu
|
||||
|
||||
# Flutter web build-time API origin (nginx on web proxies API to core-backend).
|
||||
# Production: https://msn.iykyka.com
|
||||
# Local compose smoke: http://localhost:8088
|
||||
PUBLIC_API_BASE=https://msn.iykyka.com
|
||||
|
||||
# Host port mapped to web:80
|
||||
WEB_HOST_PORT=8088
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ poc/tone-corpus/data/
|
|||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Firebase / FCM service account JSON (never commit)
|
||||
secrets/**
|
||||
!secrets/.gitkeep
|
||||
!secrets/README.md
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
|
@ -17,3 +22,8 @@ __pycache__/
|
|||
|
||||
# Go build output (core-backend/)
|
||||
/core-backend/core-backend
|
||||
|
||||
# Flutter build output (use Docker multi-stage or Dockerfile.prebuilt locally)
|
||||
mobile/build/
|
||||
mobile/.dart_tool/
|
||||
mobile/.flutter-plugins-dependencies
|
||||
|
|
|
|||
21
AGENTS.md
21
AGENTS.md
|
|
@ -90,6 +90,9 @@ changes it.
|
|||
|
||||
- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s
|
||||
"Phase 1 상세 작업 분해" checklist for what's already done and what's next.
|
||||
- After Phase 1 A~C, use [`docs/deploy-checklist.md`](docs/deploy-checklist.md)
|
||||
(N1→N5) for smoke, Docker deploy, stabilize, FCM/Android QA, then human PoC.
|
||||
Keep that file and `roadmap.md` in sync when status changes.
|
||||
- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order
|
||||
without a reason, and note the reason in the checklist if you do.
|
||||
- When a task is finished, check it off in that same checklist. When you
|
||||
|
|
@ -113,6 +116,24 @@ Do not invent frameworks, folder layouts, or CI conventions beyond what
|
|||
in sync.
|
||||
- Prefer updating existing docs over creating parallel overlapping docs.
|
||||
|
||||
## Git remotes & branch policy (Master)
|
||||
|
||||
Master policy for this repo — follow even when a cloud agent default suggests
|
||||
feature branches or a non-`main` base:
|
||||
|
||||
1. **Work on `main`.** Commit and land changes on `main` (fast-forward or merge
|
||||
into `main`). Do not leave finished work only on long-lived side branches
|
||||
unless Master explicitly asks for a temporary branch.
|
||||
2. **Dual remote sync after every `main` update.**
|
||||
- `origin` = GitHub `o0kuma/hikikomori`
|
||||
- `gitea` = Gitea `gitea.iykyka.com/oh/iykyka` (iykyka)
|
||||
- Push both: `./scripts/push-both.sh main` (or `git push origin main` then
|
||||
`git push gitea main`).
|
||||
3. Never commit secrets (tokens, API keys). Gitea/GitHub credentials stay in
|
||||
local/env only.
|
||||
4. If a PR was opened for tooling reasons, merge it into `main` and dual-push;
|
||||
then close the PR. Keep `gitea/main` even with `origin/main`.
|
||||
|
||||
## Communication with agents
|
||||
|
||||
- Read the relevant docs before proposing product/tech changes.
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md).
|
|||
|
||||
Quick context:
|
||||
|
||||
- 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.
|
||||
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`).
|
||||
**Next execution track:** N1 smoke → N2 Docker (`msn.iykyka.com`) → N3 stabilize →
|
||||
N4 FCM/Android QA → **N5 / D human PoC last** — see
|
||||
[`docs/deploy-checklist.md`](./docs/deploy-checklist.md). Do not start human PoC
|
||||
early or invent Phase 1 §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.
|
||||
- **Git:** work and land on `main`; after each push to GitHub `origin`, also push
|
||||
`main` to Gitea `gitea` (`scripts/push-both.sh`). See `AGENTS.md`.
|
||||
|
||||
Before changing product or technical direction, read `AGENTS.md` and the
|
||||
relevant files under `docs/`.
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -18,17 +18,22 @@
|
|||
- 기획: [`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)
|
||||
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
|
||||
- 베타 직전(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 에이전트 규칙
|
||||
|
||||
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
|
||||
- **브랜치:** 작업·머지는 항상 `main`
|
||||
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
|
||||
|
||||
## 현재 단계
|
||||
|
||||
- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로)
|
||||
- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막)
|
||||
- Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com)
|
||||
- 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`**
|
||||
- **다음 (선택):** N4 FCM·Android QA
|
||||
- **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지
|
||||
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
|
||||
|
||||
## 로컬 실행 (요약)
|
||||
|
|
@ -50,3 +55,18 @@ go run . migrate && go run .
|
|||
# 터미널 3 — Flutter (Android)
|
||||
cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
|
||||
```
|
||||
|
||||
## Docker (N2-B / `msn.iykyka.com`)
|
||||
|
||||
- 구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md)
|
||||
- Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
|
||||
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # ADMIN_API_TOKEN, POSTGRES_PASSWORD 필수
|
||||
# 로컬: PUBLIC_API_BASE=http://localhost:8088
|
||||
docker compose up -d --build
|
||||
curl -sS http://localhost:8088/health
|
||||
```
|
||||
|
||||
Postgres·AI는 내부망만. 엣지 프록시는 `web:80`(또는 `WEB_HOST_PORT`)만 공개.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
tests
|
||||
.git
|
||||
*.md
|
||||
Dockerfile
|
||||
.env
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# ai-service — FastAPI (internal only; do not publish host ports in compose).
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd --system --uid 10001 --create-home app \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
USER app
|
||||
EXPOSE 8001
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
|
||||
CMD curl -fsS http://127.0.0.1:8001/health || exit 1
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
**/*.db
|
||||
**/__pycache__
|
||||
.git
|
||||
*.md
|
||||
Dockerfile
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# core-backend — Go (Gin). Production DB via DATABASE_URL (Postgres).
|
||||
# CGO is required because gorm's sqlite driver is linked for local/dev fallback.
|
||||
FROM golang:1.24-bookworm AS build
|
||||
WORKDIR /src
|
||||
|
||||
# go.mod may request a newer toolchain than the image — let Go fetch it.
|
||||
ENV GOTOOLCHAIN=auto
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc libc6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -o /out/core-backend .
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --create-home app
|
||||
|
||||
COPY --from=build /out/core-backend /usr/local/bin/core-backend
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
|
||||
CMD ["core-backend"]
|
||||
|
|
@ -20,6 +20,21 @@ type createContactRequest struct {
|
|||
RelationshipNote string `json:"relationship_note"`
|
||||
}
|
||||
|
||||
type updateContactRequest struct {
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
ContactUserID *uint `json:"contact_user_id"`
|
||||
RelationshipNote string `json:"relationship_note"`
|
||||
}
|
||||
|
||||
func contactJSON(ct Contact) gin.H {
|
||||
return gin.H{
|
||||
"id": ct.ID,
|
||||
"display_name": ct.DisplayName,
|
||||
"contact_user_id": ct.ContactUserID,
|
||||
"relationship_note": ct.RelationshipNote,
|
||||
}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
InviteCode string `json:"invite_code" binding:"required"`
|
||||
}
|
||||
|
|
@ -242,12 +257,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
|||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"display_name": contact.DisplayName,
|
||||
"contact_user_id": contact.ContactUserID,
|
||||
"relationship_note": contact.RelationshipNote,
|
||||
})
|
||||
c.JSON(http.StatusOK, contactJSON(contact))
|
||||
})
|
||||
|
||||
r.GET("/users/:id/contacts", func(c *gin.Context) {
|
||||
|
|
@ -262,16 +272,47 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
|||
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
|
||||
out := make([]gin.H, 0, len(contacts))
|
||||
for _, ct := range contacts {
|
||||
out = append(out, gin.H{
|
||||
"id": ct.ID,
|
||||
"display_name": ct.DisplayName,
|
||||
"contact_user_id": ct.ContactUserID,
|
||||
"relationship_note": ct.RelationshipNote,
|
||||
})
|
||||
out = append(out, contactJSON(ct))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"contacts": out})
|
||||
})
|
||||
|
||||
r.PATCH("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
contactID, ok := parseUintParam(c, "contactId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateContactRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
var contact Contact
|
||||
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
||||
return
|
||||
}
|
||||
if req.ContactUserID != nil && *req.ContactUserID == userID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot set contact_user_id to yourself"})
|
||||
return
|
||||
}
|
||||
contact.DisplayName = req.DisplayName
|
||||
contact.ContactUserID = req.ContactUserID
|
||||
contact.RelationshipNote = req.RelationshipNote
|
||||
if err := db.Save(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contactJSON(contact))
|
||||
})
|
||||
|
||||
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,46 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestPatchContactSetsPeerUserID(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||
peerID, _ := mustSignup(t, server.URL, "상대")
|
||||
|
||||
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||
DisplayName: "이름만",
|
||||
})
|
||||
if createResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create contact without peer: %d", createResp.StatusCode)
|
||||
}
|
||||
var created map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&created)
|
||||
contactID := uint(created["id"].(float64))
|
||||
if created["contact_user_id"] != nil {
|
||||
t.Fatalf("expected null peer id, got %v", created["contact_user_id"])
|
||||
}
|
||||
|
||||
patchResp := patchJSONAuth(
|
||||
t,
|
||||
server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10),
|
||||
ownerToken,
|
||||
updateContactRequest{
|
||||
DisplayName: "상대방",
|
||||
ContactUserID: &peerID,
|
||||
},
|
||||
)
|
||||
if patchResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch contact: %d", patchResp.StatusCode)
|
||||
}
|
||||
var updated map[string]interface{}
|
||||
json.NewDecoder(patchResp.Body).Decode(&updated)
|
||||
if uint(updated["contact_user_id"].(float64)) != peerID {
|
||||
t.Fatalf("expected peer %d, got %v", peerID, updated["contact_user_id"])
|
||||
}
|
||||
if updated["display_name"] != "상대방" {
|
||||
t.Fatalf("display_name: %v", updated["display_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitesRequireAdminToken(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
resp := postJSON(t, server.URL+"/invites", nil)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
|
|||
t.Fatalf("revoke: %d", del.StatusCode)
|
||||
}
|
||||
|
||||
// Push test without FCM_SERVER_KEY should soft-skip.
|
||||
// Push test without FCM credentials should soft-skip.
|
||||
push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"title": "t",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ func registerDemoRoutes(r *gin.Engine) {
|
|||
"demo_invite_code": demoInviteCode,
|
||||
"demo_display_name": "테스터",
|
||||
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
|
||||
"pairing_steps": []string{
|
||||
"두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).",
|
||||
"각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.",
|
||||
"연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.",
|
||||
"메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.",
|
||||
"L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.",
|
||||
},
|
||||
"notes": []string{
|
||||
"대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.",
|
||||
"ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
|
|||
if demo["demo_invite_code"] != demoInviteCode {
|
||||
t.Fatalf("demo code: %v", demo["demo_invite_code"])
|
||||
}
|
||||
steps, ok := demo["pairing_steps"].([]any)
|
||||
if !ok || len(steps) < 3 {
|
||||
t.Fatalf("expected pairing_steps on /demo, got %#v", demo["pairing_steps"])
|
||||
}
|
||||
|
||||
a := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||
InviteCode: demoInviteCode,
|
||||
|
|
|
|||
|
|
@ -3,19 +3,27 @@ module hikikomori/core-backend
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
|
|
@ -42,7 +50,4 @@ require (
|
|||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.2 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
|
|
@ -7,6 +9,7 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
|||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
|
|
@ -14,6 +17,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
|
|
@ -24,6 +29,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
@ -56,6 +63,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
|
|
@ -71,18 +79,24 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
@ -94,6 +108,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO
|
|||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
|
|
|
|||
|
|
@ -105,6 +105,21 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
|
|||
return resp
|
||||
}
|
||||
|
||||
func patchJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("patch %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
|
|
|
|||
|
|
@ -2,28 +2,111 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser
|
||||
// records a metric and returns without error so product flows stay usable.
|
||||
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
|
||||
|
||||
// FCM_SERVER_KEY = legacy HTTP API (often disabled in new Firebase projects).
|
||||
// Prefer FCM HTTP v1 via service account:
|
||||
// FCM_SERVICE_ACCOUNT_FILE=/path/to.json
|
||||
// or FCM_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
|
||||
func fcmServerKey() string {
|
||||
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
|
||||
}
|
||||
|
||||
func loadFCMServiceAccountJSON() ([]byte, error) {
|
||||
if path := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_FILE")); path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read FCM_SERVICE_ACCOUNT_FILE: %w", err)
|
||||
}
|
||||
return bytes.TrimSpace(b), nil
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_JSON")); raw != "" {
|
||||
return []byte(raw), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fcmServiceAccount struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
|
||||
type fcmLegacyPayload struct {
|
||||
To string `json:"to,omitempty"`
|
||||
Registration []string `json:"registration_ids,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
Notification map[string]string `json:"notification"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Registration []string `json:"registration_ids,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
Notification map[string]string `json:"notification"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type fcmV1MessageRequest struct {
|
||||
Message fcmV1Message `json:"message"`
|
||||
}
|
||||
|
||||
type fcmV1Message struct {
|
||||
Token string `json:"token"`
|
||||
Notification map[string]string `json:"notification,omitempty"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
fcmTokenMu sync.Mutex
|
||||
fcmTokenSource oauth2.TokenSource
|
||||
fcmProjectID string
|
||||
)
|
||||
|
||||
func fcmV1Ready(ctx context.Context) (projectID string, ts oauth2.TokenSource, err error) {
|
||||
fcmTokenMu.Lock()
|
||||
defer fcmTokenMu.Unlock()
|
||||
if fcmTokenSource != nil && fcmProjectID != "" {
|
||||
return fcmProjectID, fcmTokenSource, nil
|
||||
}
|
||||
raw, err := loadFCMServiceAccountJSON()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
var sa fcmServiceAccount
|
||||
if err := json.Unmarshal(raw, &sa); err != nil {
|
||||
return "", nil, fmt.Errorf("parse service account json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(sa.ProjectID) == "" {
|
||||
return "", nil, fmt.Errorf("service account json missing project_id")
|
||||
}
|
||||
creds, err := google.CredentialsFromJSON(ctx, raw, fcmMessagingScope)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("fcm credentials: %w", err)
|
||||
}
|
||||
fcmProjectID = sa.ProjectID
|
||||
fcmTokenSource = creds.TokenSource
|
||||
return fcmProjectID, fcmTokenSource, nil
|
||||
}
|
||||
|
||||
func collectFCMRegistrationIDs(tokens []DeviceToken) []string {
|
||||
regIDs := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if strings.HasPrefix(t.Token, "install:") {
|
||||
continue
|
||||
}
|
||||
regIDs = append(regIDs, t.Token)
|
||||
}
|
||||
return regIDs
|
||||
}
|
||||
|
||||
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
|
||||
|
|
@ -33,25 +116,89 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
|
|||
return 0, "no_device_tokens", nil
|
||||
}
|
||||
|
||||
key := fcmServerKey()
|
||||
if key == "" {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "fcm_not_configured", nil
|
||||
}
|
||||
|
||||
// Skip placeholder install:* tokens — they are not real FCM registration IDs.
|
||||
regIDs := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if strings.HasPrefix(t.Token, "install:") {
|
||||
continue
|
||||
}
|
||||
regIDs = append(regIDs, t.Token)
|
||||
}
|
||||
regIDs := collectFCMRegistrationIDs(tokens)
|
||||
if len(regIDs) == 0 {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "only_placeholder_tokens", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
projectID, ts, v1err := fcmV1Ready(ctx)
|
||||
if v1err != nil {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", v1err
|
||||
}
|
||||
if projectID != "" && ts != nil {
|
||||
n, err := sendFCMv1(ctx, projectID, ts, regIDs, title, body, data)
|
||||
if err != nil {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", err
|
||||
}
|
||||
runtimeMetrics.recordPush(n, false)
|
||||
return n, "", nil
|
||||
}
|
||||
|
||||
// Legacy fallback when service account is not configured.
|
||||
key := fcmServerKey()
|
||||
if key == "" {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "fcm_not_configured", nil
|
||||
}
|
||||
n, err := sendFCMLegacy(key, regIDs, title, body, data)
|
||||
if err != nil {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", err
|
||||
}
|
||||
runtimeMetrics.recordPush(n, false)
|
||||
return n, "", nil
|
||||
}
|
||||
|
||||
func sendFCMv1(ctx context.Context, projectID string, ts oauth2.TokenSource, regIDs []string, title, body string, data map[string]string) (int, error) {
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("fcm access token: %w", err)
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", projectID)
|
||||
sent := 0
|
||||
var lastErr error
|
||||
for _, reg := range regIDs {
|
||||
payload := fcmV1MessageRequest{
|
||||
Message: fcmV1Message{
|
||||
Token: reg,
|
||||
Notification: map[string]string{"title": title, "body": body},
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return sent, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
lastErr = fmt.Errorf("fcm v1 returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
if sent == 0 && lastErr != nil {
|
||||
return 0, lastErr
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
func sendFCMLegacy(key string, regIDs []string, title, body string, data map[string]string) (int, error) {
|
||||
payload := fcmLegacyPayload{
|
||||
Registration: regIDs,
|
||||
Priority: "high",
|
||||
|
|
@ -61,7 +208,7 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
|
|||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "key="+key)
|
||||
|
|
@ -69,14 +216,11 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
|
|||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", err
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode)
|
||||
return 0, fmt.Errorf("fcm returned %d", resp.StatusCode)
|
||||
}
|
||||
runtimeMetrics.recordPush(len(regIDs), false)
|
||||
return len(regIDs), "", nil
|
||||
return len(regIDs), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
# Flutter web + reverse-proxy to core-backend (same origin for msn.iykyka.com).
|
||||
# API/WS paths match core-backend routes used by mobile/lib.
|
||||
# Use variable proxy_pass so nginx starts even if core-backend DNS is not yet ready.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Docker Compose embedded DNS
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Flutter SPA
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Core API + WebSocket (internal service name from docker-compose)
|
||||
location ~ ^/(health|demo|auth|invites|admin|conversations|messages|users|ws)(/|$) {
|
||||
set $upstream_core core-backend:8080;
|
||||
proxy_pass http://$upstream_core;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# 와카뷰 — msn.iykyka.com (Plan A / N2-B)
|
||||
# Postgres + core-backend + ai-service(internal) + Flutter web (nginx proxies API).
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # fill secrets
|
||||
# docker compose up -d --build
|
||||
# # edge proxy → web:80 (AI/Postgres not published)
|
||||
#
|
||||
# Local smoke without domain:
|
||||
# PUBLIC_API_BASE=http://localhost:8088 docker compose up -d --build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ykavu}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ykavu}
|
||||
volumes:
|
||||
- ykavu_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ykavu} -d ${POSTGRES_DB:-ykavu}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
# N2-A: no host port — internal only
|
||||
|
||||
ai-service:
|
||||
build:
|
||||
context: ./ai-service
|
||||
environment:
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8001/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# N2-A3: internal only — do not publish ports
|
||||
|
||||
core-backend:
|
||||
build:
|
||||
context: ./core-backend
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-ykavu}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ykavu}?sslmode=disable
|
||||
AI_SERVICE_URL: http://ai-service:8001
|
||||
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env}
|
||||
ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1}
|
||||
FCM_SERVER_KEY: ${FCM_SERVER_KEY:-}
|
||||
# Prefer HTTP v1 (service account). Mount secrets/firebase-service-account.json on the host.
|
||||
FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-/secrets/firebase-service-account.json}
|
||||
FCM_SERVICE_ACCOUNT_JSON: ${FCM_SERVICE_ACCOUNT_JSON:-}
|
||||
volumes:
|
||||
- ./secrets:/secrets:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ai-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# Not published — reached via web nginx proxy (same origin)
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: mobile/Dockerfile
|
||||
args:
|
||||
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com}
|
||||
ports:
|
||||
- "${WEB_HOST_PORT:-8088}:80"
|
||||
depends_on:
|
||||
core-backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# Join Nginx Proxy Manager network so msn.iykyka.com can reach web by name.
|
||||
networks:
|
||||
- default
|
||||
- npm
|
||||
|
||||
volumes:
|
||||
ykavu_pgdata:
|
||||
|
||||
networks:
|
||||
default:
|
||||
npm:
|
||||
external: true
|
||||
name: nginx-proxy_default
|
||||
|
|
@ -29,18 +29,18 @@
|
|||
## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7)
|
||||
|
||||
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
||||
현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다.
|
||||
회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다.
|
||||
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
||||
PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다.
|
||||
|
||||
| # | 질문 | 잠정 결정 | 상태 |
|
||||
| # | 질문 | 결정 | 상태 |
|
||||
|---|------|------|------|
|
||||
| Q1 | AI 와카뷰로 확정? | 예 | 제안 — 근거는 `decision-log.md` |
|
||||
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 |
|
||||
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 |
|
||||
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 |
|
||||
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 |
|
||||
| Q6 | 서비스 이름 | "와카뷰" (가칭) | 제안 (가칭) |
|
||||
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 |
|
||||
| Q1 | AI 와카뷰로 확정? | 예 | **확정** — `decision-log.md` |
|
||||
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | **확정** |
|
||||
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 (시작 기본값은 PoC 후) | **확정** (하위 기본값은 열림) |
|
||||
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | **확정** (실사용은 PoC) |
|
||||
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | **확정** |
|
||||
| Q6 | 서비스 이름 | "와카뷰 (Ykavu)" | **확정** (2026-07-31 명칭 변경) |
|
||||
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | **확정** |
|
||||
|
||||
## 3. MVP 시나리오 좁히기
|
||||
|
||||
|
|
@ -147,10 +147,13 @@
|
|||
PoC #3 역할극 자극재로 사용
|
||||
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
|
||||
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
|
||||
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영)
|
||||
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) — **맨 마지막(N5/D)**
|
||||
- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md`
|
||||
- [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정
|
||||
- [x] Q1~Q7 정식 확정 — `decision-log.md` (Phase 1 C, 2026-07-30). PoC 의존 하위만 열림
|
||||
- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과
|
||||
무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠
|
||||
- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md`
|
||||
Phase 1 §1, 회의 필요
|
||||
- [x] 기술 스택 결정 — `tech-design.md` §8 / `roadmap.md` Phase 1 §1
|
||||
(Flutter/Dart, Go core + Python AI, WebSocket, drift+SQLCipher; 배포 DB는
|
||||
[`deploy-checklist.md`](./deploy-checklist.md) N2-A — 프로덕션 DB는 **PostgreSQL** 확정)
|
||||
- [ ] A~C 이후 실행 트랙 — [`deploy-checklist.md`](./deploy-checklist.md)
|
||||
(N1 스모크 → N2 Docker → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
# 배포·잔여 작업 체크리스트 (와카뷰)
|
||||
|
||||
Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||
권위 문서: [`roadmap.md`](./roadmap.md) · [`decision-log.md`](./decision-log.md) · [`AGENTS.md`](../AGENTS.md).
|
||||
|
||||
**사람 PoC(D)와 Phase 1 §3 기본값은 맨 마지막.** 추측으로 채우지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 통합 현황 (Claude + Cursor)
|
||||
|
||||
### DONE — Claude (`claude/project-planning-approach-ukdz31` 계열)
|
||||
|
||||
- [x] 기획 문서 세트 (`PLANNING`, vision/PRD/tech-design/risk/roadmap, decision-log 초안)
|
||||
- [x] PoC #1/#3 계획·모집/역할극 자료·Q3 인터뷰 가이드·프로토타입 앵커
|
||||
- [x] `poc/tone-corpus/` 파이프라인 (전처리·draft·escalation·retrieve·blind_eval)
|
||||
- [x] 스택 확정: Flutter + Go core + Python AI
|
||||
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
|
||||
- [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선)
|
||||
|
||||
### DONE — Cursor 후속
|
||||
|
||||
- [x] A1/A2 API · A3 Flutter 메신저 연결 · API E2E (`scripts/e2e_a3.py`)
|
||||
- [x] Phase 1 B (drift/SQLCipher, FCM 골격, sessions, metrics, data-flow, identity)
|
||||
- [x] Phase 1 C (Q1~Q7 **확정**, invite-ops, Android release 경로)
|
||||
- [x] Flutter Web SQLCipher stub · Twin Shadow UI · CORS · `DEMO-YKAVU`
|
||||
- [x] GitHub + Gitea 듀얼 리모트 (`scripts/push-both.sh`)
|
||||
|
||||
### NOW
|
||||
|
||||
- 앱 코드는 클로즈드 베타 직전 수준
|
||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
||||
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
|
||||
- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음
|
||||
|
||||
### NEXT 순서
|
||||
|
||||
```
|
||||
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
|
||||
→ N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
|
||||
```
|
||||
|
||||
### LOCKED
|
||||
|
||||
- [ ] Phase 2+ (L3 / OS 레이어 / L4 / B2B) — Phase 1 게이트 전 구현 금지
|
||||
- [ ] PoC §3 기본값(자율성 시작 레벨, 화이트리스트 기본 주제, 신뢰 UX 최종 카피) 추측 금지
|
||||
|
||||
---
|
||||
|
||||
## 항목 템플릿
|
||||
|
||||
각 ID를 처리할 때 아래로 상태를 갱신한다.
|
||||
|
||||
```text
|
||||
Status: todo | doing | done | blocked
|
||||
Depends on:
|
||||
Acceptance: (체크리스트)
|
||||
Notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## N1 — 배포 전 스모크
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N1-1** | 서비스 기동 | **done** (2026-07-31) | `8080`/`8001`/`5555` health·web 200. 코어를 현재 `main`으로 재기동 (`ADMIN_API_TOKEN=dev-admin-token`, `ALLOW_DEMO_INVITE=1`) |
|
||||
| **N1-2** | API E2E | **done** (2026-07-31) | `python3 scripts/e2e_a3.py` → **16 passed, 0 failed** |
|
||||
| **N1-3** | Web 가입 스모크 | **done** (API) | `DEMO-YKAVU` 재사용 가입 + 토큰 발급 확인. `/demo` → `DEMO-YKAVU`. (브라우저 클릭 스모크는 로컬/테스터) |
|
||||
| **N1-4** | 말투 온보딩 스모크 | **done** (코드경로) | 온보딩은 기기 로컬(drift); API 스모크에서는 대화 목록 진입 경로까지 확인. UI 탭은 테스터 |
|
||||
| **N1-5** | 핵심 메신저 스모크 | **done** (API) | DEMO 두 유저 → 연락처 → 대화 → 메시지. E2E에 draft/L1·거부권·화이트리스트 포함 |
|
||||
| **N1-6** | 프로덕션 CORS/API 메모 | **done** (2026-07-31) | 아래 Notes |
|
||||
|
||||
로컬 포트 참고: 앱 **5555**, 코어 **8080**, AI **8001**. Dart VM Service 고포트(예: 39369)는 디버그용 — 무시 가능.
|
||||
데모 초대 코드(현재): **`DEMO-YKAVU`** (구 `DEMO-BUNSIN` 아님).
|
||||
|
||||
### N1-6 Notes — `msn.iykyka.com` 변경 목록
|
||||
|
||||
| 항목 | 로컬 지금 | 프로덕션 필요 |
|
||||
|------|-----------|----------------|
|
||||
| Flutter `CORE_API_BASE` | `http://127.0.0.1:8080` / emulator `10.0.2.2` | `https://msn.iykyka.com` (또는 API 서브경로/서브도메인 — compose에서 확정) |
|
||||
| CORS Allow-Origin | `corsMiddleware()`가 요청 `Origin` 반사(로컬 `localhost:5555` 확인됨) | 동일 미들웨어면 same-origin 또는 `https://msn.iykyka.com` Origin 허용. 와일드카드+Credentials 조합 주의 |
|
||||
| AI | `AI_SERVICE_URL=http://127.0.0.1:8001` | compose 내부 `http://ai-service:8001` (외부 미노출, N2-A3) |
|
||||
| DB | SQLite `dev.db` | `DATABASE_URL=postgres://…` (N2-A2) |
|
||||
| Admin | `ADMIN_API_TOKEN` env | Portainer secret (N2-A5) |
|
||||
| Demo | `ALLOW_DEMO_INVITE=1`, `DEMO-YKAVU` | on 유지 (N2-A6) |
|
||||
| WebSocket | `ws://host:8080` | `wss://msn.iykyka.com` (`AppConfig.wsBase`) |
|
||||
|
||||
---
|
||||
|
||||
## N2 — Docker 배포 (`https://msn.iykyka.com`) — Plan A
|
||||
|
||||
### N2-A. 착수 전 결정 (Master 확인)
|
||||
|
||||
| ID | 결정 | 값 | Status |
|
||||
|----|------|-----|--------|
|
||||
| **N2-A1** | 구 Node MSN 교체 | **교체(Plan A)** — `msn.iykyka.com`에 와카뷰 스택으로 컷오버 | **done** (2026-07-31 Master 확정) |
|
||||
| **N2-A2** | DB | **PostgreSQL** (`tech-design.md` §8). compose에 `postgres` 서비스 + `DATABASE_URL`. SQLite는 로컬/테스트 전용 | **done** (2026-07-31 Master 확정) |
|
||||
| **N2-A3** | AI 서비스 노출 | **내부망만** — 외부 포트/도메인 미공개, core-backend만 `AI_SERVICE_URL`로 호출 | **done** (2026-07-31 Master 확정) |
|
||||
| **N2-A4** | 클라이언트 제공 | **Web 우선** — compose에 Flutter web 서빙. 내부 APK는 N4/릴리즈 경로로 후속 | **done** (2026-07-31 Master 확정) |
|
||||
| **N2-A5** | 시크릿 관리 | **Portainer/호스트 env만** — `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, DB 비밀번호, FCM 등 **git 커밋 금지** | **done** (2026-07-31 Master 확정) |
|
||||
| **N2-A6** | 데모 초대 | 프로덕션 **`ALLOW_DEMO_INVITE=1` (on)** — 테스터용 `DEMO-YKAVU` 유지. 베타 확대 전 재검토 | **done** (2026-07-31 Master 확정) |
|
||||
|
||||
N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/compose, Postgres 포함)**.
|
||||
|
||||
### N2-B. 이미지·compose
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N2-B1** | `core-backend` Dockerfile | **done** | `core-backend/Dockerfile` — 이미지 빌드 성공 (`GOTOOLCHAIN=auto`, CGO) |
|
||||
| **N2-B2** | `ai-service` Dockerfile | **done** | `ai-service/Dockerfile` — 이미지 빌드 성공 |
|
||||
| **N2-B3** | Flutter web 빌드/서빙 | **done** | `mobile/Dockerfile` (flutter multi-stage) + `deploy/nginx-web.conf` (API/WS 프록시). 대안: `mobile/Dockerfile.prebuilt` |
|
||||
| **N2-B4** | `docker-compose.yml` | **done** | root `docker-compose.yml` — postgres + ai(internal) + core(internal) + web 포트 |
|
||||
| **N2-B5** | env 템플릿 | **done** | `.env.example`에 Postgres/`PUBLIC_API_BASE`/`WEB_HOST_PORT` 등 추가 |
|
||||
| **N2-B6** | 데이터 볼륨 | **done** | compose 볼륨 `ykavu_pgdata` |
|
||||
| **N2-B7** | 내부 DNS | **done** (정의) | compose 서비스명 `postgres` / `ai-service` / `core-backend`. *에이전트 VM은 bridge TCP 제한으로 런타임 검증 불가 — Portainer 호스트에서 확인* |
|
||||
| **N2-B8** | CORS + API base | **done** (2026-07-31) | `PUBLIC_API_BASE=https://msn.iykyka.com`. OPTIONS CORS + 가입 확인 |
|
||||
| **N2-B9** | 리버스 프록시 | **done** (2026-07-31) | Nginx Proxy Manager host #7 → `ykavu-web-1:80` |
|
||||
| **N2-B10** | Portainer 스택 | **done** (compose CLI) | 서버 `~/project/ykavu` 에서 `docker compose up -d --build` (Portainer UI 아님) |
|
||||
| **N2-B11** | 구 MSN 컷오버 | **done** (2026-07-31) | `iykyk_msn-service` stop. NPM `msn.iykyka.com` → 와카뷰. 롤백: MSN start + NPM upstream 복구 |
|
||||
| **N2-B12** | 배포 스모크 | **done** (2026-07-31) | `/health`=`status`, `/demo`=`DEMO-YKAVU`, root 200, DEMO 가입 OK |
|
||||
| **N2-B13** | 운영 runbook | **done** | [`deploy-docker.md`](./deploy-docker.md) · [`deploy-portainer.md`](./deploy-portainer.md) |
|
||||
|
||||
관련: 라이브 `https://msn.iykyka.com` · 서버 path `~/project/ykavu` · web 포트 `8788`(+ NPM). 시크릿 git 금지.
|
||||
|
||||
---
|
||||
|
||||
## N3 — 배포 직후 안정화
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N3-1** | 헬스/로그 | **done** (2026-07-31) | 전 컨테이너 healthy. 공개 `/health` OK. 최근 로그에 OOM/502 없음 |
|
||||
| **N3-2** | 초대 발급 리허설 | **done** (2026-07-31) | `POST /invites` note=`N3-rehearsal` 발급·`GET /invites` 목록 확인 |
|
||||
| **N3-3** | admin metrics | **done** (2026-07-31) | Bearer로 `/admin/metrics`·`/admin/dashboard` 200 |
|
||||
| **N3-4** | Gemini | **done** (2026-07-31) | 서버 `GEMINI_API_KEY` 주입·ai-service 재기동. 라이브 draft `status=ok` (예: `딱히? ㅋㅋ`) |
|
||||
| **N3-5** | 백업 리허설 | **done** (2026-07-31) | `pg_dump` gzip → restore test DB → drop. [`ops-backup.md`](./ops-backup.md) |
|
||||
| **N3-6** | 테스터 안내 | **done** (2026-07-31) | [`tester-guide.md`](./tester-guide.md) |
|
||||
|
||||
---
|
||||
|
||||
## N4 — 베타 품질 잔여
|
||||
|
||||
### Track A — 메신저 UX (대화 열기 경로)
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N4-A1** | 내 사용자 ID 표시·복사 | done | 대화목록·연락처에 `MyUserIdChip` |
|
||||
| **N4-A2** | 연락처 원탭 대화 + ID 필수 | done | 숫자 peer ID 없으면 추가/대화 차단·안내 |
|
||||
| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 |
|
||||
| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 |
|
||||
| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) |
|
||||
| **N4-A6** | ID 없는 연락처 수정(PATCH)·배너 | done | 「ID 입력」으로 peer ID 보강 |
|
||||
|
||||
### Track B — 데모 콘텐츠 (테스터 페어링)
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N4-B1** | `/demo` pairing_steps·notes | done | GET `/demo`에 페어링 단계 |
|
||||
| **N4-B2** | 테스터 가이드 페어링 문서화 | done | `docs/tester-guide.md` ID 교환 플로우 |
|
||||
| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 |
|
||||
| **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) |
|
||||
|
||||
### FCM — [`fcm-setup.md`](./fcm-setup.md)
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 |
|
||||
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
|
||||
| **N4-3** | 서버 FCM 자격증명 (HTTP v1) | doing | Master: `secrets/firebase-service-account.json` (레거시 서버 키 대신) |
|
||||
| **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
|
||||
|
||||
### Android UI 탭 (`mobile/README.md`)
|
||||
|
||||
API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.com` → e2e **16/16**, 2026-07-31).
|
||||
실기기/에뮬레이터 **화면 탭**은 Master 로컬에서 1회.
|
||||
|
||||
| ID | 작업 | Status | 비고 |
|
||||
|----|------|--------|------|
|
||||
| **N4-5** | 가입 → 말투 저장 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-8** | 에스컬레이션 → 사후알림 함 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-9** | 되돌리기·거부권 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | api-done | 실기기 UI 탭 남음 |
|
||||
|
||||
### 후순위
|
||||
|
||||
| ID | 작업 | Status | 비고 |
|
||||
|----|------|--------|------|
|
||||
| **N4-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 |
|
||||
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
|
||||
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
||||
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
||||
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
|
||||
|
||||
---
|
||||
|
||||
## N5 — 사람 PoC (D) — **지금 열지 않음**
|
||||
|
||||
N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` Phase 1 §3과 동일.
|
||||
|
||||
| ID | 작업 | Status |
|
||||
|----|------|--------|
|
||||
| **N5-1** | PoC #1/#3 참가자 모집·실행 | locked |
|
||||
| **N5-2** | Q3 인터뷰 → 자율성 기본값 | locked |
|
||||
| **N5-3** | 화이트리스트 기본 주제 | locked |
|
||||
| **N5-4** | 신뢰 UX 문구/위치 확정 | locked |
|
||||
| **N5-5** | vision 게이트 → 실제 클로즈드 베타 오픈 | locked |
|
||||
|
||||
자료: [`poc-plan.md`](./poc-plan.md) · [`poc-materials.md`](./poc-materials.md) · [`user-interview-guide.md`](./user-interview-guide.md).
|
||||
|
||||
---
|
||||
|
||||
## 바로 다음 5개 (권장)
|
||||
|
||||
1. ~~N2-A1~A6 결정 체크~~ **done**
|
||||
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
|
||||
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
|
||||
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
|
||||
5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10)
|
||||
|
||||
|
||||
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Docker 배포 (N2-B) — `msn.iykyka.com`
|
||||
|
||||
체크리스트: [`deploy-checklist.md`](./deploy-checklist.md) N2-B.
|
||||
결정: N2-A (Postgres, AI 내부망, Web 우선, 시크릿 env, `ALLOW_DEMO_INVITE=1`).
|
||||
|
||||
## 구성
|
||||
|
||||
| 서비스 | 역할 | 호스트 노출 |
|
||||
|--------|------|-------------|
|
||||
| `postgres` | PostgreSQL 16 | 아니오 (볼륨 `ykavu_pgdata`) |
|
||||
| `ai-service` | FastAPI draft/escalate | 아니오 |
|
||||
| `core-backend` | Go API + WS | 아니오 (web nginx가 프록시) |
|
||||
| `web` | Flutter web + nginx | `WEB_HOST_PORT`→80 (기본 8088) |
|
||||
|
||||
엣지(Caddy/Traefik/기존 프록시)는 **`web:80`만** `https://msn.iykyka.com`에 연결하면 된다.
|
||||
|
||||
## 로컬 / 서버
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 최소: ADMIN_API_TOKEN, POSTGRES_PASSWORD, (선택) GEMINI_API_KEY
|
||||
# 로컬 스모크:
|
||||
# PUBLIC_API_BASE=http://localhost:8088
|
||||
|
||||
docker compose up -d --build
|
||||
curl -sS http://localhost:8088/health
|
||||
curl -sS http://localhost:8088/demo
|
||||
```
|
||||
|
||||
Portainer·OpenResty 컷오버 상세: [`deploy-portainer.md`](./deploy-portainer.md).
|
||||
서버 원샷: `./scripts/server-up.sh`
|
||||
|
||||
## 시크릿
|
||||
|
||||
- git에 `.env` 커밋 금지 (`AGENTS.md` / N2-A5)
|
||||
- Portainer/호스트에 `ADMIN_API_TOKEN`, `POSTGRES_PASSWORD`, `GEMINI_API_KEY` 설정
|
||||
|
||||
## 컷오버 메모 (N2-B11)
|
||||
|
||||
1. 새 스택을 임시 포트 또는 스테이징로 Up → 스모크
|
||||
2. 기존 Node MSN 중지
|
||||
3. 리버스 프록시를 `web:80`으로 전환
|
||||
4. 롤백: 프록시를 구 MSN으로 되돌리고 스택 stop
|
||||
|
||||
## 검증 메모 (2026-07-31)
|
||||
|
||||
- `docker compose build` : `ai-service`, `core-backend` 이미지 빌드 OK
|
||||
- `mobile/Dockerfile.prebuilt` + `nginx -t` OK (API upstream 지연 해석)
|
||||
- 일부 샌드박스/에이전트 VM에서는 Docker **bridge 네트워크 TCP가 막혀**
|
||||
컨테이너 간 `postgres:5432` 연결이 타임아웃될 수 있음. **Portainer가 돌아가는
|
||||
실제 호스트에서는 기본 bridge compose를 사용**하면 된다.
|
||||
- Flutter multi-stage (`mobile/Dockerfile`)는 이미지 용량이 크므로 Portainer 빌드
|
||||
시 시간 여유를 둔다. 급하면 호스트에서 `flutter build web` 후 `Dockerfile.prebuilt`.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Portainer 배포 · 컷오버 (N2-B8~B12)
|
||||
|
||||
대상: `https://msn.iykyka.com`
|
||||
스택 정의: 저장소 root [`docker-compose.yml`](../docker-compose.yml)
|
||||
개요: [`deploy-docker.md`](./deploy-docker.md)
|
||||
|
||||
## 라이브 상태 (2026-07-31 컷오버 완료)
|
||||
|
||||
| URL | 결과 |
|
||||
|-----|------|
|
||||
| `https://msn.iykyka.com/health` | `{"status":"ok"}` — 와카뷰 |
|
||||
| `https://msn.iykyka.com/demo` | `DEMO-YKAVU` |
|
||||
| `https://msn.iykyka.com/` | Flutter web 200 |
|
||||
| 서버 path | `~/project/ykavu` (`docker compose`) |
|
||||
| web host port | `8788` (NPM이 `ykavu-web-1:80`으로 프록시) |
|
||||
| 구 MSN | `iykyk_msn-service` **stopped** (롤백 시 start) |
|
||||
| 엣지 | Nginx Proxy Manager (`nginx-proxy-app-1`, openresty) |
|
||||
|
||||
`docker-compose.yml`의 `web` 서비스는 external network `nginx-proxy_default`에 연결됨.
|
||||
|
||||
---
|
||||
|
||||
## A. Portainer 스택 생성
|
||||
|
||||
1. Portainer → **Stacks** → **Add stack**
|
||||
2. Build method: **Repository** (권장)
|
||||
- Repository URL: `https://gitea.iykyka.com/oh/iykyka.git` (또는 GitHub `o0kuma/hikikomori`)
|
||||
- Compose path: `docker-compose.yml`
|
||||
- Branch: `main`
|
||||
3. **Environment variables** (시크릿 — git 금지):
|
||||
|
||||
| Name | 예 |
|
||||
|------|-----|
|
||||
| `ADMIN_API_TOKEN` | 긴 랜덤 |
|
||||
| `POSTGRES_PASSWORD` | 긴 랜덤 |
|
||||
| `POSTGRES_USER` | `ykavu` |
|
||||
| `POSTGRES_DB` | `ykavu` |
|
||||
| `GEMINI_API_KEY` | (있으면) |
|
||||
| `ALLOW_DEMO_INVITE` | `1` |
|
||||
| `PUBLIC_API_BASE` | `https://msn.iykyka.com` |
|
||||
| `WEB_HOST_PORT` | `8088` (컷오버 전 임시) → 전환 후 openresty가 가리키는 포트에 맞춤 |
|
||||
| `FCM_SERVER_KEY` | (없으면 비움) |
|
||||
|
||||
4. Deploy the stack → 빌드 완료까지 대기 (Flutter multi-stage는 수 분~십수 분)
|
||||
5. 호스트에서 확인:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8088/health # → {"status":"ok"}
|
||||
curl -sS http://127.0.0.1:8088/demo # → DEMO-YKAVU
|
||||
```
|
||||
|
||||
### SSH로 올릴 때
|
||||
|
||||
```bash
|
||||
git clone https://gitea.iykyka.com/oh/iykyka.git ykavu && cd ykavu
|
||||
cp .env.example .env # 값 채움
|
||||
./scripts/server-up.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B. OpenResty 컷오버 (N2-B11)
|
||||
|
||||
1. **병렬 기동**: 새 스택을 `WEB_HOST_PORT=8088`(또는 빈 포트)로 Up, 구 MSN은 유지
|
||||
2. 로컬 스모크: `/health` → `status`, `/demo` → `DEMO-YKAVU`, 브라우저로 `http://HOST:8088/` 가입
|
||||
3. OpenResty upstream을 **구 Express → `127.0.0.1:8088`(web)** 로 변경 후 reload
|
||||
4. 공개 URL 스모크:
|
||||
- `https://msn.iykyka.com/health` → `{"status":"ok"}`
|
||||
- `https://msn.iykyka.com/demo` → demo JSON
|
||||
- 회원가입 `DEMO-YKAVU`
|
||||
5. 구 Node MSN 컨테이너/프로세스 중지
|
||||
6. **롤백**: upstream을 구 MSN으로 되돌리고 openresty reload
|
||||
|
||||
WebSocket: openresty에서 `/ws` 에 `Upgrade` / `Connection` 헤더 전달 필요
|
||||
(nginx `proxy_set_header Upgrade $http_upgrade` 와 동일).
|
||||
|
||||
---
|
||||
|
||||
## C. 배포 스모크 체크 (N2-B12)
|
||||
|
||||
- [ ] `GET /health` → `{"status":"ok"}`
|
||||
- [ ] `GET /demo` → `demo_invite_code=DEMO-YKAVU`
|
||||
- [ ] 브라우저 가입 → 말투 온보딩 → 대화 목록
|
||||
- [ ] (가능 시) draft/L1 1회
|
||||
- [ ] AI·Postgres 호스트 포트 미노출 확인
|
||||
|
||||
---
|
||||
|
||||
## Master에게 필요한 것 (에이전트 대행 시)
|
||||
|
||||
아래 중 **하나**만 있으면 N2-B8~B12를 에이전트가 이어서 실행할 수 있다.
|
||||
|
||||
1. SSH: `iykyka@iykyka.com:7788` 용 **private key** (또는 일시 비밀번호 — 채팅 대신 시크릿 채널 권장)
|
||||
2. Portainer **API access token** + endpoint id
|
||||
3. Master가 A~C를 직접 수행한 뒤 결과만 공유
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# FCM 설정 (N4-1 ~ N4-4)
|
||||
|
||||
서버·클라이언트의 푸시 **코드 경로는 준비됨**.
|
||||
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
|
||||
|
||||
## 이미 된 것 (코드)
|
||||
|
||||
| 계층 | 동작 |
|
||||
|------|------|
|
||||
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
|
||||
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
|
||||
| core-backend | `notifyUser` + `POST /admin/push-test` — **FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 |
|
||||
|
||||
## Master가 할 일
|
||||
|
||||
### N4-1 — Firebase Android 앱
|
||||
|
||||
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
|
||||
package: **`com.ykavu.ykavu_mobile`**
|
||||
2. `google-services.json`을 로컬에만 배치 (git 금지):
|
||||
|
||||
```bash
|
||||
cp ~/Downloads/google-services.json mobile/android/app/google-services.json
|
||||
```
|
||||
|
||||
### N4-3 — 서비스 계정 JSON (HTTP v1)
|
||||
|
||||
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 아래를 쓴다.
|
||||
|
||||
1. Google Cloud → 사용자 인증 정보 → 서비스 계정 만들기
|
||||
(API: Firebase Cloud Messaging API, 데이터: **애플리케이션 데이터**)
|
||||
2. 역할: **Firebase Cloud Messaging Admin** (없으면 Firebase 관리자 / 임시 소유자)
|
||||
3. 키 유형 **JSON** 다운로드
|
||||
4. 서버(또는 이 워크스페이스)에 배치:
|
||||
|
||||
```bash
|
||||
# 파일명 고정
|
||||
mkdir -p secrets
|
||||
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json
|
||||
chmod 600 secrets/firebase-service-account.json
|
||||
```
|
||||
|
||||
5. 프로덕션 호스트에도 동일 파일:
|
||||
|
||||
```bash
|
||||
# 예: scp 후
|
||||
cd ~/project/ykavu
|
||||
# secrets/firebase-service-account.json 존재 확인
|
||||
docker compose up -d --build core-backend
|
||||
```
|
||||
|
||||
`docker-compose.yml`이 `./secrets` → 컨테이너 `/secrets`로 마운트하고
|
||||
`FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json`을 읽는다.
|
||||
|
||||
**git / 채팅에 JSON 내용을 붙여넣지 않는다.**
|
||||
|
||||
### N4-2 / N4-4 — 실기기 스모크
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
|
||||
# 로그: device token registered (FCM)
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://msn.iykyka.com/admin/push-test \
|
||||
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}'
|
||||
```
|
||||
|
||||
기대: `sent >= 1`.
|
||||
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
|
||||
`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인.
|
||||
|
||||
## Web
|
||||
|
||||
Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Postgres 백업·복구 (N3-5)
|
||||
|
||||
서버 path: `~/project/ykavu`
|
||||
덤프 위치: `~/backups/ykavu/ykavu-YYYYMMDDThhmmssZ.sql.gz`
|
||||
|
||||
## 백업
|
||||
|
||||
```bash
|
||||
cd ~/project/ykavu
|
||||
./scripts/backup-postgres.sh
|
||||
# 또는
|
||||
docker compose exec -T postgres pg_dump -U ykavu ykavu | gzip > ~/backups/ykavu/ykavu-$(date -u +%Y%m%dT%H%M%SZ).sql.gz
|
||||
```
|
||||
|
||||
리허설(2026-07-31): 덤프 → `ykavu_restore_test` DB로 복구 → `\dt` 11테이블 · users count 확인 → 테스트 DB 삭제 OK.
|
||||
|
||||
## 복구 (주의: 운영 DB 덮어쓰기)
|
||||
|
||||
```bash
|
||||
cd ~/project/ykavu
|
||||
# 1) 서비스 중지 권장
|
||||
docker compose stop core-backend web
|
||||
# 2) 기존 DB 드롭/재생성 또는 새 DB로 검증 후 전환
|
||||
gunzip -c ~/backups/ykavu/ykavu-XXXX.sql.gz | docker compose exec -T postgres psql -U ykavu -d ykavu
|
||||
docker compose start core-backend web
|
||||
```
|
||||
|
||||
권장: 먼저 별도 DB(`ykavu_restore_test`)에 복구해 검증한 뒤 컷오버.
|
||||
|
|
@ -212,10 +212,19 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
|||
|
||||
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
|
||||
- §3 항목과 동일. A~C 완료 후에만 착수.
|
||||
**단, 프로덕션 Docker 배포·스모크(N1~N3)는 D보다 앞** — [`deploy-checklist.md`](./deploy-checklist.md).
|
||||
|
||||
##### E. 베타 이후 (지금은 설계만, 구현 금지)
|
||||
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
|
||||
|
||||
#### 6. 배포·잔여 작업 (A~C 이후 실행 트랙)
|
||||
|
||||
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
|
||||
|
||||
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
|
||||
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
|
||||
본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
|
||||
|
||||
|
||||
## Phase 2 — L3 확장 + 베타 확대
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
# 와카뷰 테스터 안내 (N3-6 / Track B)
|
||||
|
||||
## 접속
|
||||
|
||||
- 웹: **https://msn.iykyka.com**
|
||||
- 공용 데모 초대 코드: **`DEMO-YKAVU`**
|
||||
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능)
|
||||
|
||||
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
|
||||
|
||||
API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함.
|
||||
|
||||
## 권장 페어링 플로우 (5~10분)
|
||||
|
||||
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
|
||||
|
||||
1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장)
|
||||
2. **말투 샘플** — 몇 줄 적거나 스킵
|
||||
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
|
||||
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
|
||||
5. **메시지** — 사람 모드로 한두 줄 주고받기
|
||||
6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기
|
||||
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요.
|
||||
|
||||
### ID 없는 옛 연락처
|
||||
|
||||
이름만 넣고 ID를 비운 연락처는 대화가 안 됩니다. 연락처 화면에서 **「ID 입력」**으로 숫자 ID를 채우면 됩니다 (삭제 후 재추가 불필요).
|
||||
|
||||
## 알아둘 점
|
||||
|
||||
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
|
||||
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
|
||||
- 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`).
|
||||
- 문제/스크린샷은 Master에게 전달해 주세요.
|
||||
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
||||
|
||||
## 개인 초대 (운영자)
|
||||
|
||||
공용 코드 대신 1회용 코드가 필요하면:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://msn.iykyka.com/invites \
|
||||
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"note":"테스터-이름","expires_in_days":14}'
|
||||
```
|
||||
|
||||
절차 상세: [`invite-ops.md`](./invite-ops.md)
|
||||
|
||||
## 장애 시 운영자 체크
|
||||
|
||||
```bash
|
||||
cd ~/project/ykavu
|
||||
docker compose ps
|
||||
curl -sS https://msn.iykyka.com/health
|
||||
docker compose logs --tail=100 core-backend ai-service web
|
||||
```
|
||||
|
||||
백업: `./scripts/backup-postgres.sh` (호스트에서)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Flutter Web → nginx. Build from repository root:
|
||||
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com .
|
||||
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
|
||||
FROM ${FLUTTER_IMAGE} AS build
|
||||
|
||||
WORKDIR /app
|
||||
ARG CORE_API_BASE=https://msn.iykyka.com
|
||||
|
||||
COPY mobile/pubspec.yaml mobile/pubspec.lock ./
|
||||
RUN flutter pub get
|
||||
|
||||
COPY mobile/ .
|
||||
RUN flutter build web --release \
|
||||
--dart-define=CORE_API_BASE=${CORE_API_BASE}
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/build/web /usr/share/nginx/html
|
||||
COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
|
||||
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke).
|
||||
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
|
||||
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
|
||||
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
|
||||
FROM nginx:1.27-alpine
|
||||
COPY mobile/build/web /usr/share/nginx/html
|
||||
COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
|
||||
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
|
||||
|
|
@ -63,9 +63,14 @@ HTTP로 검증한다.
|
|||
sudo apt-get install -y libsqlite3-dev libsqlcipher1
|
||||
```
|
||||
|
||||
## 푸시 (FCM)
|
||||
|
||||
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
|
||||
- Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`)
|
||||
|
||||
## 아직 없는 것
|
||||
|
||||
- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
|
||||
- Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4)
|
||||
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
|
||||
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
||||
- iOS 빌드 (v1 범위 밖)
|
||||
|
|
|
|||
|
|
@ -14,3 +14,6 @@ key.properties
|
|||
**/*.jks
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Firebase — never commit real project credentials (see docs/fcm-setup.md).
|
||||
app/google-services.json
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ plugins {
|
|||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Apply only when Master drops google-services.json (see docs/fcm-setup.md).
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER",
|
||||
"project_id": "ykavu-REPLACE",
|
||||
"storage_bucket": "ykavu-REPLACE.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:REPLACE:android:REPLACE",
|
||||
"android_client_info": {
|
||||
"package_name": "com.ykavu.ykavu_mobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "REPLACE_WITH_ANDROID_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="와카뷰"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ plugins {
|
|||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
|
|
|||
|
|
@ -175,6 +175,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
if (draft == null || draft.isEscalate || session.user == null) return;
|
||||
final text = _draftEdit.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
// L0: twin send is forbidden server-side — move text to human composer instead.
|
||||
if (session.autonomyLevel == AutonomyLevel.L0) {
|
||||
setState(() {
|
||||
_input.text = text;
|
||||
_pendingDraft = null;
|
||||
_draftEdit.clear();
|
||||
_banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final msg = await session.api.sendMessage(
|
||||
|
|
@ -278,6 +290,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
final level = context.watch<SessionState>().autonomyLevel;
|
||||
final isL0 = level == AutonomyLevel.L0;
|
||||
final title = isL0
|
||||
? '초안 (L0) — 직접 보내기'
|
||||
: level == AutonomyLevel.L1
|
||||
? 'L1 승인 — 수정 후 보내기'
|
||||
: '초안 (L2) — 승인 후 보내기';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -293,12 +313,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
children: [
|
||||
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'L1 승인 — 수정 후 보내기',
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isL0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _draftEdit,
|
||||
|
|
@ -319,7 +348,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _sendTwinApproved,
|
||||
child: const Text('승인하고 보내기'),
|
||||
child: Text(isL0 ? '입력창으로 옮기기' : '승인하고 보내기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/my_user_id_chip.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
|
|
@ -45,32 +46,44 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
final nameCtrl = TextEditingController();
|
||||
final peerCtrl = TextEditingController();
|
||||
final noteCtrl = TextEditingController();
|
||||
final session = context.read<SessionState>();
|
||||
final myId = session.user?.id;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('연락처 추가'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '표시 이름'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (선택)',
|
||||
helperText: '대화 시작에 필요',
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (myId != null) ...[
|
||||
MyUserIdChip(userId: myId),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '표시 이름',
|
||||
helperText: '목록에 보일 이름 (예: 친구 닉네임)',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (숫자, 필수)',
|
||||
helperText: '대화하려면 상대의 숫자 ID가 필요합니다. 이름만으로는 안 됩니다.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||
|
|
@ -79,20 +92,30 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
final session = context.read<SessionState>();
|
||||
final name = nameCtrl.text.trim();
|
||||
final peer = int.tryParse(peerCtrl.text.trim());
|
||||
if (name.isEmpty || session.user == null) return;
|
||||
if (peer == null) {
|
||||
setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 대화를 시작할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
if (peer == session.user!.id) {
|
||||
setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final peer = int.tryParse(peerCtrl.text.trim());
|
||||
final created = await session.api.createContact(
|
||||
userId: session.user!.id,
|
||||
displayName: name,
|
||||
contactUserId: peer,
|
||||
relationshipNote: noteCtrl.text.trim(),
|
||||
);
|
||||
setState(() => _contacts = [..._contacts, created]);
|
||||
setState(() {
|
||||
_contacts = [..._contacts, created];
|
||||
_error = null;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = '추가 실패 (${e.statusCode})');
|
||||
setState(() => _error = '추가 실패 (${e.statusCode}): ${e.body}');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +123,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
final session = context.read<SessionState>();
|
||||
final me = session.user;
|
||||
if (me == null || contact.contactUserId == null) {
|
||||
setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.');
|
||||
setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -110,13 +133,95 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editContact(Contact contact) async {
|
||||
final nameCtrl = TextEditingController(text: contact.displayName);
|
||||
final peerCtrl = TextEditingController(
|
||||
text: contact.contactUserId == null ? '' : '${contact.contactUserId}',
|
||||
);
|
||||
final noteCtrl = TextEditingController(text: contact.relationshipNote);
|
||||
final session = context.read<SessionState>();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
contact.contactUserId == null
|
||||
? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.'
|
||||
: '표시 이름·상대 ID·메모를 고칠 수 있습니다.',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '표시 이름'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
autofocus: contact.contactUserId == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (숫자, 필수)',
|
||||
helperText: '상대 대화 목록에 보이는 숫자 ID',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted || session.user == null) return;
|
||||
final name = nameCtrl.text.trim();
|
||||
final peer = int.tryParse(peerCtrl.text.trim());
|
||||
if (name.isEmpty) return;
|
||||
if (peer == null) {
|
||||
setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 합니다.');
|
||||
return;
|
||||
}
|
||||
if (peer == session.user!.id) {
|
||||
setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final updated = await session.api.updateContact(
|
||||
userId: session.user!.id,
|
||||
contactId: contact.id,
|
||||
displayName: name,
|
||||
contactUserId: peer,
|
||||
relationshipNote: noteCtrl.text.trim(),
|
||||
);
|
||||
setState(() {
|
||||
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
|
||||
_error = null;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = '수정 실패 (${e.statusCode}): ${e.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(Contact c) async {
|
||||
final session = context.read<SessionState>();
|
||||
if (session.user == null) return;
|
||||
|
|
@ -140,8 +245,15 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final me = context.watch<SessionState>().user?.id;
|
||||
final missingIdCount = _contacts.where((c) => c.contactUserId == null).length;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('연락처')),
|
||||
appBar: AppBar(
|
||||
title: const Text('연락처'),
|
||||
actions: [
|
||||
if (me != null) MyUserIdChip(userId: me, compact: true),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDialog,
|
||||
tooltip: '연락처 추가',
|
||||
|
|
@ -154,6 +266,29 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
children: [
|
||||
if (me != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: MyUserIdChip(userId: me),
|
||||
),
|
||||
if (missingIdCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Material(
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'사용자 ID가 없는 연락처 $missingIdCount개 — 「ID 입력」으로 숫자 ID를 채우면 대화를 시작할 수 있습니다.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
|
|
@ -161,17 +296,21 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
),
|
||||
if (_contacts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
||||
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.',
|
||||
'상대에게 내 ID를 알려 주고, 상대의 숫자 ID를 받아 추가하세요.\n'
|
||||
'표시 이름만 넣고 ID를 비우면 대화를 시작할 수 없습니다.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -192,17 +331,36 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
),
|
||||
title: Text(c.displayName, style: theme.textTheme.titleSmall),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
|
||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall,
|
||||
c.contactUserId == null
|
||||
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
|
||||
: [
|
||||
'사용자 #${c.contactUserId}',
|
||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: c.contactUserId == null
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.contactUserId != null)
|
||||
TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
|
||||
if (c.contactUserId != null) ...[
|
||||
FilledButton.tonal(
|
||||
onPressed: () => _startChat(c),
|
||||
child: const Text('대화'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '수정',
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
onPressed: () => _editContact(c),
|
||||
),
|
||||
] else
|
||||
FilledButton(
|
||||
onPressed: () => _editContact(c),
|
||||
child: const Text('ID 입력'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '삭제',
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
|
|
@ -210,6 +368,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
if (c.contactUserId == null) {
|
||||
_editContact(c);
|
||||
} else {
|
||||
_startChat(c);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../services/api_client.dart';
|
|||
import '../state/session_state.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/gradient_text.dart';
|
||||
import '../widgets/my_user_id_chip.dart';
|
||||
import 'autonomy_settings_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
|
|
@ -21,6 +22,7 @@ class ConversationListScreen extends StatefulWidget {
|
|||
|
||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||
List<ConversationSummary> _rooms = [];
|
||||
Map<int, String> _peerNames = {};
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
|
|
@ -39,7 +41,23 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
try {
|
||||
final list = await session.api.listConversations();
|
||||
list.sort((a, b) => b.id.compareTo(a.id));
|
||||
setState(() => _rooms = list);
|
||||
final names = <int, String>{};
|
||||
if (session.user != null) {
|
||||
try {
|
||||
final contacts = await session.api.listContacts(session.user!.id);
|
||||
for (final c in contacts) {
|
||||
if (c.contactUserId != null) {
|
||||
names[c.contactUserId!] = c.displayName;
|
||||
}
|
||||
}
|
||||
} on ApiException {
|
||||
// Names are optional enrichment.
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_rooms = list;
|
||||
_peerNames = names;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
|
||||
} finally {
|
||||
|
|
@ -47,20 +65,55 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
String _titleFor(ConversationSummary room, int? me) {
|
||||
if (me == null) return '대화방 #${room.id}';
|
||||
final peers = room.userIds.where((id) => id != me).toList();
|
||||
if (room.isGroup) return '그룹 #${room.id}';
|
||||
if (peers.isEmpty) return '나와의 대화';
|
||||
final peerId = peers.first;
|
||||
final name = _peerNames[peerId];
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
return '상대 #$peerId';
|
||||
}
|
||||
|
||||
String _subtitleFor(ConversationSummary room, int? me) {
|
||||
if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함';
|
||||
final peers = me == null ? const <int>[] : room.userIds.where((id) => id != me).toList();
|
||||
final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
|
||||
return '$peerPart · 방 #${room.id}';
|
||||
}
|
||||
|
||||
Future<void> _createConversation() async {
|
||||
final peerCtrl = TextEditingController();
|
||||
final session = context.read<SessionState>();
|
||||
final myId = session.user?.id;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('새 대화'),
|
||||
content: TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID',
|
||||
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (myId != null) ...[
|
||||
MyUserIdChip(userId: myId),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (숫자)',
|
||||
helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||
|
|
@ -69,15 +122,31 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
final session = context.read<SessionState>();
|
||||
final me = session.user;
|
||||
final peer = int.tryParse(peerCtrl.text.trim());
|
||||
if (me == null || peer == null) return;
|
||||
if (me == null) return;
|
||||
if (peer == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (peer == me.id) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final conv = await session.api.createConversation(userIds: [me.id, peer]);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
conversationId: conv.id,
|
||||
title: _peerNames[peer] ?? '상대 #$peer',
|
||||
),
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
} on ApiException catch (e) {
|
||||
|
|
@ -107,6 +176,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
appBar: AppBar(
|
||||
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
|
||||
actions: [
|
||||
if (me != null) MyUserIdChip(userId: me, compact: true),
|
||||
IconButton(
|
||||
tooltip: '사후 알림',
|
||||
onPressed: () {
|
||||
|
|
@ -163,12 +233,17 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Text(
|
||||
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (me != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: MyUserIdChip(userId: me),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
|
|
@ -196,15 +271,28 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
child: const Icon(Icons.chat_bubble_outline, size: 30, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'대화방이 없습니다',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.',
|
||||
'1) 내 ID를 상대에게 알려 주세요\n'
|
||||
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
|
||||
'3) 연락처에서 「대화」또는 행을 탭하세요',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
icon: const Icon(Icons.contacts_outlined),
|
||||
label: const Text('연락처 열기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -221,28 +309,34 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
),
|
||||
),
|
||||
title: Text(
|
||||
me == null ? '대화방 #${room.id}' : room.titleFor(me),
|
||||
_titleFor(room, me),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: room.twinDisabledByPeer
|
||||
? Row(
|
||||
children: [
|
||||
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'상대가 와카뷰를 거부함',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
if (room.twinDisabledByPeer) ...[
|
||||
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
_subtitleFor(room, me),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: room.twinDisabledByPeer
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
conversationId: room.id,
|
||||
title: me == null ? null : room.titleFor(me),
|
||||
title: _titleFor(room, me),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class DataFlowScreen extends StatelessWidget {
|
|||
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
||||
'채팅 릴레이: 보낸 메시지 본문\n'
|
||||
'계정: 표시 이름·초대 코드·세션 토큰\n'
|
||||
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후',
|
||||
'푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
|
||||
),
|
||||
_section(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,14 @@ class _DemoTestPanel extends StatelessWidget {
|
|||
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'페어링: 두 명이 각자 가입 → 내 사용자 ID를 교환 → 연락처에 상대 숫자 ID로 대화 시작',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -127,6 +127,21 @@ class ApiClient {
|
|||
return Contact.fromJson(json);
|
||||
}
|
||||
|
||||
Future<Contact> updateContact({
|
||||
required int userId,
|
||||
required int contactId,
|
||||
required String displayName,
|
||||
int? contactUserId,
|
||||
String relationshipNote = '',
|
||||
}) async {
|
||||
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
|
||||
'display_name': displayName,
|
||||
if (contactUserId != null) 'contact_user_id': contactUserId,
|
||||
'relationship_note': relationshipNote,
|
||||
});
|
||||
return Contact.fromJson(json);
|
||||
}
|
||||
|
||||
Future<void> deleteContact(int userId, int contactId) async {
|
||||
await _json('DELETE', '/users/$userId/contacts/$contactId');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Resolves a device push token for core-backend registration.
|
||||
///
|
||||
/// Prefer a real FCM registration token when Firebase is configured
|
||||
/// (`google-services.json` on Android). Otherwise fall back to a stable
|
||||
/// `install:` placeholder that the server intentionally skips for delivery.
|
||||
class PushTokenService {
|
||||
PushTokenService({this.storeInstallId, this.readInstallId});
|
||||
|
||||
/// Persist a newly generated install id (KV / secure storage).
|
||||
final Future<void> Function(String installId)? storeInstallId;
|
||||
|
||||
/// Load a previously stored install id.
|
||||
final Future<String?> Function()? readInstallId;
|
||||
|
||||
Future<({String token, String platform, bool isFcm})> resolve() async {
|
||||
final platform = _platformLabel();
|
||||
final fcm = await _tryFirebaseToken();
|
||||
if (fcm != null && fcm.isNotEmpty) {
|
||||
return (token: fcm, platform: platform, isFcm: true);
|
||||
}
|
||||
final installId = await _ensureInstallId();
|
||||
return (token: 'install:$installId', platform: platform, isFcm: false);
|
||||
}
|
||||
|
||||
Future<String?> _tryFirebaseToken() async {
|
||||
// Web push needs a separate Firebase web config + VAPID; skip until provided.
|
||||
if (kIsWeb) return null;
|
||||
if (defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (Firebase.apps.isEmpty) {
|
||||
await Firebase.initializeApp();
|
||||
}
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
final token = await messaging.getToken();
|
||||
if (token == null || token.isEmpty) return null;
|
||||
return token;
|
||||
} catch (e) {
|
||||
debugPrint('FCM token unavailable (using install placeholder): $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _ensureInstallId() async {
|
||||
final existing = await readInstallId?.call();
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final rand = Random.secure();
|
||||
final installId =
|
||||
List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
|
||||
await storeInstallId?.call(installId);
|
||||
return installId;
|
||||
}
|
||||
|
||||
String _platformLabel() {
|
||||
if (kIsWeb) return 'web';
|
||||
if (defaultTargetPlatform == TargetPlatform.android) return 'android';
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) return 'ios';
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
|
@ -7,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
import '../db/app_database.dart';
|
||||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/push_token_service.dart';
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
SessionState({ApiClient? api, AppDatabase? db})
|
||||
|
|
@ -148,22 +148,25 @@ class SessionState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
/// Registers a stable install id as the push token until Firebase Messaging
|
||||
/// is wired with a real FCM registration token (roadmap B).
|
||||
/// Registers a real FCM token when Firebase is configured; otherwise a stable
|
||||
/// `install:` placeholder (server skips placeholders for delivery).
|
||||
Future<void> _registerDeviceTokenBestEffort() async {
|
||||
if (user == null) return;
|
||||
try {
|
||||
_db ??= await _openDb();
|
||||
var installId = await _db!.getKv(_kDeviceInstallId);
|
||||
if (installId == null || installId.isEmpty) {
|
||||
final rand = Random.secure();
|
||||
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
|
||||
await _db!.setKv(_kDeviceInstallId, installId);
|
||||
}
|
||||
final resolved = await PushTokenService(
|
||||
readInstallId: () => _db!.getKv(_kDeviceInstallId),
|
||||
storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id),
|
||||
).resolve();
|
||||
await _api.registerDeviceToken(
|
||||
userId: user!.id,
|
||||
token: 'install:$installId',
|
||||
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other',
|
||||
token: resolved.token,
|
||||
platform: resolved.platform,
|
||||
);
|
||||
debugPrint(
|
||||
resolved.isFcm
|
||||
? 'device token registered (FCM)'
|
||||
: 'device token registered (install placeholder)',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('device token register skipped: $e');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Shows the signed-in numeric user id with one-tap copy.
|
||||
class MyUserIdChip extends StatelessWidget {
|
||||
const MyUserIdChip({super.key, required this.userId, this.compact = false});
|
||||
|
||||
final int userId;
|
||||
final bool compact;
|
||||
|
||||
Future<void> _copy(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: '$userId'));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('내 사용자 ID $userId 를 복사했습니다. 상대에게 알려 주세요.'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
if (compact) {
|
||||
return IconButton(
|
||||
tooltip: '내 ID $userId 복사',
|
||||
onPressed: () => _copy(context),
|
||||
icon: const Icon(Icons.badge_outlined),
|
||||
);
|
||||
}
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _copy(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'내 사용자 ID',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$userId',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'탭하여 복사',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.copy_rounded, size: 16, color: theme.colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,18 +5,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "96.0.0"
|
||||
version: "88.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.75"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "8.1.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -29,10 +37,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -61,10 +69,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: "8c0535c3b2f625619f4dd1036ef1127f2e77bfedf89ed2eb2676ef076e0b6712"
|
||||
sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.3"
|
||||
version: "4.1.2"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -129,14 +137,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -165,18 +165,18 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
version: "1.0.8"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
version: "3.1.2"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -209,14 +209,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -225,6 +217,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.1"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "16.4.3"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -290,10 +330,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
version: "4.1.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
|
@ -316,10 +356,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "6.3.2"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -328,14 +368,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -368,38 +400,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_util
|
||||
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
version: "4.9.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -480,14 +488,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.5.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -508,42 +508,42 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
version: "2.2.19"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -608,38 +608,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
||||
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.27"
|
||||
version: "2.4.13"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
version: "2.5.4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -652,10 +644,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -697,18 +689,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5
|
||||
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.4"
|
||||
version: "4.2.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
version: "1.10.1"
|
||||
sqlcipher_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -801,10 +793,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
version: "15.0.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -841,10 +833,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -862,5 +854,5 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
dart: ">=3.10.0-0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ dependencies:
|
|||
flutter_secure_storage: ^10.3.1
|
||||
sqlcipher_flutter_libs: ^0.6.8
|
||||
google_fonts: ^6.3.2
|
||||
firebase_core: ^4.12.1
|
||||
firebase_messaging: ^16.4.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run on the deploy host from the compose project directory (e.g. ~/project/ykavu).
|
||||
# Creates gzipped pg_dump under ~/backups/ykavu and prunes older than keep-count.
|
||||
set -euo pipefail
|
||||
|
||||
KEEP="${BACKUP_KEEP:-10}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
OUT_DIR="${BACKUP_DIR:-$HOME/backups/ykavu}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
DUMP="$OUT_DIR/ykavu-$STAMP.sql.gz"
|
||||
|
||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-ykavu}" "${POSTGRES_DB:-ykavu}" | gzip >"$DUMP"
|
||||
ls -lh "$DUMP"
|
||||
ls -1t "$OUT_DIR"/ykavu-*.sql.gz | tail -n +"$((KEEP + 1))" | xargs -r rm -f
|
||||
echo "OK $DUMP"
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Push main to GitHub (origin) and Gitea (gitea) remotes.
|
||||
# Push a branch to GitHub (origin) and Gitea iykyka (gitea) remotes.
|
||||
# Master policy: land work on main, then run: ./scripts/push-both.sh main
|
||||
# See AGENTS.md "Git remotes & branch policy".
|
||||
set -euo pipefail
|
||||
BRANCH="${1:-main}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run ON the iykyka host (SSH or Portainer host shell) after cloning this repo.
|
||||
# Does not commit secrets. Requires Docker Compose v2 + .env filled.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "Missing .env — copy from .env.example and set ADMIN_API_TOKEN, POSTGRES_PASSWORD, GEMINI_API_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
: "${ADMIN_API_TOKEN:?ADMIN_API_TOKEN required}"
|
||||
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}"
|
||||
: "${PUBLIC_API_BASE:=https://msn.iykyka.com}"
|
||||
: "${WEB_HOST_PORT:=8088}"
|
||||
|
||||
export PUBLIC_API_BASE WEB_HOST_PORT
|
||||
|
||||
echo "==> Building & starting stack (PUBLIC_API_BASE=$PUBLIC_API_BASE WEB_HOST_PORT=$WEB_HOST_PORT)"
|
||||
docker compose pull postgres || true
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
echo "==> Waiting for web health on localhost:${WEB_HOST_PORT}"
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS "http://127.0.0.1:${WEB_HOST_PORT}/health" >/dev/null 2>&1; then
|
||||
echo "OK health"
|
||||
curl -fsS "http://127.0.0.1:${WEB_HOST_PORT}/health"; echo
|
||||
curl -fsS "http://127.0.0.1:${WEB_HOST_PORT}/demo"; echo
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "Timed out waiting for /health — check: docker compose ps && docker compose logs"
|
||||
docker compose ps
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Secrets (gitignored)
|
||||
|
||||
Put the Firebase service account JSON here as:
|
||||
|
||||
```text
|
||||
secrets/firebase-service-account.json
|
||||
```
|
||||
|
||||
Never commit this file. Docker Compose mounts `./secrets` read-only into
|
||||
`core-backend` as `/secrets` (`FCM_SERVICE_ACCOUNT_FILE`).
|
||||
|
||||
See [`docs/fcm-setup.md`](../docs/fcm-setup.md).
|
||||
Loading…
Reference in New Issue