Handlers, DB, Cache

This commit is contained in:
2026-03-10 19:17:43 +03:00
parent e56b1f1305
commit 968c030939
23 changed files with 566 additions and 34 deletions

View File

@@ -11,6 +11,8 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/fx"
"payouts/internal/api/payment"
"payouts/internal/api/user"
"payouts/internal/api/version"
appConfig "payouts/internal/config"
"payouts/internal/service/monitoring"
@@ -18,8 +20,11 @@ import (
// Module is a fx module
var Module = fx.Options(
user.Module,
payment.Module,
version.Module,
monitoring.Module,
fx.Invoke(RegisterRoutes),
)
@@ -32,7 +37,9 @@ type Params struct {
AppConfig *appConfig.App
Version version.Handler
PaymentHandler payment.Handler
UserHandler user.Handler
Version version.Handler
Metrics monitoring.Metrics
}
@@ -54,11 +61,16 @@ func RegisterRoutes(p Params, lc fx.Lifecycle) {
}
apiRouter := router.PathPrefix(BaseRoute).Subrouter()
apiRouter.HandleFunc("/test", func(http.ResponseWriter, *http.Request) {
slog.Info("Test called", slog.String("sample", "value"))
})
// data
userRouter := apiRouter.PathPrefix(user.BaseRoute).Subrouter()
userRouter.HandleFunc(user.RegisterRoute, p.UserHandler.UserRegister).Methods(http.MethodPost)
userRouter.HandleFunc(user.LoginRoute, p.UserHandler.UserLogin).Methods(http.MethodPost)
paymentRouter := apiRouter.PathPrefix(payment.BaseRoute).Subrouter()
paymentRouter.HandleFunc(payment.CreateRoute, p.PaymentHandler.PaymentCreate).Methods(http.MethodPost)
paymentRouter.HandleFunc(payment.CallbackRoute, p.PaymentHandler.PaymentCallback).Methods(http.MethodPost)
// collect api metrics
apiRouter.Use(p.Metrics.GetMiddleware())
router.Handle(p.AppConfig.Metrics.Endpoint, promhttp.Handler())

View File

@@ -0,0 +1,17 @@
package payment
import (
"net/http"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(NewPaymentHandler),
)
type Handler interface {
GetSbpBanks(http.ResponseWriter, *http.Request)
PaymentCreate(http.ResponseWriter, *http.Request)
PaymentCallback(http.ResponseWriter, *http.Request)
}

View File

@@ -0,0 +1,54 @@
package payment
import (
"net/http"
"go.uber.org/fx"
"payouts/internal/config"
"payouts/internal/service/cache"
"payouts/internal/service/database"
)
const (
BaseRoute = "/payment"
CreateRoute = "/create"
CallbackRoute = "/callback"
BanksRoute = "/sbp/banks"
)
type paymentHandler struct {
dbService database.Service
cacheService cache.Service
}
// Params represents the module input params
type Params struct {
fx.In
AppConfig *config.App
DbService database.Service
CacheService cache.Service
}
func NewPaymentHandler(p Params) (Handler, error) {
return &paymentHandler{
dbService: p.DbService,
cacheService: p.CacheService,
}, nil
}
// GetSbpBanks implements [Handler].
func (p *paymentHandler) GetSbpBanks(http.ResponseWriter, *http.Request) {
panic("unimplemented")
}
// PaymentCreate implements [Handler].
func (p *paymentHandler) PaymentCreate(http.ResponseWriter, *http.Request) {
panic("unimplemented")
}
// PaymentCallback implements [Handler].
func (p *paymentHandler) PaymentCallback(http.ResponseWriter, *http.Request) {
panic("unimplemented")
}

View File

@@ -0,0 +1,16 @@
package user
import (
"net/http"
"go.uber.org/fx"
)
type Handler interface {
UserRegister(http.ResponseWriter, *http.Request)
UserLogin(http.ResponseWriter, *http.Request)
}
var Module = fx.Options(
fx.Provide(NewUserHandler),
)

View File

@@ -0,0 +1,144 @@
package user
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jinzhu/copier"
"go.uber.org/fx"
"golang.org/x/crypto/bcrypt"
"payouts/internal/config"
"payouts/internal/models"
"payouts/internal/service/cache"
"payouts/internal/service/database"
"payouts/internal/service/database/orm"
)
const (
BaseRoute = "/user"
RegisterRoute = "/register"
LoginRoute = "/login"
)
type userHandler struct {
ttl time.Duration
dbService database.Service
cacheService cache.Service
}
// Params represents the module input params
type Params struct {
fx.In
AppConfig *config.App
DbService database.Service
CacheService cache.Service
}
func NewUserHandler(p Params) (Handler, error) {
return &userHandler{
ttl: p.AppConfig.Cache.TTL,
dbService: p.DbService,
cacheService: p.CacheService,
}, nil
}
// UserRegister implements [Handler].
func (u *userHandler) UserRegister(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
errResponse := func(message string, err error, status int) {
http.Error(w, errors.Join(errors.New(message), err).Error(), status)
}
user := models.UserRegister{}
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
slog.Error("Failed to get password hash", slog.String("error", err.Error()))
errResponse("failed to decode request body", err, http.StatusBadRequest)
return
}
if user.Passwd != user.PasswdCfm || len(user.Passwd) == 0 || len(user.Phone) == 0 || len(user.TIN) == 0 {
slog.Error("No required parameters passed")
errResponse("invalid parameters", nil, http.StatusBadRequest)
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Passwd), bcrypt.DefaultCost)
if err != nil {
slog.Error("Failed to get password hash", slog.String("error", err.Error()))
errResponse("internal error", nil, http.StatusInternalServerError)
return
}
user.PasswdHash = string(hashedPassword)
ormUser := orm.User{}
copier.Copy(&ormUser, user)
// todo: add data validation
err = u.dbService.CreateUser(ormUser, database.WithContext(r.Context()))
if err != nil {
slog.Error("Failed to create user", slog.String("error", err.Error()))
errResponse("failed to create user", err, http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
}
// UserLogin implements [Handler].
func (u *userHandler) UserLogin(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
errResponse := func(message string, err error, status int) {
w.Header().Set("Content-Type", "text/plain")
http.Error(w, errors.Join(errors.New(message), err).Error(), status)
}
user := models.UserLoginReq{}
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
errResponse("failed to decode request body", err, http.StatusBadRequest)
return
}
if len(user.Phone) == 0 || len(user.Passwd) == 0 {
slog.Error("No required parameters passed")
errResponse("invalid parameters", nil, http.StatusBadRequest)
return
}
ormUser, err := u.dbService.GetUser(orm.User{Phone: user.Phone}, database.WithContext(r.Context()))
if err != nil {
errResponse("invalid credentials", nil, http.StatusUnauthorized)
return
}
err = bcrypt.CompareHashAndPassword([]byte(ormUser.PasswdHash), []byte(user.Passwd))
if err != nil {
errResponse("invalid credentials", nil, http.StatusUnauthorized)
return
}
sessionId := uuid.New().String()
u.cacheService.PutSession(sessionId, ormUser)
resp := models.UserLoginResp{
Token: sessionId,
TokenTtl: time.Now().Add(u.ttl).Unix(),
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(resp)
if err != nil {
errResponse("failed to encode response", err, http.StatusInternalServerError)
return
}
}

View File

@@ -2,13 +2,18 @@ package config
import (
logging "payouts/internal/log/config"
cache "payouts/internal/service/cache/config"
database "payouts/internal/service/database/config"
monitoring "payouts/internal/service/monitoring/config"
yookassa "payouts/internal/service/yookassa/config"
)
type App struct {
Server Server
Metrics monitoring.Metrics
Database database.Database
Cache cache.Cache
Log logging.Log
YooKassa yookassa.YooKassa
}

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -17,6 +18,7 @@ import (
const (
ConfigPathArg = "config-path"
ConfigPathDefault = "./payouts.properties"
envConfigFile = "CONFIG_PATH"
)
var Module = fx.Provide(NewAppConfig)
@@ -36,7 +38,7 @@ func getConfigData(filePath string) (string, string, string) {
func NewAppConfig() (*App, error) {
mainConfig := &App{}
configPaths := []string{ConfigPathDefault}
configPaths := []string{ConfigPathDefault, os.Getenv(envConfigFile)}
configPath := pflag.String(ConfigPathArg, "", "")
pflag.Parse()

20
internal/models/user.go Normal file
View File

@@ -0,0 +1,20 @@
package models
type UserRegister struct {
TIN string `json:"tin"`
Phone string `json:"phone"`
Passwd string `json:"password,omitempty"`
PasswdCfm string `json:"password_cfm,omitempty"`
PasswdHash string `json:"-"`
}
type UserLoginReq struct {
Phone string `json:"phone"`
Passwd string `json:"password"`
PasswdHash string `json:"-"`
}
type UserLoginResp struct {
Token string `json:"token"`
TokenTtl int64 `json:"token_ttl"`
}

39
internal/service/cache/cache_service.go vendored Normal file
View File

@@ -0,0 +1,39 @@
package cache
import (
"time"
"github.com/jellydator/ttlcache/v3"
"payouts/internal/service/database/orm"
)
type cacheService struct {
cache *ttlcache.Cache[string, orm.User]
}
func NewSessionCache(ttl time.Duration) (Service, error) {
return &cacheService{
cache: ttlcache.New(ttlcache.WithTTL[string, orm.User](ttl)),
}, nil
}
// PutSession implements [Service].
func (c *cacheService) PutSession(sessionID string, user orm.User) {
c.cache.Set(sessionID, user, ttlcache.DefaultTTL)
}
// GetUserFromSession implements [Service].
func (c *cacheService) GetSession(sessionId string) (orm.User, error) {
if !c.cache.Has(sessionId) {
return orm.User{}, NoSessionFound
}
cachedItem := c.cache.Get(sessionId)
return cachedItem.Value(), nil
}
// StartBackground implements [Service].
func (c *cacheService) StartBackground() {
c.cache.Start()
}

View File

@@ -0,0 +1,7 @@
package config
import "time"
type Cache struct {
TTL time.Duration
}

50
internal/service/cache/module.go vendored Normal file
View File

@@ -0,0 +1,50 @@
package cache
import (
"context"
"errors"
"go.uber.org/fx"
"payouts/internal/config"
"payouts/internal/service/database/orm"
)
var Module = fx.Options(
fx.Provide(New),
fx.Invoke(StartCache),
)
var NoSessionFound = errors.New("no session found")
type Service interface {
PutSession(string, orm.User)
GetSession(string) (orm.User, error)
StartBackground()
}
type Params struct {
fx.In
AppConfig *config.App
}
func New(p Params) (Service, error) {
return NewSessionCache(p.AppConfig.Cache.TTL)
}
// RegisterRoutes registers the api routes and starts the http server
func StartCache(s Service, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(c context.Context) error {
go func() {
s.StartBackground()
}()
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
}

View File

@@ -1,7 +1,8 @@
package config
type Database struct {
Type string
Connection string
LogLevel string
Type string
Connection string
LogLevel string
TraceRequests bool
}

View File

@@ -1,12 +1,17 @@
package database
import (
"context"
"errors"
"log/slog"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"payouts/internal/service/database/config"
"payouts/internal/service/database/orm"
)
type dbService struct {
@@ -14,31 +19,67 @@ type dbService struct {
db *gorm.DB
}
func NewDatabaseService(dbType, connection, logLevel string) (DatabaseService, error) {
func NewDatabaseService(conf config.Database) (Service, error) {
var dialector gorm.Dialector
switch dbType {
switch conf.Type {
case "sqlite":
dialector = sqlite.Open(connection)
dialector = sqlite.Open(conf.Connection)
case "postgres":
dialector = postgres.Open(connection)
dialector = postgres.Open(conf.Connection)
default:
return nil, errors.New("unknown dbType")
}
level := slog.LevelInfo
level.UnmarshalText([]byte(conf.LogLevel))
slogGormOpts := []slogGorm.Option{
slogGorm.SetLogLevel(slogGorm.DefaultLogType, level),
}
if conf.TraceRequests {
slogGormOpts = append(slogGormOpts, slogGorm.WithTraceAll())
}
slogGorm := slogGorm.New(slogGormOpts...)
db, err := gorm.Open(dialector, &gorm.Config{
Logger: slogGorm.New(),
Logger: slogGorm,
})
if err == nil {
db.DB()
db.AutoMigrate()
// db.LogMode(true)
db.AutoMigrate(&orm.User{})
}
result := &dbService{}
result.dbType = dbType
result.dbType = conf.Type
result.db = db
return result, err
}
func getParams(options ...Optional) *params {
params := &params{
ctx: context.Background(),
}
for _, opt := range options {
opt(params)
}
return params
}
// AddUser implements [Service].
func (d *dbService) CreateUser(userModel orm.User, opts ...Optional) error {
p := getParams(opts...)
return gorm.G[orm.User](d.db).Create(p.ctx, &userModel)
}
// GetUser implements [Service].
func (d *dbService) GetUser(userModel orm.User, opts ...Optional) (orm.User, error) {
p := getParams(opts...)
userResp, err := gorm.G[orm.User](d.db).Where(&userModel).First(p.ctx)
return userResp, err
}

View File

@@ -1,16 +1,32 @@
package database
import (
"payouts/internal/config"
"context"
"go.uber.org/fx"
"payouts/internal/config"
"payouts/internal/service/database/orm"
)
var Module = fx.Options(
fx.Provide(New),
)
type DatabaseService interface {
type params struct {
ctx context.Context
}
type Optional func(*params)
func WithContext(ctx context.Context) Optional {
return func(p *params) {
p.ctx = ctx
}
}
type Service interface {
CreateUser(user orm.User, opts ...Optional) error
GetUser(user orm.User, opts ...Optional) (orm.User, error)
}
// Params represents the module input params
@@ -21,6 +37,6 @@ type Params struct {
}
// NewPersistence instantiates the persistence module
func New(p Params) (DatabaseService, error) {
return NewDatabaseService(p.AppConfig.Database.Type, p.AppConfig.Database.Connection, p.AppConfig.Database.LogLevel)
func New(p Params) (Service, error) {
return NewDatabaseService(p.AppConfig.Database)
}

View File

@@ -0,0 +1,10 @@
package orm
import "gorm.io/gorm"
type User struct {
gorm.Model
TIN string
Phone string `gorm:"uniqueIndex:idx_phone"`
PasswdHash string
}

View File

@@ -0,0 +1,10 @@
package config
type YooKassa struct {
BaseUrl string
ApiBaseKey string
ApiBaseSecret string
ApiPaymentKey string
ApiPaymentSecret string
}

View File

@@ -0,0 +1,37 @@
package yookassa
import (
"context"
"go.uber.org/fx"
"payouts/internal/config"
)
var Module = fx.Options(
fx.Provide(New),
)
type params struct {
ctx context.Context
}
type Optional func(*params)
func WithContext(ctx context.Context) Optional {
return func(p *params) {
p.ctx = ctx
}
}
type Service interface {
}
type Param struct {
fx.In
AppConfig *config.App
}
func New(p Param) (Service, error) {
return NewYookassaService(p.AppConfig.YooKassa)
}

View File

@@ -0,0 +1,13 @@
package yookassa
import "payouts/internal/service/yookassa/config"
type yookassaService struct {
conf config.YooKassa
}
func NewYookassaService(conf config.YooKassa) (Service, error) {
return &yookassaService{
conf: conf,
}, nil
}