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 <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-31 07:33:23 +00:00
parent 82a5809d4e
commit ef73b93d83
No known key found for this signature in database
11 changed files with 258 additions and 60 deletions

View File

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

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -2,22 +2,49 @@ 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"`
@ -26,20 +53,52 @@ type fcmLegacyPayload struct {
Data map[string]string `json:"data,omitempty"`
}
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
var tokens []DeviceToken
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
}
type fcmV1MessageRequest struct {
Message fcmV1Message `json:"message"`
}
key := fcmServerKey()
if key == "" {
runtimeMetrics.recordPush(0, true)
return 0, "fcm_not_configured", nil
}
type fcmV1Message struct {
Token string `json:"token"`
Notification map[string]string `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
}
// Skip placeholder install:* tokens — they are not real FCM registration IDs.
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:") {
@ -47,11 +106,99 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
}
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) {
var tokens []DeviceToken
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
}
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
}

View File

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

View File

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

View File

@ -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": <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 실푸시** 우선.

0
secrets/.gitkeep Normal file
View File

12
secrets/README.md Normal file
View File

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