Initial commit;
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
9
cmd/main.go
Normal file
9
cmd/main.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"steam_analyzer/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app.Run()
|
||||||
|
}
|
||||||
20
config/config.dev.yaml
Normal file
20
config/config.dev.yaml
Normal 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
20
config/config.prod.yaml
Normal 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
20
deploy/Dockerfile
Normal 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
37
docker-compose.local.yaml
Normal 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
59
docker-compose.yaml
Normal 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
30
go.mod
Normal 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
110
go.sum
Normal 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
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
|
||||||
33
pkg/logger/logger.go
Normal file
33
pkg/logger/logger.go
Normal 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
11
pkg/postgresql/errors.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package postgresql
|
||||||
|
|
||||||
|
import "github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoRows = pgx.ErrNoRows
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrConstraintUnique = "23505"
|
||||||
|
)
|
||||||
82
pkg/postgresql/migrations.go
Normal file
82
pkg/postgresql/migrations.go
Normal 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
33
pkg/postgresql/pgx.go
Normal 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
|
||||||
|
}
|
||||||
26
pkg/postgresql/pgx_pool.go
Normal file
26
pkg/postgresql/pgx_pool.go
Normal 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
41
pkg/postgresql/pq.go
Normal 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
19
pkg/postgresql/utils.go
Normal 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
58
pkg/server/server.go
Normal 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
27
pkg/server/utils.go
Normal 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)
|
||||||
|
}
|
||||||
5
pkg/utils/httputils/httputils.go
Normal file
5
pkg/utils/httputils/httputils.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package httputils
|
||||||
|
|
||||||
|
type HTTPError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
19
pkg/utils/jsonutils/jsonutils.go
Normal file
19
pkg/utils/jsonutils/jsonutils.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user