From df09d23662edb49d3841a0fff98d45e0d691d265 Mon Sep 17 00:00:00 2001 From: theorose49 Date: Sun, 28 Jun 2026 10:55:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B8=ED=84=B4=20=EC=A7=81=EA=B8=89?= =?UTF-8?q?=20+=20=EC=B4=88=EA=B3=BC=EA=B7=BC=EB=AC=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=A7=91=EA=B3=84=ED=99=94=20+=20SSO=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20URL=20+=20=EB=94=94?= =?UTF-8?q?=EB=B0=94=EC=9D=B4=EC=8A=A4/FCM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지) - 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계 - 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session - (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/server/main.go | 4 +- internal/config/config.go | 12 +- internal/httpapi/handlers.go | 14 +- internal/httpapi/handlers_attendance.go | 11 +- internal/httpapi/handlers_inbox.go | 35 +++++ internal/httpapi/handlers_incentive.go | 2 +- internal/httpapi/perms.go | 11 +- internal/httpapi/router.go | 10 +- internal/models/member.go | 14 ++ internal/models/models.go | 2 +- internal/push/push.go | 195 ++++++++++++++++++++++++ internal/seed/seed.go | 2 +- 12 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 internal/push/push.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 2245837..e2d4fb0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "spin/internal/config" "spin/internal/db" "spin/internal/httpapi" + "spin/internal/push" "spin/internal/seed" "spin/internal/storage" ) @@ -44,7 +45,8 @@ func main() { log.Printf("seed skipped (SEED=false)") } - router := httpapi.NewRouter(gdb, store, cfg) + pusher := push.New(cfg.FCMCredentialsFile) + router := httpapi.NewRouter(gdb, store, cfg, pusher) addr := ":" + cfg.Port srv := &http.Server{ diff --git a/internal/config/config.go b/internal/config/config.go index 7860ce2..db8845a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,13 @@ type Config struct { S3SecretKey string DevAuth bool SeedData bool + // FCMCredentialsFile is the path to a Firebase service-account JSON used to + // send push notifications to spin-mobile. Empty = push disabled (no-op). + FCMCredentialsFile string + // LogoutURL is the common SSO logout path injected by infra (LOGOUT_URL): + // ends the oauth2-proxy session then redirects to Keycloak end-session. + // The static frontend reads it via /me. Defaults to the infra-common value. + LogoutURL string // AdminGroups are the Keycloak groups whose members are super-admins // (manage everything across the company: incentive console, accounting, // approvals, all member/project data). @@ -69,7 +76,10 @@ func Load() Config { SeedData: env("SEED", "false") == "true", // Super-admin Keycloak groups (comma-separated). Default: admin // (shared group name across all internal apps, not app-specific). - AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")), + AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")), + FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""), + LogoutURL: env("LOGOUT_URL", + "/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"), } } diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index f42cea0..e85ed67 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -9,17 +9,19 @@ import ( // MeResponse enriches the proxy identity with the matched Member profile. type MeResponse struct { - User User `json:"user"` - Member *models.Member `json:"member"` - IsAdmin bool `json:"isAdmin"` + User User `json:"user"` + Member *models.Member `json:"member"` + IsAdmin bool `json:"isAdmin"` + LogoutURL string `json:"logoutUrl"` } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { u := currentUser(r.Context()) writeJSON(w, http.StatusOK, MeResponse{ - User: u, - Member: s.lookupMember(u.Email), - IsAdmin: s.isAdmin(r), + User: u, + Member: s.lookupMember(u.Email), + IsAdmin: s.isAdmin(r), + LogoutURL: s.cfg.LogoutURL, }) } diff --git a/internal/httpapi/handlers_attendance.go b/internal/httpapi/handlers_attendance.go index 201bd03..b3fa5d9 100644 --- a/internal/httpapi/handlers_attendance.go +++ b/internal/httpapi/handlers_attendance.go @@ -104,12 +104,13 @@ func (s *Server) handleTimesheet(w http.ResponseWriter, r *http.Request) { leaveMin += int(lv.Days * float64(pol.DailyStandardMin)) } - var ots []models.OvertimeRequest - s.db.Where("lower(member_email) = ? AND status = ? AND date LIKE ?", - email, models.StatusApproved, prefix+"%").Find(&ots) + // Overtime is OBSERVED from real attendance (worked beyond the daily standard), + // not user-applied — only admins see it (via the 근무 관리 timesheet). otMin := 0 - for _, o := range ots { - otMin += o.Minutes + for _, a := range att { + if extra := a.WorkMinutes - pol.DailyStandardMin; extra > 0 { + otMin += extra + } } writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days)) diff --git a/internal/httpapi/handlers_inbox.go b/internal/httpapi/handlers_inbox.go index c23a2cb..5d924a1 100644 --- a/internal/httpapi/handlers_inbox.go +++ b/internal/httpapi/handlers_inbox.go @@ -46,6 +46,41 @@ func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } +// ---- push device registration (spin-mobile) ------------------------------- + +func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { + var body struct { + Token string `json:"token"` + Platform string `json:"platform"` + } + if err := decodeJSON(r, &body); err != nil || strings.TrimSpace(body.Token) == "" { + writeError(w, http.StatusBadRequest, "token이 필요합니다") + return + } + email := s.email(r) + var d models.Device + if err := s.db.Where("token = ?", body.Token).First(&d).Error; err == nil { + d.MemberEmail = email + d.Platform = body.Platform + s.db.Save(&d) + } else { + d = models.Device{MemberEmail: email, Token: body.Token, Platform: body.Platform} + s.db.Create(&d) + } + writeJSON(w, http.StatusOK, d) +} + +func (s *Server) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) { + var body struct { + Token string `json:"token"` + } + decodeJSON(r, &body) + if body.Token != "" { + s.db.Where("token = ?", body.Token).Delete(&models.Device{}) + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + // ---- work status (출근/퇴근/휴식/미팅/이동) -------------------------------- var workStatuses = map[string]bool{"in": true, "out": true, "break": true, "meeting": true, "move": true} diff --git a/internal/httpapi/handlers_incentive.go b/internal/httpapi/handlers_incentive.go index 08b6525..863d218 100644 --- a/internal/httpapi/handlers_incentive.go +++ b/internal/httpapi/handlers_incentive.go @@ -29,7 +29,7 @@ func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Co func defaultQuota() map[string]interface{} { return map[string]interface{}{ - models.RankJunior: 30.0, models.RankSenior: 50.0, + models.RankIntern: 15.0, models.RankJunior: 30.0, models.RankSenior: 50.0, models.RankLead: 80.0, models.RankPartner: 120.0, } } diff --git a/internal/httpapi/perms.go b/internal/httpapi/perms.go index 5ef902f..2d97cc5 100644 --- a/internal/httpapi/perms.go +++ b/internal/httpapi/perms.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "net/http" "strings" @@ -84,12 +85,20 @@ func (s *Server) ensureMember(next http.Handler) http.Handler { }) } -// notify writes an inbox Notification (best-effort). +// notify writes an inbox Notification and fans out a push to the recipient's +// registered devices (best-effort; push is a no-op when FCM isn't configured). func (s *Server) notify(recipient, typ, title, body, link string) { if strings.TrimSpace(recipient) == "" { return } s.db.Create(&models.Notification{Recipient: recipient, Type: typ, Title: title, Body: body, Link: link}) + if s.push != nil && s.push.Enabled() { + var tokens []string + s.db.Model(&models.Device{}).Where("lower(member_email) = lower(?)", recipient).Pluck("token", &tokens) + if len(tokens) > 0 { + go s.push.Send(context.Background(), tokens, title, body, link) + } + } } // audit writes an AuditLog row (best-effort). diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index b3e2a8b..00a86ef 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -5,6 +5,7 @@ import ( "net/http" "spin/internal/config" + "spin/internal/push" "spin/internal/storage" "github.com/go-chi/chi/v5" @@ -18,11 +19,12 @@ type Server struct { db *gorm.DB store *storage.Storage cfg config.Config + push *push.Sender } // NewRouter wires up the chi router and mounts the /api routes for every module. -func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Handler { - s := &Server{db: db, store: store, cfg: cfg} +func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *push.Sender) http.Handler { + s := &Server{db: db, store: store, cfg: cfg, push: pusher} r := chi.NewRouter() r.Use(middleware.RequestID) @@ -52,6 +54,10 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Hand r.Get("/me/nav", s.handleNav) r.Post("/me/avatar", s.handleUploadAvatar) + // push device registration (spin-mobile) + r.Post("/devices", s.handleRegisterDevice) + r.Post("/devices/unregister", s.handleUnregisterDevice) + // inbox / notifications (메일함) r.Get("/notifications", s.handleListNotifications) r.Get("/notifications/unread-count", s.handleUnreadCount) diff --git a/internal/models/member.go b/internal/models/member.go index b76876f..2701ba7 100644 --- a/internal/models/member.go +++ b/internal/models/member.go @@ -10,6 +10,7 @@ import ( // 주임(junior) · 선임(senior) · 책임(lead) · 파트너(partner) // Stored as the Korean label so it round-trips to the UI directly. const ( + RankIntern = "인턴" RankJunior = "주임" RankSenior = "선임" RankLead = "책임" @@ -99,3 +100,16 @@ type WorkStatusEvent struct { } func (m *WorkStatusEvent) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } + +// Device is a registered push target (spin-mobile Flutter 앱 FCM 토큰). 한 유저가 +// 여러 기기를 가질 수 있고, 토큰은 고유. +type Device struct { + Base + MemberEmail string `gorm:"index" json:"memberEmail"` + Token string `gorm:"uniqueIndex" json:"token"` + Platform string `json:"platform"` // android | ios + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (m *Device) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } diff --git a/internal/models/models.go b/internal/models/models.go index 286187d..0b85b2e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -23,7 +23,7 @@ func (b *Base) ensureID() { func All() []interface{} { return []interface{}{ // slice 1 — members / org - &Member{}, &Department{}, &AuditLog{}, &Notification{}, &WorkStatusEvent{}, + &Member{}, &Department{}, &AuditLog{}, &Notification{}, &WorkStatusEvent{}, &Device{}, // slice 2 — attendance / leave &Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{}, // slice 3 — projects diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..086a6f5 --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,195 @@ +// Package push sends FCM (Firebase Cloud Messaging) notifications via the HTTP v1 +// API using a service-account JSON — no third-party SDK, stdlib only. +// +// It is OPTIONAL: if no credentials are configured the Sender is "disabled" and +// Send is a no-op (logged). This lets spin run without Firebase until the client +// provides a service account. +package push + +import ( + "bytes" + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +type serviceAccount struct { + ProjectID string `json:"project_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + TokenURI string `json:"token_uri"` +} + +// Sender holds the FCM credentials and a cached OAuth access token. +type Sender struct { + sa *serviceAccount + key *rsa.PrivateKey + mu sync.Mutex + token string + expires time.Time + client *http.Client +} + +// New builds a Sender from a service-account JSON file path. An empty path or a +// read error yields a disabled Sender (Send is a no-op). +func New(credsPath string) *Sender { + if strings.TrimSpace(credsPath) == "" { + log.Printf("push: FCM disabled (no credentials configured)") + return &Sender{} + } + raw, err := os.ReadFile(credsPath) + if err != nil { + log.Printf("push: FCM disabled (cannot read %s: %v)", credsPath, err) + return &Sender{} + } + var sa serviceAccount + if err := json.Unmarshal(raw, &sa); err != nil || sa.PrivateKey == "" || sa.ClientEmail == "" { + log.Printf("push: FCM disabled (invalid service account JSON)") + return &Sender{} + } + key, err := parsePrivateKey(sa.PrivateKey) + if err != nil { + log.Printf("push: FCM disabled (bad private key: %v)", err) + return &Sender{} + } + if sa.TokenURI == "" { + sa.TokenURI = "https://oauth2.googleapis.com/token" + } + log.Printf("push: FCM enabled (project=%s)", sa.ProjectID) + return &Sender{sa: &sa, key: key, client: &http.Client{Timeout: 10 * time.Second}} +} + +// Enabled reports whether real sending is configured. +func (s *Sender) Enabled() bool { return s != nil && s.sa != nil } + +// Send delivers a notification to each token. No-op when disabled. Errors per +// token are logged but don't abort the batch. +func (s *Sender) Send(ctx context.Context, tokens []string, title, body, link string) { + if !s.Enabled() || len(tokens) == 0 { + return + } + tok, err := s.accessToken(ctx) + if err != nil { + log.Printf("push: token error: %v", err) + return + } + endpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", s.sa.ProjectID) + for _, t := range tokens { + msg := map[string]any{ + "message": map[string]any{ + "token": t, + "notification": map[string]string{"title": title, "body": body}, + "data": map[string]string{"link": link}, + }, + } + b, _ := json.Marshal(msg) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err := s.client.Do(req) + if err != nil { + log.Printf("push: send error: %v", err) + continue + } + if resp.StatusCode >= 300 { + rb, _ := io.ReadAll(resp.Body) + log.Printf("push: FCM %d: %s", resp.StatusCode, string(rb)) + } + resp.Body.Close() + } +} + +// accessToken returns a cached OAuth2 access token, minting a new one via the +// service-account JWT grant when expired. +func (s *Sender) accessToken(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.token != "" && time.Now().Before(s.expires.Add(-60*time.Second)) { + return s.token, nil + } + now := time.Now() + claims := map[string]any{ + "iss": s.sa.ClientEmail, + "scope": "https://www.googleapis.com/auth/firebase.messaging", + "aud": s.sa.TokenURI, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + } + assertion, err := s.signJWT(claims) + if err != nil { + return "", err + } + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + form.Set("assertion", assertion) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.sa.TokenURI, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := s.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if out.AccessToken == "" { + return "", fmt.Errorf("token exchange failed: %s", out.Error) + } + s.token = out.AccessToken + s.expires = now.Add(time.Duration(out.ExpiresIn) * time.Second) + return s.token, nil +} + +func (s *Sender) signJWT(claims map[string]any) (string, error) { + header := map[string]string{"alg": "RS256", "typ": "JWT"} + hb, _ := json.Marshal(header) + cb, _ := json.Marshal(claims) + signingInput := b64(hb) + "." + b64(cb) + h := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:]) + if err != nil { + return "", err + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, errors.New("no PEM block") + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + k, ok := keyAny.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("not an RSA private key") + } + return k, nil +} diff --git a/internal/seed/seed.go b/internal/seed/seed.go index ef51e92..28ce013 100644 --- a/internal/seed/seed.go +++ b/internal/seed/seed.go @@ -67,7 +67,7 @@ func Run(db *gorm.DB) error { cfg := models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30, MiddlePct: 40, FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40, RankQuota: map[string]interface{}{ - models.RankJunior: 30.0, models.RankSenior: 50.0, models.RankLead: 80.0, models.RankPartner: 120.0, + models.RankIntern: 15.0, models.RankJunior: 30.0, models.RankSenior: 50.0, models.RankLead: 80.0, models.RankPartner: 120.0, }} db.Create(&cfg)