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. # Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable.
ALLOW_DEMO_INVITE=1 ALLOW_DEMO_INVITE=1
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test). # FCM push (escalation notify + /admin/push-test). Prefer HTTP v1 service account:
# Without this, notifyUser soft-skips and records push_skipped metrics. # 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_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) --- # --- docker compose (N2-B / msn.iykyka.com) ---
POSTGRES_USER=ykavu POSTGRES_USER=ykavu

5
.gitignore vendored
View File

@ -9,6 +9,11 @@ poc/tone-corpus/data/
.env.* .env.*
!.env.example !.env.example
# Firebase / FCM service account JSON (never commit)
secrets/**
!secrets/.gitkeep
!secrets/README.md
__pycache__/ __pycache__/
*.pyc *.pyc

View File

@ -95,7 +95,7 @@ func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
t.Fatalf("revoke: %d", del.StatusCode) 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{}{ push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{
"user_id": userID, "user_id": userID,
"title": "t", "title": "t",

View File

@ -3,19 +3,27 @@ module hikikomori/core-backend
go 1.25.0 go 1.25.0
require ( 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/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // 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/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // 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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= 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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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.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/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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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-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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= 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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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= 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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= 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.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.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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= 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 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= 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/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.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= 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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=

View File

@ -2,22 +2,49 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm" "gorm.io/gorm"
) )
// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
// records a metric and returns without error so product flows stay usable.
// 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 { func fcmServerKey() string {
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY")) 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 { type fcmLegacyPayload struct {
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Registration []string `json:"registration_ids,omitempty"` Registration []string `json:"registration_ids,omitempty"`
@ -26,20 +53,52 @@ type fcmLegacyPayload struct {
Data map[string]string `json:"data,omitempty"` 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) { type fcmV1MessageRequest struct {
var tokens []DeviceToken Message fcmV1Message `json:"message"`
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
} }
key := fcmServerKey() type fcmV1Message struct {
if key == "" { Token string `json:"token"`
runtimeMetrics.recordPush(0, true) Notification map[string]string `json:"notification,omitempty"`
return 0, "fcm_not_configured", nil 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)) regIDs := make([]string, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
if strings.HasPrefix(t.Token, "install:") { 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) 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 { if len(regIDs) == 0 {
runtimeMetrics.recordPush(0, true) runtimeMetrics.recordPush(0, true)
return 0, "only_placeholder_tokens", nil 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{ payload := fcmLegacyPayload{
Registration: regIDs, Registration: regIDs,
Priority: "high", Priority: "high",
@ -61,7 +208,7 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw)) req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
if err != nil { if err != nil {
return 0, "", err return 0, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+key) 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} client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
runtimeMetrics.recordPush(0, true) return 0, err
return 0, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { 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} ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env}
ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1} ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1}
FCM_SERVER_KEY: ${FCM_SERVER_KEY:-} 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@ -164,9 +164,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| ID | 작업 | Status | 완료 조건 | | 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-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` + 기기 수신 | | **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
### Android UI 탭 (`mobile/README.md`) ### Android UI 탭 (`mobile/README.md`)

View File

@ -1,7 +1,7 @@
# FCM 설정 (N4-1 ~ N4-4) # 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:` 플레이스홀더 | | Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | | 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가 할 일 ## Master가 할 일
### N4-1 — Firebase 앱 ### N4-1 — Firebase Android
1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용). 1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`** package: **`com.ykavu.ykavu_mobile`**
3. 받은 `google-services.json`을 로컬에만 배치 (git 금지): 2. `google-services.json`을 로컬에만 배치 (git 금지):
```bash ```bash
cp ~/Downloads/google-services.json mobile/android/app/google-services.json 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 ```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 ```bash
# 예: scp 후
cd ~/project/ykavu 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 마이그레이션이 필요하다. **git / 채팅에 JSON 내용을 붙여넣지 않는다.**
> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다.
### N4-2 / N4-4 — 실기기 스모크 ### N4-2 / N4-4 — 실기기 스모크
```bash ```bash
cd mobile cd mobile
flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# 가입 → 로그에 "device token registered (FCM)" 확인 # 로그: device token registered (FCM)
``` ```
```bash ```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"}' -d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}'
``` ```
기대: `sent >= 1`, `skipped_reason` 없음. 기기에 알림 표시. 기대: `sent >= 1`.
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
`only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인. `fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인.
## Web ## Web
현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며 Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선.
이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다.

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