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:
parent
82a5809d4e
commit
ef73b93d83
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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,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