From ef73b93d83c8e4ce577adca3b1e4d82ca25ca023 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 07:33:23 +0000 Subject: [PATCH] feat(fcm): send pushes via HTTP v1 service account Prefer FCM_SERVICE_ACCOUNT_FILE/JSON over legacy FCM_SERVER_KEY, mount secrets/ into core-backend, and document Master JSON placement. Co-authored-by: okuma --- .env.example | 7 +- .gitignore | 5 + core-backend/b_test.go | 2 +- core-backend/go.mod | 15 ++- core-backend/go.sum | 15 +++ core-backend/push.go | 200 +++++++++++++++++++++++++++++++++------ docker-compose.yml | 5 + docs/deploy-checklist.md | 4 +- docs/fcm-setup.md | 53 ++++++----- secrets/.gitkeep | 0 secrets/README.md | 12 +++ 11 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 secrets/.gitkeep create mode 100644 secrets/README.md diff --git a/.env.example b/.env.example index 4328858..5912766 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,12 @@ ADMIN_API_TOKEN= # 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 diff --git a/.gitignore b/.gitignore index 88ebfe0..a645677 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/core-backend/b_test.go b/core-backend/b_test.go index 52b92b4..9e9749f 100644 --- a/core-backend/b_test.go +++ b/core-backend/b_test.go @@ -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", diff --git a/core-backend/go.mod b/core-backend/go.mod index f4613b5..a2ddee6 100644 --- a/core-backend/go.mod +++ b/core-backend/go.mod @@ -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 ) diff --git a/core-backend/go.sum b/core-backend/go.sum index 182699d..37e0bda 100644 --- a/core-backend/go.sum +++ b/core-backend/go.sum @@ -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= diff --git a/core-backend/push.go b/core-backend/push.go index d187004..a2aab5d 100644 --- a/core-backend/push.go +++ b/core-backend/push.go @@ -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 } diff --git a/docker-compose.yml b/docker-compose.yml index c8dda89..6f31d80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,11 @@ services: 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 diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 5ea108f..511c733 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -164,9 +164,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N4-1** | Firebase + `google-services.json` | blocked | Master: Console 앱 + `mobile/android/app/google-services.json` (git 금지) | +| **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 자격증명 | blocked | Master: 호스트 `.env`의 `FCM_SERVER_KEY`만 | +| **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`) diff --git a/docs/fcm-setup.md b/docs/fcm-setup.md index f705f68..9c838a4 100644 --- a/docs/fcm-setup.md +++ b/docs/fcm-setup.md @@ -1,7 +1,7 @@ # FCM 설정 (N4-1 ~ N4-4) -서버·클라이언트의 푸시 **코드 경로는 준비됨**. 실제 전송은 Master가 Firebase 시크릿을 -넣기 전까지 soft-skip 한다 (`install:` 토큰·`FCM_SERVER_KEY` 없음). +서버·클라이언트의 푸시 **코드 경로는 준비됨**. +새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다. ## 이미 된 것 (코드) @@ -9,47 +9,57 @@ |------|------| | Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 | | Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | -| core-backend | `notifyUser` + `POST /admin/push-test` — `FCM_SERVER_KEY` + 실 토큰일 때만 전송 | +| core-backend | `notifyUser` + `POST /admin/push-test` — **FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 | ## Master가 할 일 -### N4-1 — Firebase 앱 +### N4-1 — Firebase Android 앱 -1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용). -2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`** -3. 받은 `google-services.json`을 로컬에만 배치 (git 금지): +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 ``` -템플릿: `mobile/android/app/google-services.json.example` +### N4-3 — 서비스 계정 JSON (HTTP v1) -### N4-3 — 서버 키 (env only) +레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 아래를 쓴다. -Cloud Messaging **레거시 서버 키**(또는 호환 서버 키)를 호스트 `.env`에만 설정: +1. Google Cloud → 사용자 인증 정보 → 서비스 계정 만들기 + (API: Firebase Cloud Messaging API, 데이터: **애플리케이션 데이터**) +2. 역할: **Firebase Cloud Messaging Admin** (없으면 Firebase 관리자 / 임시 소유자) +3. 키 유형 **JSON** 다운로드 +4. 서버(또는 이 워크스페이스)에 배치: ```bash -# 서버 ~/project/ykavu/.env -FCM_SERVER_KEY=AAAA... +# 파일명 고정 +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 -docker compose up -d core-backend +# secrets/firebase-service-account.json 존재 확인 +docker compose up -d --build core-backend ``` -**git / 이미지에 키를 넣지 않는다** (N2-A5). +`docker-compose.yml`이 `./secrets` → 컨테이너 `/secrets`로 마운트하고 +`FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json`을 읽는다. -> 레거시 HTTP API가 Console에서 비활성이면 HTTP v1 마이그레이션이 필요하다. -> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다. +**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)" 확인 +# 로그: device token registered (FCM) ``` ```bash @@ -59,11 +69,10 @@ curl -sS -X POST https://msn.iykyka.com/admin/push-test \ -d '{"user_id": , "title":"와카뷰","body":"push smoke"}' ``` -기대: `sent >= 1`, `skipped_reason` 없음. 기기에 알림 표시. - -`only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인. +기대: `sent >= 1`. +`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치. +`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인. ## Web -현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며 -이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다. +Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선. diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..38fe44d --- /dev/null +++ b/secrets/README.md @@ -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).