/
avpavlow
/
wallet-api
Обзор
Документация
Войти
/
avpavlow
/
wallet-api
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
coverage.html
451 строка
17 KB
aleksey
feat: Добавить в makefile команду для выполения тестов о покрытиях тестами
26 мар 2026, 14:11
26 мар 2026, 14:11
5a9aed3
Код
Авторство
О чём код?
<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <title>api: Go Coverage Report</title> <style> body { background: black; color: rgb(80, 80, 80); } body, pre, #legend span { font-family: Menlo, monospace; font-weight: bold; } #topbar { background: black; position: fixed; top: 0; left: 0; right: 0; height: 42px; border-bottom: 1px solid rgb(80, 80, 80); } #content { margin-top: 50px; } #nav, #legend { float: left; margin-left: 10px; } #legend { margin-top: 12px; } #nav { margin-top: 10px; } #legend span { margin: 0 5px; } .cov0 { color: rgb(192, 0, 0) } .cov1 { color: rgb(128, 128, 128) } .cov2 { color: rgb(116, 140, 131) } .cov3 { color: rgb(104, 152, 134) } .cov4 { color: rgb(92, 164, 137) } .cov5 { color: rgb(80, 176, 140) } .cov6 { color: rgb(68, 188, 143) } .cov7 { color: rgb(56, 200, 146) } .cov8 { color: rgb(44, 212, 149) } .cov9 { color: rgb(32, 224, 152) } .cov10 { color: rgb(20, 236, 155) } </style> </head> <body> <div id="topbar"> <div id="nav"> <select id="files"> <option value="file0">github.com/avpavlov-cloud/wallet-api/cmd/api/main.go (26.8%)</option> <option value="file1">github.com/avpavlov-cloud/wallet-api/internal/handlers/account.go (0.0%)</option> <option value="file2">github.com/avpavlov-cloud/wallet-api/internal/middleware/auth.go (0.0%)</option> <option value="file3">github.com/avpavlov-cloud/wallet-api/internal/middleware/logger.go (0.0%)</option> </select> </div> <div id="legend"> <span>not tracked</span> <span class="cov0">not covered</span> <span class="cov8">covered</span> </div> </div> <div id="content"> <pre class="file" id="file0" style="display: none">package main import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/avpavlov-cloud/wallet-api/internal/handlers" "github.com/avpavlov-cloud/wallet-api/internal/middleware" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" ) func SetupRouter(pool *pgxpool.Pool) *gin.Engine <span class="cov8" title="1">{ server := handlers.NewServer(pool) // 2. Инициализация Gin r := gin.Default() r.Use(gin.Recovery()) r.Use(middleware.JSONLogger()) protected := r.Group("/") protected.Use(middleware.AuthMiddleware()) </span><span class="cov8" title="1">{ protected.POST("/accounts", server.CreateAccountHandler) protected.POST("/transfer", server.TransferHandler) protected.GET("/accounts/:id", server.GetAccountHandlerfunc) }</span> <span class="cov8" title="1">return r</span> } func main() <span class="cov0" title="0">{ // Настраиваем JSON-обработчик: логи будут выходить в одну строку JSON // nil в опциях означает уровень по умолчанию (Info) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) // Устанавливаем его как глобальный логгер для всего приложения slog.SetDefault(logger) dbConnStr := os.Getenv("DB_SOURCE") if dbConnStr == "" </span><span class="cov0" title="0">{ // Это сработает, только если вы запускаете без Докера dbConnStr = "postgres://user:password@localhost:5432/wallet_db?sslmode=disable" }</span> // Используем пул соединений для высокой нагрузки <span class="cov0" title="0">dbPool, err := pgxpool.New(context.Background(), dbConnStr) if err != nil </span><span class="cov0" title="0">{ fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) }</span> <span class="cov0" title="0">defer dbPool.Close() // Проверка связи if err := dbPool.Ping(context.Background()); err != nil </span><span class="cov0" title="0">{ logger.Error("Database unreachable", "error", err) os.Exit(1) }</span> <span class="cov0" title="0">r := SetupRouter(dbPool) // Используем общую настройку // --- GRACEFUL SHUTDOWN LOGIC --- srv := &http.Server{ Addr: ":8000", Handler: r, } // Запуск сервера в отдельной горутине go func() </span><span class="cov0" title="0">{ slog.Info("Server is starting", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed </span><span class="cov0" title="0">{ slog.Error("server failed to start", "error", err) os.Exit(1) }</span> }() // Канал для системных сигналов <span class="cov0" title="0">quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit // Блокировка до получения сигнала (Ctrl+C или docker stop) slog.Info("shutting down server...") // Контекст ожидания завершения (5 секунд) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Останавливаем HTTP сервер if err := srv.Shutdown(ctx); err != nil </span><span class="cov0" title="0">{ slog.Error("server forced to shutdown", "error", err) os.Exit(1) }</span> <span class="cov0" title="0">slog.Info("server exiting gracefully")</span> } </pre> <pre class="file" id="file1" style="display: none">package handlers import ( "context" "log/slog" "net/http" "time" "github.com/avpavlov-cloud/wallet-api/internal/model" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" ) type Server struct { DbPool *pgxpool.Pool // Или *sql.DB, если используете стандартный драйвер } // Конструктор func NewServer(db *pgxpool.Pool) *Server <span class="cov0" title="0">{ return &Server{DbPool: db} }</span> func (s *Server) CreateAccountHandler(c *gin.Context) <span class="cov0" title="0">{ var req model.CreateAccountRequest // Валидация JSON if err := c.ShouldBindJSON(&req); err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return }</span> // SQL запрос на вставку <span class="cov0" title="0">query := `INSERT INTO accounts (owner_name, balance, currency) VALUES ($1, $2, $3) RETURNING id, created_at` var id int64 var createdAt time.Time err := s.DbPool.QueryRow(context.Background(), query, req.OwnerName, req.Balance, req.Currency).Scan(&id, &createdAt) if err != nil </span><span class="cov0" title="0">{ slog.Error("failed to create account", "error", err, "owner", req.OwnerName, ) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create account"}) return }</span> <span class="cov0" title="0">c.JSON(http.StatusCreated, gin.H{ "id": id, "owner_name": req.OwnerName, "balance": req.Balance, "currency": req.Currency, "created_at": createdAt, }) slog.Info("account created successfully", "id", id)</span> } func (s *Server) TransferHandler(c *gin.Context) <span class="cov0" title="0">{ var req model.TransferRequest if err := c.ShouldBindJSON(&req); err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return }</span> // Защита от перевода самому себе <span class="cov0" title="0">if req.FromAccountID == req.ToAccountID </span><span class="cov0" title="0">{ c.JSON(http.StatusBadRequest, gin.H{"error": "cannot transfer to the same account"}) return }</span> <span class="cov0" title="0">tx, err := s.DbPool.Begin(context.Background()) if err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusInternalServerError, gin.H{"error": "tx failed"}) return }</span> <span class="cov0" title="0">defer tx.Rollback(context.Background()) // --- МЕХАНИЗМ СОРТИРОВКИ ID --- firstID, secondID := req.FromAccountID, req.ToAccountID if firstID > secondID </span><span class="cov0" title="0">{ firstID, secondID = secondID, firstID }</span> // Сначала блокируем (SELECT FOR UPDATE) "меньший" ID, затем "больший" // Это гарантирует, что любая другая транзакция по этим ID пойдет по тому же пути <span class="cov0" title="0">_, err = tx.Exec(context.Background(), "SELECT id FROM accounts WHERE id = $1 FOR UPDATE", firstID) _, err = tx.Exec(context.Background(), "SELECT id FROM accounts WHERE id = $1 FOR UPDATE", secondID) // ------------------------------ // Теперь выполняем саму логику списания/зачисления // Снимаем деньги res, err := tx.Exec(context.Background(), "UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1", req.Amount, req.FromAccountID) if err != nil || res.RowsAffected() == 0 </span><span class="cov0" title="0">{ c.JSON(http.StatusBadRequest, gin.H{"error": "insufficient funds or sender error"}) return }</span> // Зачисляем деньги <span class="cov0" title="0">res, err = tx.Exec(context.Background(), "UPDATE accounts SET balance = balance + $1 WHERE id = $2", req.Amount, req.ToAccountID) if err != nil || res.RowsAffected() == 0 </span><span class="cov0" title="0">{ c.JSON(http.StatusBadRequest, gin.H{"error": "receiver error"}) return }</span> // Записываем лог <span class="cov0" title="0">_, _ = tx.Exec(context.Background(), "INSERT INTO transactions (from_account_id, to_account_id, amount) VALUES ($1, $2, $3)", req.FromAccountID, req.ToAccountID, req.Amount) if err := tx.Commit(context.Background()); err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"}) return }</span> <span class="cov0" title="0">c.JSON(http.StatusOK, gin.H{"message": "transfer successful"})</span> } func (s *Server) GetAccountHandlerfunc(c *gin.Context) <span class="cov0" title="0">{ id := c.Param("id") // Используем транзакцию только для чтения (Read Committed), // чтобы гарантировать Консистентность (C) tx, err := s.DbPool.Begin(context.Background()) if err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return }</span> <span class="cov0" title="0">defer tx.Rollback(context.Background()) // 1. Получаем данные счета var acc model.Account err = tx.QueryRow(context.Background(), "SELECT id, owner_name, balance, currency, created_at FROM accounts WHERE id = $1", id). Scan(&acc.ID, &acc.OwnerName, &acc.Balance, &acc.Currency, &acc.CreatedAt) if err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) return }</span> // 2. Получаем последние 5 транзакций (где этот счет был отправителем или получателем) <span class="cov0" title="0">rows, err := tx.Query(context.Background(), ` SELECT id, from_account_id, to_account_id, amount, created_at FROM transactions WHERE from_account_id = $1 OR to_account_id = $1 ORDER BY created_at DESC LIMIT 5`, id) if err != nil </span><span class="cov0" title="0">{ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch transactions"}) return }</span> <span class="cov0" title="0">defer rows.Close() var history []gin.H for rows.Next() </span><span class="cov0" title="0">{ var tid, from, to int64 var amt float64 var cat time.Time if err := rows.Scan(&tid, &from, &to, &amt, &cat); err == nil </span><span class="cov0" title="0">{ history = append(history, gin.H{ "id": tid, "from": from, "to": to, "amount": amt, "at": cat, }) }</span> } <span class="cov0" title="0">c.JSON(http.StatusOK, gin.H{ "account": acc, "history": history, })</span> } </pre> <pre class="file" id="file2" style="display: none">package middleware import ( "net/http" "os" "github.com/gin-gonic/gin" ) func AuthMiddleware() gin.HandlerFunc <span class="cov0" title="0">{ return func(c *gin.Context) </span><span class="cov0" title="0">{ // Извлекаем ключ из заголовка X-API-KEY apiKey := c.GetHeader("X-API-KEY") expectedKey := os.Getenv("API_KEY") // Если ключа нет или он не совпадает — прерываем запрос if apiKey == "" || apiKey != expectedKey </span><span class="cov0" title="0">{ c.JSON(http.StatusUnauthorized, gin.H{"error": "Неавторизованный доступ"}) c.Abort() // Критически важно: это не дает запросу идти дальше return }</span> <span class="cov0" title="0">c.Next()</span> // Переход к следующему обработчику } } </pre> <pre class="file" id="file3" style="display: none">package middleware import ( "log/slog" "time" "github.com/gin-gonic/gin" ) // JSONLogger возвращает Middleware для Gin, который пишет логи в формате JSON func JSONLogger() gin.HandlerFunc <span class="cov0" title="0">{ return func(c *gin.Context) </span><span class="cov0" title="0">{ start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery // Передаем управление следующему обработчику (эндпоинту) c.Next() // Данные собираются ПОСЛЕ выполнения запроса end := time.Since(start) status := c.Writer.Status() // Используем структурированный логгер slog slog.Info("http_request", slog.String("method", c.Request.Method), slog.String("path", path), slog.String("query", query), slog.Int("status", status), slog.Duration("duration", end), slog.String("ip", c.ClientIP()), slog.String("user_agent", c.Request.UserAgent()), slog.String("error", c.Errors.ByType(gin.ErrorTypePrivate).String()), ) }</span> } </pre> </div> </body> <script> (function() { var files = document.getElementById('files'); var visible; files.addEventListener('change', onChange, false); function select(part) { if (visible) visible.style.display = 'none'; visible = document.getElementById(part); if (!visible) return; files.value = part; visible.style.display = 'block'; location.hash = part; } function onChange() { select(files.value); window.scrollTo(0, 0); } if (location.hash != "") { select(location.hash.substr(1)); } if (!visible) { select("file0"); } })(); </script> </html>