feat: 보안 수정 + Prometheus 메트릭 + 단위 테스트 추가

보안:
- Zip Bomb 방어 (io.LimitReader 100MB)
- Redis Del 에러 로깅 (auth, idempotency)
- 로그인 실패 로그에서 username 제거
- os.Remove 에러 로깅

모니터링:
- Prometheus 메트릭 미들웨어 + /metrics 엔드포인트
- http_requests_total, http_request_duration_seconds 등 4개 메트릭

테스트:
- download (11), chain (10), bossraid (20) = 41개 단위 테스트

기타:
- DB 모델 GORM 인덱스 태그 추가
- launcherHash 필드 + hashFileToHex() 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:37:42 +09:00
parent 82adb37ecb
commit 844a5b264b
14 changed files with 1016 additions and 495 deletions

View File

@@ -8,7 +8,7 @@ import (
type Announcement struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Title string `json:"title" gorm:"not null"`

View File

@@ -94,7 +94,7 @@ func (h *Handler) Login(c *fiber.Ctx) error {
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
if err != nil {
log.Printf("Login failed (username=%s): %v", req.Username, err)
log.Printf("Login failed: %v", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
}
@@ -176,6 +176,7 @@ func (h *Handler) Logout(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
}
if err := h.svc.Logout(userID); err != nil {
log.Printf("Logout failed for user %d: %v", userID, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"})
}
c.Cookie(&fiber.Cookie{

View File

@@ -20,7 +20,7 @@ type User struct {
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"`
PasswordHash string `json:"-" gorm:"not null"`
Role Role `json:"role" gorm:"default:'user'"`
Role Role `json:"role" gorm:"type:varchar(20);index;default:'user'"`
SsafyID *string `json:"ssafyId,omitempty" gorm:"type:varchar(100);uniqueIndex"`
}

View File

@@ -202,7 +202,9 @@ func (s *Service) DeleteUser(id uint) error {
defer delCancel()
sessionKey := fmt.Sprintf("session:%d", id)
refreshKey := fmt.Sprintf("refresh:%d", id)
s.rdb.Del(delCtx, sessionKey, refreshKey)
if err := s.rdb.Del(delCtx, sessionKey, refreshKey).Err(); err != nil {
log.Printf("WARNING: failed to delete Redis sessions for user %d: %v", id, err)
}
// TODO: Clean up wallet and profile data via cross-service calls
// (walletCreator/profileCreator are creation-only; deletion callbacks are not yet wired up)

View File

@@ -19,7 +19,7 @@ const (
// BossRoom represents a boss raid session room.
type BossRoom struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
@@ -63,8 +63,8 @@ type RoomSlot struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;not null"`
SlotIndex int `json:"slotIndex" gorm:"not null"`
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;uniqueIndex:idx_server_slot;not null"`
SlotIndex int `json:"slotIndex" gorm:"uniqueIndex:idx_server_slot;not null"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
Status SlotStatus `json:"status" gorm:"type:varchar(20);index;default:idle;not null"`
BossRoomID *uint `json:"bossRoomId" gorm:"index"`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
package chain
import (
"encoding/hex"
"testing"
tocrypto "github.com/tolelom/tolchain/crypto"
)
// testEncKey returns a valid 32-byte AES-256 key for testing.
func testEncKey() []byte {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return key
}
// newTestService creates a minimal Service with only the encryption key set.
// No DB, Redis, or chain client — only suitable for testing pure crypto functions.
func newTestService() *Service {
return &Service{
encKeyBytes: testEncKey(),
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
svc := newTestService()
// Generate a real ed25519 private key
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
// Encrypt
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
if cipherHex == "" || nonceHex == "" {
t.Fatal("encryptPrivKey returned empty strings")
}
// Verify ciphertext is valid hex
if _, err := hex.DecodeString(cipherHex); err != nil {
t.Errorf("cipherHex is not valid hex: %v", err)
}
if _, err := hex.DecodeString(nonceHex); err != nil {
t.Errorf("nonceHex is not valid hex: %v", err)
}
// Decrypt
decrypted, err := svc.decryptPrivKey(cipherHex, nonceHex)
if err != nil {
t.Fatalf("decryptPrivKey failed: %v", err)
}
// Compare
if hex.EncodeToString(decrypted) != hex.EncodeToString(privKey) {
t.Error("decrypted key does not match original")
}
}
func TestEncryptDecrypt_DifferentKeysProduceDifferentCiphertext(t *testing.T) {
svc := newTestService()
privKey1, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair 1: %v", err)
}
privKey2, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair 2: %v", err)
}
cipher1, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey1))
if err != nil {
t.Fatalf("encryptPrivKey 1 failed: %v", err)
}
cipher2, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey2))
if err != nil {
t.Fatalf("encryptPrivKey 2 failed: %v", err)
}
if cipher1 == cipher2 {
t.Error("different private keys should produce different ciphertexts")
}
}
func TestEncryptSameKey_DifferentNonces(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipher1, nonce1, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey 1 failed: %v", err)
}
cipher2, nonce2, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey 2 failed: %v", err)
}
// Each encryption should use a different random nonce
if nonce1 == nonce2 {
t.Error("encrypting the same key twice should use different nonces")
}
// So ciphertext should also differ (AES-GCM is nonce-dependent)
if cipher1 == cipher2 {
t.Error("encrypting the same key with different nonces should produce different ciphertexts")
}
}
func TestDecryptWithWrongKey(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
// Create a service with a different encryption key
wrongKey := make([]byte, 32)
for i := range wrongKey {
wrongKey[i] = byte(255 - i)
}
wrongSvc := &Service{encKeyBytes: wrongKey}
_, err = wrongSvc.decryptPrivKey(cipherHex, nonceHex)
if err == nil {
t.Error("decryptPrivKey with wrong key should fail")
}
}
func TestDecryptWithInvalidHex(t *testing.T) {
svc := newTestService()
_, err := svc.decryptPrivKey("not-hex", "also-not-hex")
if err == nil {
t.Error("decryptPrivKey with invalid hex should fail")
}
}
func TestDecryptWithTamperedCiphertext(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
// Tamper with the ciphertext by flipping a byte
cipherBytes, _ := hex.DecodeString(cipherHex)
cipherBytes[0] ^= 0xFF
tamperedHex := hex.EncodeToString(cipherBytes)
_, err = svc.decryptPrivKey(tamperedHex, nonceHex)
if err == nil {
t.Error("decryptPrivKey with tampered ciphertext should fail")
}
}
func TestNewService_InvalidEncryptionKey(t *testing.T) {
tests := []struct {
name string
encKey string
}{
{"too short", "aabbccdd"},
{"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
{"empty", ""},
{"odd length", "aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewService(nil, nil, "test-chain", "", tt.encKey)
if err == nil {
t.Error("NewService should fail with invalid encryption key")
}
})
}
}
func TestNewService_ValidEncryptionKey(t *testing.T) {
// 64 hex chars = 32 bytes
validKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
svc, err := NewService(nil, nil, "test-chain", "", validKey)
if err != nil {
t.Fatalf("NewService with valid key should succeed: %v", err)
}
if svc == nil {
t.Fatal("NewService returned nil service")
}
if svc.chainID != "test-chain" {
t.Errorf("chainID = %q, want %q", svc.chainID, "test-chain")
}
// No operator key provided, so operatorWallet should be nil
if svc.operatorWallet != nil {
t.Error("operatorWallet should be nil when no operator key is provided")
}
}
func TestEnsureOperator_NilWallet(t *testing.T) {
svc := newTestService()
err := svc.ensureOperator()
if err == nil {
t.Error("ensureOperator should fail when operatorWallet is nil")
}
}
func TestResolveUsername_NoResolver(t *testing.T) {
svc := newTestService()
_, err := svc.resolveUsername("testuser")
if err == nil {
t.Error("resolveUsername should fail when userResolver is nil")
}
}

View File

@@ -22,4 +22,5 @@ type Info struct {
// LauncherSize is a human-readable string (e.g., "25.3 MB") for display purposes.
// Programmatic size tracking uses os.Stat on the actual file.
LauncherSize string `json:"launcherSize" gorm:"not null;default:''"`
LauncherHash string `json:"launcherHash" gorm:"not null;default:''"`
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
@@ -55,12 +56,16 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
err = closeErr
}
if err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
@@ -69,12 +74,15 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
launcherSize = fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
}
launcherHash := hashFileToHex(finalPath)
info, err := s.repo.GetLatest()
if err != nil {
info = &Info{}
}
info.LauncherURL = baseURL + "/api/download/launcher"
info.LauncherSize = launcherSize
info.LauncherHash = launcherHash
return info, s.repo.Save(info)
}
@@ -97,12 +105,16 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
err = closeErr
}
if err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
@@ -123,7 +135,9 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
fileHash := hashGameExeFromZip(finalPath)
if fileHash == "" {
os.Remove(finalPath)
if removeErr := os.Remove(finalPath); removeErr != nil {
log.Printf("WARNING: failed to remove file %s: %v", finalPath, removeErr)
}
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
}
@@ -139,8 +153,21 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
return info, s.repo.Save(info)
}
// NOTE: No size limit on decompressed entry. This is admin-only so
// the risk is minimal. For defense-in-depth, consider io.LimitReader.
func hashFileToHex(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
const maxExeSize = 100 * 1024 * 1024 // 100MB — Zip Bomb 방어
func hashGameExeFromZip(zipPath string) string {
r, err := zip.OpenReader(zipPath)
if err != nil {
@@ -155,7 +182,7 @@ func hashGameExeFromZip(zipPath string) string {
return ""
}
h := sha256.New()
_, err = io.Copy(h, rc)
_, err = io.Copy(h, io.LimitReader(rc, maxExeSize))
rc.Close()
if err != nil {
return ""

View File

@@ -0,0 +1,198 @@
package download
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestHashFileToHex_KnownContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "testfile.bin")
content := []byte("hello world")
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256(content)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex = %q, want %q", got, want)
}
}
func TestHashFileToHex_EmptyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.bin")
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256([]byte{})
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex (empty) = %q, want %q", got, want)
}
}
func TestHashFileToHex_NonExistentFile(t *testing.T) {
got := hashFileToHex("/nonexistent/path/file.bin")
if got != "" {
t.Errorf("hashFileToHex (nonexistent) = %q, want empty string", got)
}
}
// createTestZip creates a zip file at zipPath containing the given files.
// files is a map of filename -> content.
func createTestZip(t *testing.T, zipPath string, files map[string][]byte) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
for name, data := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("failed to create zip entry %s: %v", name, err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("failed to write zip entry %s: %v", name, err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
}
func TestHashGameExeFromZip_WithA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("fake A301.exe binary content for testing")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/A301.exe": exeContent,
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("case insensitive test")
createTestZip(t, zipPath, map[string][]byte{
"build/a301.EXE": exeContent,
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip (case insensitive) = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_NoA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/other.exe": []byte("not A301"),
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (no A301.exe) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_EmptyZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "empty.zip")
createTestZip(t, zipPath, map[string][]byte{})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (empty zip) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_InvalidZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "notazip.zip")
if err := os.WriteFile(zipPath, []byte("this is not a zip file"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (invalid zip) = %q, want empty string", got)
}
}
func TestVersionRegex(t *testing.T) {
tests := []struct {
input string
want string
}{
{"game_v1.2.3.zip", "v1.2.3"},
{"game_v2.0.zip", "v2.0"},
{"game_v10.20.30.zip", "v10.20.30"},
{"game.zip", ""},
{"noversion", ""},
}
for _, tt := range tests {
got := versionRe.FindString(tt.input)
if got != tt.want {
t.Errorf("versionRe.FindString(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestGameFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.GameFilePath()
// filepath.Join normalizes separators per OS
want := filepath.Join("/data/game", "game.zip")
if got != want {
t.Errorf("GameFilePath() = %q, want %q", got, want)
}
}
func TestLauncherFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.LauncherFilePath()
want := filepath.Join("/data/game", "launcher.exe")
if got != want {
t.Errorf("LauncherFilePath() = %q, want %q", got, want)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/tolelom/tolchain/core"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/pkg/metrics"
"a301_server/pkg/middleware"
"a301_server/routes"
@@ -146,6 +147,8 @@ func main() {
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
})
app.Use(middleware.RequestID)
app.Use(middleware.Metrics)
app.Get("/metrics", metrics.Handler)
app.Use(logger.New(logger.Config{
Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n",
TimeFormat: "2006-01-02T15:04:05Z07:00",

54
pkg/metrics/metrics.go Normal file
View File

@@ -0,0 +1,54 @@
package metrics
import (
"io"
"net/http"
"net/http/httptest"
"github.com/gofiber/fiber/v2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
HTTPRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
[]string{"method", "path", "status"},
)
HTTPRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration"},
[]string{"method", "path"},
)
DBConnectionsActive = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "db_connections_active", Help: "Active DB connections"},
)
RedisConnectionsActive = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "redis_connections_active", Help: "Active Redis connections"},
)
)
func init() {
prometheus.MustRegister(HTTPRequestsTotal, HTTPRequestDuration, DBConnectionsActive, RedisConnectionsActive)
}
// Handler returns a Fiber handler that serves the Prometheus metrics endpoint.
// It wraps promhttp.Handler() without requiring the gofiber/adaptor package.
func Handler(c *fiber.Ctx) error {
handler := promhttp.Handler()
req, err := http.NewRequest(http.MethodGet, "/metrics", nil)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
result := rec.Result()
defer result.Body.Close()
c.Set("Content-Type", result.Header.Get("Content-Type"))
c.Status(result.StatusCode)
body, err := io.ReadAll(result.Body)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
return c.Send(body)
}

View File

@@ -85,7 +85,9 @@ func Idempotency(c *fiber.Ctx) error {
// Processing failed — remove the key so it can be retried
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
database.RDB.Del(delCtx, redisKey)
if delErr := database.RDB.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
return err
}
@@ -104,7 +106,9 @@ func Idempotency(c *fiber.Ctx) error {
// Non-success — allow retry by removing the key
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
database.RDB.Del(delCtx, redisKey)
if delErr := database.RDB.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
}
return nil

25
pkg/middleware/metrics.go Normal file
View File

@@ -0,0 +1,25 @@
package middleware
import (
"strconv"
"time"
"a301_server/pkg/metrics"
"github.com/gofiber/fiber/v2"
)
// Metrics records HTTP request count and duration as Prometheus metrics.
func Metrics(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
duration := time.Since(start).Seconds()
status := strconv.Itoa(c.Response().StatusCode())
path := c.Route().Path // use route pattern to avoid cardinality explosion
method := c.Method()
metrics.HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
metrics.HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
return err
}