Initial commit;

This commit is contained in:
2026-01-17 22:45:02 +03:00
commit 2b8c774d0a
34 changed files with 1728 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

9
cmd/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"steam_analyzer/internal/app"
)
func main() {
app.Run()
}

20
config/config.dev.yaml Normal file
View File

@@ -0,0 +1,20 @@
server:
host: localhost
port: 3000
origins:
- localhost:3000
database:
host: localhost
port: 32768
name: app-database
user: admin-programmer
password: oK79k(Q#E<#|YuBL6)|TMre9£DE}F2B,t}vYt5D6e<m.7vu<pp
migration_path: ./db/migrations
steam_requester:
request_per_time: 4s
count_per_time: 1
steam_worker:
request_per_time: 10m

20
config/config.prod.yaml Normal file
View File

@@ -0,0 +1,20 @@
server:
host: 0.0.0.0
port: 3000
origins:
- localhost:3000
database:
host: steam_db
port: 5432
name: app-database
user: admin-programmer
password: oK79k(Q#E<#|YuBL6)|TMre9£DE}F2B,t}vYt5D6e<m.7vu<pp
migration_path: ./db/migrations
steam_requester:
request_per_time: 4s
count_per_time: 1
steam_worker:
request_per_time: 10m

20
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod tidy && go mod download
COPY . .
RUN GOOS=linux go build -o steam-parser ./cmd/main.go
# --------------------------------- #
FROM alpine:latest AS configure
RUN apk add --no-cache tzdata
WORKDIR /app/
COPY --from=builder /app/steam-parser .
CMD ["./steam-parser"]

37
docker-compose.local.yaml Normal file
View File

@@ -0,0 +1,37 @@
version: "3.9"
services:
db:
image: postgres:latest
container_name: steam_db
restart: unless-stopped
environment:
- POSTGRES_DB=${DATABASE_NAME}
- POSTGRES_USER=${DATABASE_USER}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
ports:
- ${DATABASE_PORT}:5432
# volumes:
# - ./volumes/steam/db-data:/var/lib/postgresql/data
networks:
- overlay
pgadmin:
image: dpage/pgadmin4:latest
container_name: steam_pgadmin
restart: unless-stopped
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}
volumes:
- pgadmin_data:/var/lib/pgadmin
ports:
- ${PG_ADMIN_PORT}:80
networks:
- overlay
networks:
overlay:
volumes:
pgadmin_data:

59
docker-compose.yaml Normal file
View File

@@ -0,0 +1,59 @@
version: "3.9"
networks:
overlay:
volumes:
pgadmin_data:
services:
steam_parser:
image: dm1roshnikov/steam-parser:latest
ports:
- 3000:3000
volumes:
- ./config/config.prod.yaml:/app/config/config.yaml:ro
networks:
- overlay
depends_on:
db:
condition: service_healthy
db:
image: postgres:latest
container_name: steam_db
restart: unless-stopped
environment:
- POSTGRES_USER=${DATABASE_USER}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=${DATABASE_NAME}
ports:
- ${DATABASE_PORT}:5432
# volumes:
# - ./volumes/steam/db-data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- overlay
pgadmin:
image: dpage/pgadmin4:latest
container_name: steam_pgadmin
restart: unless-stopped
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}
volumes:
- pgadmin_data:/var/lib/pgadmin
ports:
- ${PGADMIN_PORT}:80
networks:
- overlay

30
go.mod Normal file
View File

@@ -0,0 +1,30 @@
module steam_analyzer
go 1.25.3
require (
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/jackc/pgx/v5 v5.7.6
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.32
github.com/rs/cors v1.11.1
golang.org/x/time v0.14.0
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)

110
go.sum Normal file
View File

@@ -0,0 +1,110 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=

68
internal/app/app.go Normal file
View 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
View 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
View 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
}

View 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})
}

View 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
}

View 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
}

View 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
View 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
}

View 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
View 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"`
}

View 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
}

View 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)
}

View 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
View File

@@ -0,0 +1,3 @@
package utils
var CURRENCY_RUB = 78.41

33
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,33 @@
package logger
import (
"log/slog"
"os"
)
const (
envLocal = "local"
envDevelopment = "development"
envProduction = "production"
)
func New(env string) *slog.Logger {
var logger *slog.Logger
switch env {
case envLocal:
logger = slog.New(
slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
)
case envDevelopment:
logger = slog.New(
slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
)
case envProduction:
logger = slog.New(
slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
)
}
return logger
}

11
pkg/postgresql/errors.go Normal file
View File

@@ -0,0 +1,11 @@
package postgresql
import "github.com/jackc/pgx/v5"
var (
ErrNoRows = pgx.ErrNoRows
)
const (
ErrConstraintUnique = "23505"
)

View File

@@ -0,0 +1,82 @@
package postgresql
import (
"errors"
"fmt"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
)
func RunMigrationsWithString(connectionString string, migrationDir string) error {
m, err := migrate.New(
"file://"+migrationDir,
connectionString)
if err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
if err := m.Up(); err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("apply migrations: %w", err)
}
log.Println("✅ Migrations applied")
return nil
}
type MigrationHandler struct {
migrate *migrate.Migrate
}
func (migration *MigrationHandler) Up() error {
if err := migration.migrate.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("apply migrations: %w", err)
}
log.Println("✅ Migrations applied")
return nil
}
func (migration *MigrationHandler) Down() error {
if err := migration.migrate.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("apply migrations: %w", err)
}
log.Println("✅ Migrations applied")
return nil
}
func NewMigrationWithInstance(pool *pgxpool.Pool, migrationDir string) (*MigrationHandler, error) {
db := stdlib.OpenDBFromPool(pool)
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return nil, fmt.Errorf("create migration driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://"+migrationDir,
"postgres", driver)
if err != nil {
return nil, fmt.Errorf("create migrator: %w", err)
}
//m.Down()
/*if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("apply migrations: %w", err)
}
log.Println("✅ Migrations applied")*/
return &MigrationHandler{
migrate: m,
}, nil
}

