Initial commit;
This commit is contained in:
68
internal/app/app.go
Normal file
68
internal/app/app.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"steam_analyzer/internal/config"
|
||||
"steam_analyzer/internal/handler"
|
||||
"steam_analyzer/internal/repository"
|
||||
"steam_analyzer/internal/service"
|
||||
"steam_analyzer/internal/steamreq"
|
||||
"steam_analyzer/internal/utils"
|
||||
"steam_analyzer/internal/utils/slogutils"
|
||||
"steam_analyzer/pkg/logger"
|
||||
"steam_analyzer/pkg/postgresql"
|
||||
"steam_analyzer/pkg/server"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var steamItemURL = `https://steamcommunity.com/market/search/render/`
|
||||
|
||||
func Run() {
|
||||
cfg := config.MustLoadYaml("./config/config.dev.yaml")
|
||||
logger := logger.New(cfg.Enviroment)
|
||||
|
||||
// db := repository.ConnectOrCreateDatabase()
|
||||
// repo := repository.NewSQLLiteDatabase(db)
|
||||
ctx := context.Background()
|
||||
|
||||
pool, err := postgresql.NewPGXPool(ctx, postgresql.CreateDBConnectionString(cfg.Database))
|
||||
if err != nil {
|
||||
logger.Error("Error while creating pool", slogutils.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
repo := repository.NewPostgresDB(pool)
|
||||
err = repo.InitDatabaseTables(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Error while init tables", slogutils.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
itemService := service.NewItemService(logger, repo)
|
||||
requester := steamreq.NewRequester(steamItemURL, cfg.SteamRequester.RequestPerTime, cfg.SteamRequester.CountPerTime)
|
||||
|
||||
steamWorker := steamreq.NewSteamWorker(logger, requester, itemService, cfg.SteamWorker.RequestPerTime)
|
||||
steamItems := []steamreq.SteamItems{
|
||||
{
|
||||
Query: "case",
|
||||
ItemType: "case",
|
||||
},
|
||||
}
|
||||
go steamWorker.Start(ctx, steamItems)
|
||||
|
||||
router := mux.NewRouter()
|
||||
steamHandler := handler.NewSteamHandler(itemService)
|
||||
steamHandler.RegisterRoutes(router, "/steam")
|
||||
|
||||
server := server.New(cfg.Server, logger, router)
|
||||
server.Run()
|
||||
}
|
||||
|
||||
func PrintItems(items []steamreq.SteamMarketItem) {
|
||||
for _, item := range items {
|
||||
item.SellPrice = float64(item.SellPrice) / 100.0 * utils.CURRENCY_RUB
|
||||
fmt.Println(item.Name, item.HashName, item.SellPrice, item.SellListings, item.AssetDescription.Classid)
|
||||
}
|
||||
}
|
||||
76
internal/config/config.go
Normal file
76
internal/config/config.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/ilyakaznacheev/cleanenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enviroment string `yaml:"enviroment" env:"ENVIROMENT" env-default:"local"`
|
||||
Server Server `yaml:"server"`
|
||||
Database Database `yaml:"database"`
|
||||
SteamRequester SteamRequester `yaml:"steam_requester"`
|
||||
SteamWorker SteamWorker `yaml:"steam_worker"`
|
||||
}
|
||||
|
||||
type (
|
||||
Server struct {
|
||||
Host string `yaml:"host" env-default:"localhost"`
|
||||
Port string `yaml:"port" env-default:"8080"`
|
||||
IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"30s"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout" env-default:"1s"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout" env-default:"1s"`
|
||||
Origins []string `yaml:"origins" env-separator:"," env-default:"http://localhost:3000,"`
|
||||
}
|
||||
Database struct {
|
||||
Host string `yaml:"host" env:"DATABASE_HOST" env-description:"Database host" env-default:"localhost"`
|
||||
Port string `yaml:"port" env:"DATABASE_PORT" env-description:"Database port" env-default:"5432"`
|
||||
Sslmode string `yaml:"sslmode" env-description:"Database ssl mode" env-default:"disable"`
|
||||
Name string `yaml:"name" env-description:"Database name"`
|
||||
User string `yaml:"user" env-description:"Database user"`
|
||||
Password string `yaml:"password" env-description:"Database user password"`
|
||||
|
||||
MaxConnAttempts int `yaml:"max_conn_attempt" env-default:"5"`
|
||||
ConnTimeout time.Duration `yaml:"conn_timeout" env-default:"10s"`
|
||||
|
||||
MigrationPath string `yaml:"migration_path" env:"DATABASE_MIGRATION_PATH" env-description:"Migration path"`
|
||||
}
|
||||
SteamRequester struct {
|
||||
RequestPerTime time.Duration `yaml:"request_per_time" env-description:"request limit per time" env-default:"5s"`
|
||||
CountPerTime int `yaml:"count_per_time" env-description:"count limit per time" env-default:"1"`
|
||||
}
|
||||
SteamWorker struct {
|
||||
RequestPerTime time.Duration `yaml:"request_per_time" env-description:"update prices time" env-default:"5m"`
|
||||
}
|
||||
)
|
||||
|
||||
func MustLoadYaml(configPath string) *Config {
|
||||
if configPath == "" {
|
||||
log.Fatal("config path is not set")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
log.Fatalf("config path doesn't exist: %s", configPath)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
|
||||
if err := cleanenv.ReadConfig(configPath, &cfg); err != nil {
|
||||
log.Fatalf("cannot read config: %s", err)
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func MustLoadEnv() *Config {
|
||||
var cfg Config
|
||||
|
||||
if err := cleanenv.ReadEnv(&cfg); err != nil {
|
||||
log.Fatalf("cannot read config: %s", err)
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
55
internal/domain/item.go
Normal file
55
internal/domain/item.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SteamItem struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
ClassID string
|
||||
GameID int
|
||||
Price float64
|
||||
Count int
|
||||
History []ItemPriceHistory
|
||||
}
|
||||
|
||||
func (si SteamItem) ToDB() DBItem {
|
||||
return DBItem{
|
||||
ID: si.ID,
|
||||
Name: si.Name,
|
||||
ClassID: si.ClassID,
|
||||
GameID: si.GameID,
|
||||
}
|
||||
}
|
||||
|
||||
func (si SteamItem) ToDBHistory() DBItemHistory {
|
||||
return DBItemHistory{
|
||||
ItemID: si.ID,
|
||||
Price: si.Price,
|
||||
Count: si.Count,
|
||||
Date: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
type ItemPriceHistory struct {
|
||||
Price float64
|
||||
Count int
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
type DBItem struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
ClassID string
|
||||
GameID int
|
||||
}
|
||||
|
||||
type DBItemHistory struct {
|
||||
ItemID uuid.UUID
|
||||
Price float64
|
||||
Count int
|
||||
Date time.Time
|
||||
}
|
||||
58
internal/handler/handler.go
Normal file
58
internal/handler/handler.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"steam_analyzer/internal/repository"
|
||||
"steam_analyzer/internal/utils/jsonutils"
|
||||
"steam_analyzer/pkg/utils/httputils"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type ItemService interface {
|
||||
GetAllItemsWithLastPrice(ctx context.Context, itemName string) ([]repository.ItemWithLastPrice, error)
|
||||
// GetAllItemsWithLastPrice(itemName string) ([]*domain.SteamItem, error)
|
||||
}
|
||||
|
||||
type SteamHandler struct {
|
||||
itemService ItemService
|
||||
}
|
||||
|
||||
func NewSteamHandler(itemService ItemService) SteamHandler {
|
||||
return SteamHandler{
|
||||
itemService: itemService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h SteamHandler) RegisterRoutes(router *mux.Router, prefix string) {
|
||||
steamRouter := router.PathPrefix(prefix)
|
||||
|
||||
getRouter := steamRouter.Methods(http.MethodGet).Subrouter()
|
||||
|
||||
getRouter.HandleFunc("/items", h.GetAllItemsWithLastPrice)
|
||||
}
|
||||
|
||||
type GetAllItemsWithLastPriceResponse struct {
|
||||
Result []repository.ItemWithLastPrice `json:"result"`
|
||||
}
|
||||
|
||||
func (h SteamHandler) GetAllItemsWithLastPrice(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
if name == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
jsonutils.ToJSON(w, httputils.HTTPError{Message: "Name query param couldn't be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.itemService.GetAllItemsWithLastPrice(r.Context(), name)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
jsonutils.ToJSON(w, GetAllItemsWithLastPriceResponse{Result: items})
|
||||
}
|
||||
202
internal/repository/postgres.go
Normal file
202
internal/repository/postgres.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"steam_analyzer/internal/domain"
|
||||
"steam_analyzer/pkg/postgresql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PostgresDB struct {
|
||||
conn postgresql.PGXPool
|
||||
}
|
||||
|
||||
func NewPostgresDB(conn postgresql.PGXPool) *PostgresDB {
|
||||
return &PostgresDB{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (db PostgresDB) InitDatabaseTables(ctx context.Context) error {
|
||||
createTable := `
|
||||
CREATE TABLE IF NOT EXISTS item (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT,
|
||||
class_id TEXT UNIQUE NOT NULL,
|
||||
game_id INTEGER NOT NULL,
|
||||
type_id INTEGER
|
||||
);`
|
||||
_, err := db.conn.Exec(ctx, createTable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createTable = `
|
||||
CREATE TABLE IF NOT EXISTS item_history (
|
||||
item_id UUID REFERENCES item(id),
|
||||
price REAL,
|
||||
count INTEGER,
|
||||
date TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (item_id, date)
|
||||
);`
|
||||
_, err = db.conn.Exec(ctx, createTable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db PostgresDB) AddItem(ctx context.Context, item domain.DBItem) error {
|
||||
query := `
|
||||
INSERT INTO item (id, name, class_id, game_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
_, err := db.conn.Exec(ctx, query,
|
||||
item.ID,
|
||||
item.Name,
|
||||
item.ClassID,
|
||||
item.GameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db PostgresDB) AddItemHistory(ctx context.Context, dbItemHistory domain.DBItemHistory) error {
|
||||
query := `
|
||||
INSERT INTO item_history (item_id, price, count, date)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
_, err := db.conn.Exec(ctx, query,
|
||||
dbItemHistory.ItemID,
|
||||
dbItemHistory.Price,
|
||||
dbItemHistory.Count,
|
||||
dbItemHistory.Date,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db PostgresDB) GetItemByClassID(ctx context.Context, classID string) (*domain.SteamItem, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
class_id
|
||||
FROM item
|
||||
WHERE class_id = $1
|
||||
`
|
||||
|
||||
row := db.conn.QueryRow(ctx, query, classID)
|
||||
var steamItem domain.SteamItem
|
||||
err := row.Scan(
|
||||
&steamItem.ID,
|
||||
&steamItem.Name,
|
||||
&steamItem.ClassID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &steamItem, nil
|
||||
}
|
||||
|
||||
func (db PostgresDB) GetItemPriceHistoryByID(ctx context.Context, itemID uuid.UUID, limit int, offset int) ([]domain.ItemPriceHistory, error) {
|
||||
query := `
|
||||
SELECT
|
||||
price,
|
||||
count,
|
||||
date
|
||||
FROM item_history
|
||||
WHERE item_id = $1
|
||||
ORDER BY date DESC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
`
|
||||
|
||||
var itemPriceHistory []domain.ItemPriceHistory
|
||||
rows, err := db.conn.Query(ctx, query,
|
||||
itemID,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var steamItemPriceHistory domain.ItemPriceHistory
|
||||
|
||||
err := rows.Scan(
|
||||
&steamItemPriceHistory.Price,
|
||||
&steamItemPriceHistory.Count,
|
||||
&steamItemPriceHistory.Date,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println("error while iterating rows", err)
|
||||
|
||||
continue
|
||||
}
|
||||
itemPriceHistory = append(itemPriceHistory, steamItemPriceHistory)
|
||||
}
|
||||
|
||||
return itemPriceHistory, nil
|
||||
}
|
||||
|
||||
func (db PostgresDB) GetItemsWithLastPriceByName(ctx context.Context, name string) ([]ItemWithLastPrice, error) {
|
||||
query := `
|
||||
SELECT
|
||||
class_id,
|
||||
name,
|
||||
price,
|
||||
last_date
|
||||
FROM item
|
||||
JOIN (
|
||||
SELECT
|
||||
item_id,
|
||||
MAX(price) AS price,
|
||||
MAX(date) AS last_date
|
||||
FROM item_history
|
||||
GROUP BY item_id
|
||||
) as last_prices
|
||||
ON item.id = last_prices.item_id
|
||||
WHERE LOWER(name) like $1
|
||||
ORDER BY last_date DESC;
|
||||
`
|
||||
|
||||
rows, err := db.conn.Query(ctx, query,
|
||||
"%"+name+"%",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var itemsWithLastPrice []ItemWithLastPrice
|
||||
var itemWithLastPrice ItemWithLastPrice
|
||||
for rows.Next() {
|
||||
err := rows.Scan(
|
||||
&itemWithLastPrice.ClassID,
|
||||
&itemWithLastPrice.Name,
|
||||
&itemWithLastPrice.Price,
|
||||
&itemWithLastPrice.Date,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println("error while scaning row", err)
|
||||
continue
|
||||
}
|
||||
|
||||
itemsWithLastPrice = append(itemsWithLastPrice, itemWithLastPrice)
|
||||
}
|
||||
|
||||
return itemsWithLastPrice, nil
|
||||
}
|
||||
221
internal/repository/sqlite.go
Normal file
221
internal/repository/sqlite.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Package repository for repositorie's
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"steam_analyzer/internal/domain"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SQLLiteDatabase struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLLiteDatabase(db *sql.DB) *SQLLiteDatabase {
|
||||
return &SQLLiteDatabase{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func ConnectOrCreateDatabase() *sql.DB {
|
||||
// Connect to (or create) the SQLite database
|
||||
db, err := sql.Open("sqlite3", "./steam_items.db")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func (sqldb SQLLiteDatabase) InitDatabaseTables() error {
|
||||
createTable := `
|
||||
CREATE TABLE IF NOT EXISTS item (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT,
|
||||
class_id TEXT UNIQUE NOT NULL,
|
||||
game_id INTEGER NOT NULL,
|
||||
type_id INTEGER
|
||||
);`
|
||||
_, err := sqldb.db.Exec(createTable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createTable = `
|
||||
CREATE TABLE IF NOT EXISTS item_history (
|
||||
item_id UUID REFERENCES item(id),
|
||||
price REAL,
|
||||
count INTEGER,
|
||||
date DATETIME NOT NULL,
|
||||
PRIMARY KEY (item_id, date)
|
||||
);`
|
||||
_, err = sqldb.db.Exec(createTable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sqldb SQLLiteDatabase) AddItem(item domain.DBItem) error {
|
||||
query := `
|
||||
INSERT INTO item (id, name, class_id, game_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := sqldb.db.Exec(query,
|
||||
item.ID,
|
||||
item.Name,
|
||||
item.ClassID,
|
||||
item.GameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sqldb SQLLiteDatabase) AddItemHistory(dbItemHistory domain.DBItemHistory) error {
|
||||
query := `
|
||||
INSERT INTO item_history (item_id, price, count, date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := sqldb.db.Exec(query,
|
||||
dbItemHistory.ItemID,
|
||||
dbItemHistory.Price,
|
||||
dbItemHistory.Count,
|
||||
dbItemHistory.Date,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sqldb SQLLiteDatabase) GetItemByClassID(classID string) (*domain.SteamItem, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
class_id
|
||||
FROM item
|
||||
WHERE class_id = ?
|
||||
`
|
||||
|
||||
row := sqldb.db.QueryRow(query, classID)
|
||||
var steamItem domain.SteamItem
|
||||
err := row.Scan(
|
||||
&steamItem.ID,
|
||||
&steamItem.Name,
|
||||
&steamItem.ClassID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &steamItem, nil
|
||||
}
|
||||
|
||||
func (sqldb SQLLiteDatabase) GetItemPriceHistoryByID(itemID uuid.UUID, limit int, offset int) ([]domain.ItemPriceHistory, error) {
|
||||
query := `
|
||||
SELECT
|
||||
price,
|
||||
count,
|
||||
date
|
||||
FROM item_history
|
||||
WHERE item_id = ?
|
||||
ORDER BY date DESC
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
`
|
||||
|
||||
var itemPriceHistory []domain.ItemPriceHistory
|
||||
rows, err := sqldb.db.Query(query,
|
||||
itemID,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var steamItemPriceHistory domain.ItemPriceHistory
|
||||
|
||||
err := rows.Scan(
|
||||
&steamItemPriceHistory.Price,
|
||||
&steamItemPriceHistory.Count,
|
||||
&steamItemPriceHistory.Date,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println("error while iterating rows", err)
|
||||
|
||||
continue
|
||||
}
|
||||
itemPriceHistory = append(itemPriceHistory, steamItemPriceHistory)
|
||||
}
|
||||
|
||||
return itemPriceHistory, nil
|
||||
}
|
||||
|
||||
func (sqldb SQLLiteDatabase) GetItemsWithLastPriceByName(name string) ([]ItemWithLastPrice, error) {
|
||||
query := `
|
||||
SELECT
|
||||
item.name,
|
||||
price,
|
||||
last_date
|
||||
FROM item
|
||||
JOIN (
|
||||
SELECT
|
||||
item_id,
|
||||
price,
|
||||
max(date) as last_date
|
||||
FROM item_history
|
||||
GROUP BY item_id
|
||||
) as last_prices
|
||||
ON item.id = last_prices.item_id
|
||||
WHERE item.name like ?
|
||||
ORDER BY last_date DESC
|
||||
`
|
||||
|
||||
rows, err := sqldb.db.Query(query,
|
||||
"%"+name+"%",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var itemsWithLastPrice []ItemWithLastPrice
|
||||
var itemWithLastPrice ItemWithLastPrice
|
||||
var lastDateStr string
|
||||
layout := "2006-01-02 15:04:05.999999999-07:00"
|
||||
for rows.Next() {
|
||||
err := rows.Scan(
|
||||
&itemWithLastPrice.Name,
|
||||
&itemWithLastPrice.Price,
|
||||
&lastDateStr,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println("error while scaning row", err)
|
||||
continue
|
||||
}
|
||||
|
||||
lastDate, err := time.Parse(layout, lastDateStr)
|
||||
if err != nil {
|
||||
fmt.Println("error while parsing date", err)
|
||||
continue
|
||||
}
|
||||
itemWithLastPrice.Date = lastDate
|
||||
|
||||
itemsWithLastPrice = append(itemsWithLastPrice, itemWithLastPrice)
|
||||
}
|
||||
|
||||
return itemsWithLastPrice, nil
|
||||
}
|
||||
10
internal/repository/type.go
Normal file
10
internal/repository/type.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package repository
|
||||
|
||||
import "time"
|
||||
|
||||
type ItemWithLastPrice struct {
|
||||
ClassID string
|
||||
Name string
|
||||
Price float64
|
||||
Date time.Time
|
||||
}
|
||||
129
internal/service/item.go
Normal file
129
internal/service/item.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Package service for service's
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"steam_analyzer/internal/domain"
|
||||
"steam_analyzer/internal/repository"
|
||||
"steam_analyzer/internal/utils"
|
||||
"steam_analyzer/internal/utils/slogutils"
|
||||
"steam_analyzer/pkg/postgresql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type ItemRepository interface {
|
||||
AddItem(ctx context.Context, item domain.DBItem) error
|
||||
AddItemHistory(ctx context.Context, dbItemHistory domain.DBItemHistory) error
|
||||
GetItemByClassID(ctx context.Context, classID string) (*domain.SteamItem, error)
|
||||
GetItemPriceHistoryByID(ctx context.Context, itemID uuid.UUID, limit int, offset int) ([]domain.ItemPriceHistory, error)
|
||||
GetItemsWithLastPriceByName(ctx context.Context, name string) ([]repository.ItemWithLastPrice, error)
|
||||
}
|
||||
|
||||
type ItemService struct {
|
||||
logger *slog.Logger
|
||||
|
||||
itemRepo ItemRepository
|
||||
}
|
||||
|
||||
func NewItemService(logger *slog.Logger, itemRepo ItemRepository) *ItemService {
|
||||
return &ItemService{
|
||||
logger: logger,
|
||||
itemRepo: itemRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ItemService) AddItemWithHistory(ctx context.Context, item domain.SteamItem) error {
|
||||
searchedItem, err := s.itemRepo.GetItemByClassID(ctx, item.ClassID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
s.logger.Info("Item is new, creating it")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if searchedItem != nil {
|
||||
item.ID = searchedItem.ID
|
||||
} else {
|
||||
item.ID = uuid.New()
|
||||
|
||||
err = s.itemRepo.AddItem(ctx, item.ToDB())
|
||||
if err != nil {
|
||||
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == postgresql.ErrConstraintUnique {
|
||||
s.logger.Error("Error while adding item", slog.String("error", "Item already exists"))
|
||||
} else {
|
||||
s.logger.Error("Error while adding item", slogutils.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = s.itemRepo.AddItemHistory(ctx, item.ToDBHistory())
|
||||
if err != nil {
|
||||
s.logger.Error("Error while adding item history", slogutils.Error(sql.ErrNoRows))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s ItemService) GetItemWithPriceHistoryByClassID(ctx context.Context, classID string, limit int, offset int) (*domain.SteamItem, error) {
|
||||
steamItem, err := s.itemRepo.GetItemByClassID(ctx, classID)
|
||||
if err != nil {
|
||||
s.logger.Error("Error while getting item by class id", slogutils.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemPriceHistory, err := s.itemRepo.GetItemPriceHistoryByID(ctx, steamItem.ID, limit, offset)
|
||||
if err != nil {
|
||||
s.logger.Error("Error while getting item price history by id", slogutils.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
steamItem.History = itemPriceHistory
|
||||
|
||||
return steamItem, nil
|
||||
}
|
||||
|
||||
func (s ItemService) GetAllItemsWithLastPrice(ctx context.Context, itemName string) ([]repository.ItemWithLastPrice, error) {
|
||||
s.logger.Info("ItemService.GetAllItemsWithLastPrice", slog.String("item name is", itemName))
|
||||
|
||||
items, err := s.itemRepo.GetItemsWithLastPriceByName(ctx, itemName)
|
||||
if err != nil {
|
||||
s.logger.Error("Error while getting item with last price", slogutils.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
items[i].Price = formatPrice(item.Price)
|
||||
items[i].Date = formatDate(item.Date)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func formatPrice(price float64) float64 {
|
||||
formattedPrice := price / 100 * utils.CURRENCY_RUB
|
||||
formattedPrice = math.Round(formattedPrice*100) / 100
|
||||
|
||||
return formattedPrice
|
||||
}
|
||||
|
||||
func formatDate(date time.Time) time.Time {
|
||||
location, _ := time.LoadLocation("Europe/Moscow")
|
||||
foramettedDate := date.In(location)
|
||||
|
||||
return foramettedDate
|
||||
}
|
||||
107
internal/steamreq/requester.go
Normal file
107
internal/steamreq/requester.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package steamreq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"steam_analyzer/internal/utils/jsonutils"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Categories struct {
|
||||
caseCat string
|
||||
knife string
|
||||
}
|
||||
|
||||
type Requester struct {
|
||||
baseMarketURL string
|
||||
categories Categories
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
func NewRequester(baseURL string, perTime time.Duration, count int) *Requester {
|
||||
return &Requester{
|
||||
baseMarketURL: baseURL,
|
||||
categories: Categories{
|
||||
caseCat: "category_730_Type[]=tag_CSGO_Type_WeaponCase",
|
||||
knife: "category_730_Type[]=tag_CSGO_Type_Knife",
|
||||
},
|
||||
limiter: rate.NewLimiter(rate.Every(perTime), count),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Requester) GetItem(query string, itemType string) ([]SteamMarketItem, error) {
|
||||
var steamMarketItems []SteamMarketItem
|
||||
|
||||
var pageCount int = 1
|
||||
for i := 0; i < pageCount; i++ {
|
||||
//if err := r.limiter.Wait(context.Background()); err != nil {
|
||||
// fmt.Printf("Limiter error: %v\n", err)
|
||||
// continue
|
||||
//}
|
||||
|
||||
response, err := r.doRequest(query, itemType, i)
|
||||
if err != nil {
|
||||
fmt.Printf("Error while doing request. Query: %v. ItemType: %v. Start %v", query, itemType, i)
|
||||
continue
|
||||
}
|
||||
|
||||
steamMarketItems = append(steamMarketItems, response.Results...)
|
||||
if i == 0 {
|
||||
pageCount = int(math.Ceil(float64(response.TotalCount) / float64(response.Pagesize)))
|
||||
}
|
||||
}
|
||||
|
||||
return steamMarketItems, nil
|
||||
}
|
||||
|
||||
func (r *Requester) doRequest(query string, itemType string, start int) (*Response, error) {
|
||||
itemURL := r.createItemURL(query, itemType, start*10)
|
||||
|
||||
// fmt.Println(itemURL)
|
||||
|
||||
req, err := http.NewRequest("GET", itemURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while creating request %v", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while making http request %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var steamResponse Response
|
||||
err = jsonutils.FromJSON(resp.Body, &steamResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while getting item %v", err)
|
||||
}
|
||||
|
||||
return &steamResponse, nil
|
||||
}
|
||||
|
||||
func (r *Requester) createItemURL(item string, itemType string, start int) string {
|
||||
marketItemsURL := r.baseMarketURL + `?query=%v&start=%v&count=%v&search_descriptions=0&sort_column=%v&sort_dir=%v&appid=730&norender=1`
|
||||
count := 10
|
||||
sortColumn := "price"
|
||||
sortDir := "asc"
|
||||
|
||||
url := fmt.Sprintf(marketItemsURL,
|
||||
item,
|
||||
start,
|
||||
count,
|
||||
sortColumn,
|
||||
sortDir,
|
||||
)
|
||||
|
||||
switch itemType {
|
||||
case "case":
|
||||
url = url + "&" + r.categories.caseCat
|
||||
case "knife":
|
||||
url = url + "&" + r.categories.knife
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
33
internal/steamreq/type.go
Normal file
33
internal/steamreq/type.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package steamreq
|
||||
|
||||
type Response struct {
|
||||
Start int `json:"start"`
|
||||
Pagesize int `json:"pagesize"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Results []SteamMarketItem `json:"results"`
|
||||
}
|
||||
|
||||
type SteamMarketItem struct {
|
||||
Name string `json:"name"`
|
||||
HashName string `json:"hash_name"`
|
||||
SellListings int `json:"sell_listings"`
|
||||
SellPrice float64 `json:"sell_price"`
|
||||
SellPriceText string `json:"sell_price_text"`
|
||||
AppIcon string `json:"app_icon"`
|
||||
AppName string `json:"app_name"`
|
||||
AssetDescription struct {
|
||||
Appid int `json:"appid"`
|
||||
Classid string `json:"classid"`
|
||||
Instanceid string `json:"instanceid"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
IconURL string `json:"icon_url"`
|
||||
Tradable int `json:"tradable"`
|
||||
Name string `json:"name"`
|
||||
NameColor string `json:"name_color"`
|
||||
Type string `json:"type"`
|
||||
MarketName string `json:"market_name"`
|
||||
MarketHashName string `json:"market_hash_name"`
|
||||
Commodity int `json:"commodity"`
|
||||
} `json:"asset_description"`
|
||||
SalePriceText string `json:"sale_price_text"`
|
||||
}
|
||||
80
internal/steamreq/worker.go
Normal file
80
internal/steamreq/worker.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package steamreq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"steam_analyzer/internal/domain"
|
||||
"steam_analyzer/internal/utils/slogutils"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IRequester interface {
|
||||
GetItem(query string, itemType string) ([]SteamMarketItem, error)
|
||||
}
|
||||
|
||||
type ItemService interface {
|
||||
AddItemWithHistory(ctx context.Context, item domain.SteamItem) error
|
||||
}
|
||||
|
||||
type SteamReqWorker struct {
|
||||
logger *slog.Logger
|
||||
requester IRequester
|
||||
itemService ItemService
|
||||
reqPer time.Duration
|
||||
}
|
||||
|
||||
func NewSteamWorker(logger *slog.Logger, requester IRequester, itemService ItemService, reqPer time.Duration) *SteamReqWorker {
|
||||
return &SteamReqWorker{
|
||||
logger: logger,
|
||||
requester: requester,
|
||||
itemService: itemService,
|
||||
reqPer: reqPer,
|
||||
}
|
||||
}
|
||||
|
||||
type SteamItems struct {
|
||||
Query string
|
||||
ItemType string
|
||||
}
|
||||
|
||||
func (w SteamReqWorker) Start(ctx context.Context, items []SteamItems) {
|
||||
for {
|
||||
for _, item := range items {
|
||||
items, err := w.requester.GetItem(item.Query, item.ItemType)
|
||||
if err != nil {
|
||||
w.logger.Error("Error while request item from steam", slogutils.Error(err))
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
w.logger.Info("Succesfully get items", slog.String("items count is", strconv.Itoa(len(items))))
|
||||
|
||||
domainItems := CreateItemsFromRequest(items)
|
||||
for _, domainItem := range domainItems {
|
||||
err := w.itemService.AddItemWithHistory(ctx, domainItem)
|
||||
if err != nil {
|
||||
w.logger.Error("Error while adding item with history", slogutils.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(w.reqPer)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateItemsFromRequest(smi []SteamMarketItem) []domain.SteamItem {
|
||||
steamItem := make([]domain.SteamItem, 0, len(smi))
|
||||
|
||||
for _, item := range smi {
|
||||
steamItem = append(steamItem, domain.SteamItem{
|
||||
Name: item.Name,
|
||||
ClassID: item.AssetDescription.Classid,
|
||||
GameID: item.AssetDescription.Appid,
|
||||
Price: item.SellPrice,
|
||||
Count: item.SellListings,
|
||||
})
|
||||
}
|
||||
|
||||
return steamItem
|
||||
}
|
||||
16
internal/utils/jsonutils/jsonutils.go
Normal file
16
internal/utils/jsonutils/jsonutils.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package jsonutils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
func ToJSON(w io.Writer, v interface{}) error {
|
||||
e := json.NewEncoder(w)
|
||||
return e.Encode(v)
|
||||
}
|
||||
|
||||
func FromJSON(r io.Reader, v interface{}) error {
|
||||
d := json.NewDecoder(r)
|
||||
return d.Decode(v)
|
||||
}
|
||||
10
internal/utils/slogutils/slogutils.go
Normal file
10
internal/utils/slogutils/slogutils.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package slogutils
|
||||
|
||||
import "log/slog"
|
||||
|
||||
func Error(err error) slog.Attr {
|
||||
return slog.Attr{
|
||||
Key: "error",
|
||||
Value: slog.StringValue(err.Error()),
|
||||
}
|
||||
}
|
||||
3
internal/utils/utils.go
Normal file
3
internal/utils/utils.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package utils
|
||||
|
||||
var CURRENCY_RUB = 78.41
|
||||
Reference in New Issue
Block a user