feat: 인턴 직급 + 초과근무 관리자 집계화 + SSO 로그아웃 URL + 디바이스/FCM
All checks were successful
build-and-push / build (push) Successful in 35s
All checks were successful
build-and-push / build (push) Successful in 35s
- 직급에 인턴 추가(기본 할당량 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) <noreply@anthropic.com>
This commit is contained in:
parent
6158888417
commit
df09d23662
@ -9,6 +9,7 @@ import (
|
|||||||
"spin/internal/config"
|
"spin/internal/config"
|
||||||
"spin/internal/db"
|
"spin/internal/db"
|
||||||
"spin/internal/httpapi"
|
"spin/internal/httpapi"
|
||||||
|
"spin/internal/push"
|
||||||
"spin/internal/seed"
|
"spin/internal/seed"
|
||||||
"spin/internal/storage"
|
"spin/internal/storage"
|
||||||
)
|
)
|
||||||
@ -44,7 +45,8 @@ func main() {
|
|||||||
log.Printf("seed skipped (SEED=false)")
|
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
|
addr := ":" + cfg.Port
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@ -24,6 +24,13 @@ type Config struct {
|
|||||||
S3SecretKey string
|
S3SecretKey string
|
||||||
DevAuth bool
|
DevAuth bool
|
||||||
SeedData 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
|
// AdminGroups are the Keycloak groups whose members are super-admins
|
||||||
// (manage everything across the company: incentive console, accounting,
|
// (manage everything across the company: incentive console, accounting,
|
||||||
// approvals, all member/project data).
|
// approvals, all member/project data).
|
||||||
@ -69,7 +76,10 @@ func Load() Config {
|
|||||||
SeedData: env("SEED", "false") == "true",
|
SeedData: env("SEED", "false") == "true",
|
||||||
// Super-admin Keycloak groups (comma-separated). Default: admin
|
// Super-admin Keycloak groups (comma-separated). Default: admin
|
||||||
// (shared group name across all internal apps, not app-specific).
|
// (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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,17 +9,19 @@ import (
|
|||||||
|
|
||||||
// MeResponse enriches the proxy identity with the matched Member profile.
|
// MeResponse enriches the proxy identity with the matched Member profile.
|
||||||
type MeResponse struct {
|
type MeResponse struct {
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Member *models.Member `json:"member"`
|
Member *models.Member `json:"member"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
LogoutURL string `json:"logoutUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
u := currentUser(r.Context())
|
u := currentUser(r.Context())
|
||||||
writeJSON(w, http.StatusOK, MeResponse{
|
writeJSON(w, http.StatusOK, MeResponse{
|
||||||
User: u,
|
User: u,
|
||||||
Member: s.lookupMember(u.Email),
|
Member: s.lookupMember(u.Email),
|
||||||
IsAdmin: s.isAdmin(r),
|
IsAdmin: s.isAdmin(r),
|
||||||
|
LogoutURL: s.cfg.LogoutURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -104,12 +104,13 @@ func (s *Server) handleTimesheet(w http.ResponseWriter, r *http.Request) {
|
|||||||
leaveMin += int(lv.Days * float64(pol.DailyStandardMin))
|
leaveMin += int(lv.Days * float64(pol.DailyStandardMin))
|
||||||
}
|
}
|
||||||
|
|
||||||
var ots []models.OvertimeRequest
|
// Overtime is OBSERVED from real attendance (worked beyond the daily standard),
|
||||||
s.db.Where("lower(member_email) = ? AND status = ? AND date LIKE ?",
|
// not user-applied — only admins see it (via the 근무 관리 timesheet).
|
||||||
email, models.StatusApproved, prefix+"%").Find(&ots)
|
|
||||||
otMin := 0
|
otMin := 0
|
||||||
for _, o := range ots {
|
for _, a := range att {
|
||||||
otMin += o.Minutes
|
if extra := a.WorkMinutes - pol.DailyStandardMin; extra > 0 {
|
||||||
|
otMin += extra
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days))
|
writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days))
|
||||||
|
|||||||
@ -46,6 +46,41 @@ func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
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 (출근/퇴근/휴식/미팅/이동) --------------------------------
|
// ---- work status (출근/퇴근/휴식/미팅/이동) --------------------------------
|
||||||
|
|
||||||
var workStatuses = map[string]bool{"in": true, "out": true, "break": true, "meeting": true, "move": true}
|
var workStatuses = map[string]bool{"in": true, "out": true, "break": true, "meeting": true, "move": true}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Co
|
|||||||
|
|
||||||
func defaultQuota() map[string]interface{} {
|
func defaultQuota() map[string]interface{} {
|
||||||
return 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,
|
models.RankLead: 80.0, models.RankPartner: 120.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"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) {
|
func (s *Server) notify(recipient, typ, title, body, link string) {
|
||||||
if strings.TrimSpace(recipient) == "" {
|
if strings.TrimSpace(recipient) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.db.Create(&models.Notification{Recipient: recipient, Type: typ, Title: title, Body: body, Link: link})
|
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).
|
// audit writes an AuditLog row (best-effort).
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"spin/internal/config"
|
"spin/internal/config"
|
||||||
|
"spin/internal/push"
|
||||||
"spin/internal/storage"
|
"spin/internal/storage"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@ -18,11 +19,12 @@ type Server struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
store *storage.Storage
|
store *storage.Storage
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
push *push.Sender
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter wires up the chi router and mounts the /api routes for every module.
|
// 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 {
|
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *push.Sender) http.Handler {
|
||||||
s := &Server{db: db, store: store, cfg: cfg}
|
s := &Server{db: db, store: store, cfg: cfg, push: pusher}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
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.Get("/me/nav", s.handleNav)
|
||||||
r.Post("/me/avatar", s.handleUploadAvatar)
|
r.Post("/me/avatar", s.handleUploadAvatar)
|
||||||
|
|
||||||
|
// push device registration (spin-mobile)
|
||||||
|
r.Post("/devices", s.handleRegisterDevice)
|
||||||
|
r.Post("/devices/unregister", s.handleUnregisterDevice)
|
||||||
|
|
||||||
// inbox / notifications (메일함)
|
// inbox / notifications (메일함)
|
||||||
r.Get("/notifications", s.handleListNotifications)
|
r.Get("/notifications", s.handleListNotifications)
|
||||||
r.Get("/notifications/unread-count", s.handleUnreadCount)
|
r.Get("/notifications/unread-count", s.handleUnreadCount)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
// 주임(junior) · 선임(senior) · 책임(lead) · 파트너(partner)
|
// 주임(junior) · 선임(senior) · 책임(lead) · 파트너(partner)
|
||||||
// Stored as the Korean label so it round-trips to the UI directly.
|
// Stored as the Korean label so it round-trips to the UI directly.
|
||||||
const (
|
const (
|
||||||
|
RankIntern = "인턴"
|
||||||
RankJunior = "주임"
|
RankJunior = "주임"
|
||||||
RankSenior = "선임"
|
RankSenior = "선임"
|
||||||
RankLead = "책임"
|
RankLead = "책임"
|
||||||
@ -99,3 +100,16 @@ type WorkStatusEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *WorkStatusEvent) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
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 }
|
||||||
|
|||||||
@ -23,7 +23,7 @@ func (b *Base) ensureID() {
|
|||||||
func All() []interface{} {
|
func All() []interface{} {
|
||||||
return []interface{}{
|
return []interface{}{
|
||||||
// slice 1 — members / org
|
// slice 1 — members / org
|
||||||
&Member{}, &Department{}, &AuditLog{}, &Notification{}, &WorkStatusEvent{},
|
&Member{}, &Department{}, &AuditLog{}, &Notification{}, &WorkStatusEvent{}, &Device{},
|
||||||
// slice 2 — attendance / leave
|
// slice 2 — attendance / leave
|
||||||
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
|
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
|
||||||
// slice 3 — projects
|
// slice 3 — projects
|
||||||
|
|||||||
195
internal/push/push.go
Normal file
195
internal/push/push.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -67,7 +67,7 @@ func Run(db *gorm.DB) error {
|
|||||||
cfg := models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30, MiddlePct: 40,
|
cfg := models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30, MiddlePct: 40,
|
||||||
FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40,
|
FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40,
|
||||||
RankQuota: map[string]interface{}{
|
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)
|
db.Create(&cfg)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user