33
pkg/postgresql/pgx.go Normal file
View File

@@ -0,0 +1,33 @@
package postgresql
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
type PGXClient interface {
Prepare(query string) (*sql.Stmt, error)
ExecContext(ctx context.Context, query string, arguments ...interface{}) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}
func NewPGXClient(psqlInfo string) (*sql.DB, error) {
const op = "repository.postgresql.pq.NewConnection"
db, err := sql.Open("pgx", psqlInfo)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
err = db.Ping()
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
return db, nil
}

View File

@@ -0,0 +1,26 @@
package postgresql
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type PGXPool interface {
Exec(ctx context.Context, query string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, query string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, query string, args ...any) pgx.Row
SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults
BeginTx(ctx context.Context, opts pgx.TxOptions) (pgx.Tx, error)
}
func NewPGXPool(ctx context.Context, connectionString string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, connectionString)
if err != nil {
return nil, err
}
return pool, nil
}

41
pkg/postgresql/pq.go Normal file
View File

@@ -0,0 +1,41 @@
package postgresql
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
type PQClient interface {
Prepare(query string) (*sql.Stmt, error)
Exec(query string, arguments ...interface{}) (sql.Result, error)
Query(query string, args ...interface{}) (*sql.Rows, error)
QueryRow(query string, args ...interface{}) *sql.Row
// Begin(ctx context.Context) (*sql.Tx, error)
}
type PQClientContext interface {
Prepare(query string) (*sql.Stmt, error)
ExecContext(ctx context.Context, query string, arguments ...interface{}) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}
func NewPQClient(psqlInfo string) (*sql.DB, error) {
const op = "repository.postgresql.pq.NewConnection"
db, err := sql.Open("postgres", psqlInfo)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
err = db.Ping()
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
return db, nil
}

19
pkg/postgresql/utils.go Normal file
View File

@@ -0,0 +1,19 @@
package postgresql
import (
"fmt"
"net/url"
"steam_analyzer/internal/config"
)
func CreateDBConnectionString(cfg config.Database) string {
return fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=%s",
cfg.User,
url.QueryEscape(cfg.Password),
cfg.Host,
cfg.Port,
cfg.Name,
cfg.Sslmode,
)
}

58
pkg/server/server.go Normal file
View File

@@ -0,0 +1,58 @@
// Package server
// Contain struct and interface for project server
package server
import (
"context"
"fmt"
"log/slog"
"net/http"
"steam_analyzer/internal/config"
"time"
)
type ILogger interface {
Info(msg string, args ...any)
Debug(msg string, args ...any)
Error(msg string, args ...any)
}
type Server struct {
httpServer *http.Server
logger ILogger
}
type ServerParam struct {
Host string
Port string
Origins []string
IdleTimeout time.Duration
WriteTimeout time.Duration
ReadTimeout time.Duration
}
func New(cfg config.Server, log ILogger, h http.Handler) *Server {
return &Server{
httpServer: &http.Server{
Addr: cfg.Host + ":" + cfg.Port,
Handler: configureCORSFor(h, cfg.Origins),
// ErrorLog: log,
IdleTimeout: cfg.IdleTimeout,
WriteTimeout: cfg.WriteTimeout,
ReadTimeout: cfg.ReadTimeout,
},
logger: log,
}
}
func (s *Server) Run() {
s.logger.Info(fmt.Sprintf("Server started at %s", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); err != nil {
s.logger.Error("Cannot start server", slog.String("error", err.Error()))
}
}
func (s *Server) Stop(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}

27
pkg/server/utils.go Normal file
View File

@@ -0,0 +1,27 @@
package server
import (
"net/http"
"github.com/rs/cors"
)
func configureCORSFor(handler http.Handler, origins []string) http.Handler {
ch := cors.New(cors.Options{
// # http://mywebsite-domain.com/ is configured in hosts (localhost:80 alias)
AllowedOrigins: origins,
AllowedMethods: []string{
http.MethodPost,
http.MethodGet,
http.MethodPut,
http.MethodDelete,
// http.MethodOptions,
},
OptionsPassthrough: false,
AllowCredentials: true,
// Debug: true,
})
return ch.Handler(handler)
}

View File

@@ -0,0 +1,5 @@
package httputils
type HTTPError struct {
Message string `json:"message"`
}

View File

@@ -0,0 +1,19 @@
package jsonutils
import (
"encoding/json"
"io"
)
// ToJSON serializes the given interface into a string based JSON format
func ToJSON(i interface{}, w io.Writer) error {
e := json.NewEncoder(w)
return e.Encode(i)
}
// FromJSON deserializes the object from JSON string
// in an io.Reader to the given interface
func FromJSON(i interface{}, r io.Reader) error {
d := json.NewDecoder(r)
return d.Decode(i)
}