diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cab2dfa --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Сервис лояльности gophermart +RUN_ADDRESS=:8080 +DATABASE_URI=postgres://gophermart:password@localhost:5432/gophermart?sslmode=disable +DEBUG=true +ENV=dev + +# Сервис начислений +ACCRUAL_PORT=8081 +ACCRUAL_SYSTEM_ADDRESS=http://localhost:8081 + +# JWT +JWT_SECRET=your-secret-key +JWT_EXPIRATION_PERIOD=24h + +# Настройки для развертывания сервиса локально в docker-compose +DB_DATABASE=gophermart +DB_USERNAME=gophermart +DB_PASSWORD=password +FORWARD_DB_PORT=5432 \ No newline at end of file diff --git a/.github/workflows/gophermart.yml b/.github/workflows/gophermart.yml index b396d24..e55b6cd 100644 --- a/.github/workflows/gophermart.yml +++ b/.github/workflows/gophermart.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-latest - container: golang:1.21 + container: golang:1.23 services: postgres: diff --git a/.github/workflows/statictest.yml b/.github/workflows/statictest.yml index 5cf90b3..d5df73a 100644 --- a/.github/workflows/statictest.yml +++ b/.github/workflows/statictest.yml @@ -10,7 +10,7 @@ on: jobs: statictest: runs-on: ubuntu-latest - container: golang:1.21 + container: golang:1.22 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 50d43ce..1176171 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ vendor/ # IDEs directories .idea .vscode + +# Logs +#*.log + +.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b266c0d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,338 @@ +# This code is licensed under the terms of the MIT license https://opensource.org/license/mit +# Copyright (c) 2021 Marat Reymers + +## Golden config for golangci-lint v1.57.2 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adapt and change it for your needs. + +run: + # Timeout for analysis, e.g. 30s, 5m. + # Default: 1m + timeout: 3m + + allow-parallel-runners: true + + +# This file contains only configs which differ from defaults. +# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml +linters-settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled + # Default: 0.0 + package-average: 10.0 + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + exhaustive: + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to exclude struct packages and their names from checks. + # Regular expressions must match complete canonical struct package/name/structname. + # Default: [] + exclude: + # std libs + - "^net/http.Client$" + - "^net/http.Cookie$" + - "^net/http.Request$" + - "^net/http.Response$" + - "^net/http.Server$" + - "^net/http.Transport$" + - "^net/url.URL$" + - "^os/exec.Cmd$" + - "^reflect.StructField$" + # public libs + - "^github.com/Shopify/sarama.Config$" + - "^github.com/Shopify/sarama.ProducerMessage$" + - "^github.com/mitchellh/mapstructure.DecoderConfig$" + - "^github.com/prometheus/client_golang/.+Opts$" + - "^github.com/spf13/cobra.Command$" + - "^github.com/spf13/cobra.CompletionOptions$" + - "^github.com/stretchr/testify/mock.Mock$" + - "^github.com/testcontainers/testcontainers-go.+Request$" + - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" + - "^golang.org/x/tools/go/analysis.Analyzer$" + - "^google.golang.org/protobuf/.+Options$" + - "^gopkg.in/yaml.v3.Node$" + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + # Ignore comments when counting lines. + # Default false + ignore-comments: true + + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be find in https://go-critic.github.io/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + gomnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + gomodguard: + blocked: + # List of blocked modules. + # Default: [] + modules: + - github.com/golang/protobuf: + recommendations: + - google.golang.org/protobuf + reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" + - github.com/satori/go.uuid: + recommendations: + - github.com/google/uuid + reason: "satori's package is not maintained" + - github.com/gofrs/uuid: + recommendations: + - github.com/gofrs/uuid/v5 + reason: "gofrs' package was not go module before v5" + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `go tool vet help` to see all analyzers. + # Default: [] + disable: + - fieldalignment # too strict + # Settings per analyzer. + settings: + shadow: + # Whether to be strict about shadowing; can be noisy. + # Default: false + strict: true + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit, lll ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + rowserrcheck: + # database/sql is always checked + # Default: [] + packages: + - github.com/jmoiron/sqlx + + tenv: + # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. + # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. + # Default: false + all: true + + +linters: + disable-all: true + enable: + ## enabled by default + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - gosimple # specializes in simplifying a code + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - typecheck # like the front-end of a Go compiler, parses and type-checks Go code + - unused # checks for unused constants, variables, functions and types + ## disabled by default + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - copyloopvar # detects places where loop variables are copied + - cyclop # checks function and package cyclomatic complexity + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + # - execinquery # checks query string in Query function which reads your Go src files and warning it finds + - exhaustive # checks exhaustiveness of enum switch statements + # - exportloopref # checks for pointers to enclosing loop variables + - forbidigo # forbids identifiers + - funlen # tool for detection of long functions + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecknoglobals # checks that no global variables exist + - gochecknoinits # checks that no init functions are present in Go code + - gochecksumtype # checks exhaustiveness on Go "sum types" + - gocognit # computes and checks the cognitive complexity of functions + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godot # checks if comments end in a period + - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt +# - gomnd # detects magic numbers + - mnd # detects magic numbers + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - intrange # finds places where for loops could make use of an integer range + - lll # reports long lines + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - stylecheck # is a replacement for golint + - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + - testpackage # makes you use a separate _test package + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- gci # controls golang package import order and makes it always deterministic + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects FIXME, TODO and other comment keywords + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- goerr113 # [too strict] checks the errors handling expressions + #- gofmt # [replaced by goimports] checks whether code was gofmt-ed + #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + + exclude-rules: + - source: "(noinspection|TODO)" + linters: [ godot ] + - source: "//noinspection" + linters: [ gocritic ] + - path: "_test\\.go" + linters: + - bodyclose + - dupl + - funlen + - goconst + - gosec + - noctx + - wrapcheck + + + +output: + show-stats: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..366fae4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: local + hooks: + - id: go-fmt + name: go-fmt + entry: sh -c 'go fmt ./...' + language: system + types: [go] + + - id: goimports + name: goimports + entry: sh -c 'find . -name "*.go" -exec goimports -w {} +' + language: system + types: [go] + + - id: golines + name: golines + entry: sh -c 'find . -name "*.go" -exec golines -w -m 120 --shorten-comments {} \;' + language: system + types: [go] + + - id: golangci-lint + name: golangci-lint + entry: sh -c 'golangci-lint run ./...' + language: system + types: [go] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..693540b --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +SHELL=/bin/bash +PROJECT=gophermart +PORT=8082 +DB_HOST=localhost +DB_PORT=5432 +DB_URI=postgres://$(PROJECT):password@$(DB_HOST):$(DB_PORT)/$(PROJECT)?sslmode=disable + +build: + GOOS=darwin GOARCH=amd64 go build -o bin/gophermart-darwin-amd64 cmd/gophermart/*.go + GOOS=darwin GOARCH=amd64 go build -o bin/randomport-darwin-amd64 cmd/randomport/main.go + +# Локальное тестирование MacOS (Intel) +test-macos: build + ./cmd/gophermarttest/gophermarttest-darwin-amd64 \ + -test.v -test.run=^TestGophermart$$ \ + -gophermart-binary-path=bin/gophermart-darwin-amd64 \ + -gophermart-host=localhost \ + -gophermart-port=$(PORT) \ + -gophermart-database-uri="$(DB_URI)" \ + -accrual-binary-path=cmd/accrual/accrual_darwin_amd64 \ + -accrual-host=localhost \ + -accrual-port=$(shell ./bin/randomport-darwin-amd64) \ + -accrual-database-uri="$(DB_URI)" | tee gophermarttest.log + +# Локальное тестирование Linux +build-linux: + GOOS=linux GOARCH=amd64 go build -o bin/gophermart-linux-amd64 cmd/gophermart/*.go + GOOS=linux GOARCH=amd64 go build -o bin/randomport-linux-amd64 cmd/randomport/main.go + +test: build-linux + ./cmd/gophermarttest/gophermarttest-linux-amd64 \ + -test.v -test.run=^TestGophermart$$ \ + -gophermart-binary-path=bin/gophermart-linux-amd64 \ + -gophermart-host=localhost \ + -gophermart-port=$(PORT) \ + -gophermart-database-uri="$(DB_URI)" \ + -accrual-binary-path=cmd/accrual/accrual_linux_amd64 \ + -accrual-host=localhost \ + -accrual-port=$(shell ./bin/randomport-linux-amd64) \ + -accrual-database-uri="$(DB_URI)" | tee gophermarttest-linux.log + +perm: + chmod -R +x bin + +# Запуск сервиса +run: + go run cmd/gophermart/main.go cmd/gophermart/flags.go cmd/gophermart/logging.go + +# Запуск сервиса с локальными переменными окружения +run-env: + RUN_ADDRESS=":$(PORT)" \ + DATABASE_URI="$(DB_URI)" \ + ACCRUAL_SYSTEM_ADDRESS="http://localhost:8081" \ + JWT_SECRET="your-256-bit-secret" \ + JWT_EXPIRATION_PERIOD="24h" \ + DEBUG=true \ + exec go run cmd/gophermart/*.go || true + +# Запуск accrual сервера (blackbox) +run-accrual: + RUN_ADDRESS=":8081" \ + DATABASE_URI="$(DB_URI)" \ + ./cmd/accrual/accrual_linux_amd64 + +lint : + @echo "Running linter..." + golangci-lint run | tee lint.log \ No newline at end of file diff --git a/README.md b/README.md index 0471a34..4f0756e 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,132 @@ # gophermart -Индивидуальный дипломный проект курса «Go-разработчик» +Накопительная система лояльности «Гофермарт». Индивидуальный дипломный проект курса «Go-разработчик» -# Обновление шаблона +## Быстрый старт -Чтобы иметь возможность получать обновления автотестов и других частей шаблона, выполните команду: +### 1. Подготовка окружения + +```bash +# Клонирование репозитория +git clone https://github.com/your-username/gophermart.git +cd gophermart + +# Создание .env файла из примера +cp .env.example .env +``` + +### 2. Запуск базы данных + +```bash +# Запуск PostgreSQL через Docker compose +docker compose up -d +``` + +### 3. Запуск сервиса и утилит + +```bash +# Сборка и запуск сервиса gophermart +make run + +# Запуск accrual +make run-accrual + +# Запуск линтеров +make lint + +# Запуск тестов +make test +``` + +## Разработка + +### Установка утилит + +В Linux однократно: + +```bash +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest +go install golang.org/x/tools/cmd/goimports@latest +go install github.com/segmentio/golines@latest +# Установка pre-commit +sudo apt update +sudo apt install pipx +pipx install pre-commit +``` + +### Запуск линтеров и форматтеров + +```bash +# Исправление импортов +goimports -w . + +# Исправление форматирования +gofmt -w . + +# Исправление длины строк +golines -w -m 120 --shorten-comments . +# Запуск линтера +golangci-lint run ./... ``` + +## План реализации + +### 1. Структура проекта + +- [x] настройка автотестов +- [x] docker compose для локальной разработки +- [x] выбор фреймворков и библиотек (echo, sqlx) + - [x] выбор логгера (slog) +- [x] линтеры и форматтеры: `golangci-lint`, `goimports`, `gofmt`, `golines` + +### 2. Основные модели данных и миграции + +- [x] `users` – пользователи +- [x] `orders` – заказы (номера) +- [ ] `transactions` – транзакции (пополнения и списания) + +### 3. Регистрация, аутентификация и авторизация пользователей + +- [x] `POST /api/user/register` — регистрация пользователя +- [x] `POST /api/user/login` — аутентификация пользователя +- Настройка приватного ключа +- Middleware для авторизации запросов + +### 4. Работа с заказами + +- [x] `POST /api/user/orders` — загрузка пользователем номера заказа для расчёта, регистрация заказа и привязка к пользователю +- [x] `GET /api/user/orders` — получение списка загруженных пользователем номеров заказов, статусов их обработки и информации о начислениях + +### 5. Взаимодействие с системой расчета баллов лояльности + +- [x] Проверка заказа в системе accrual и начисление баллов (поллинг, воркер пул) + +### 6. Баланс + +- [x] `GET /api/user/balance` — получение текущего баланса счёта баллов лояльности пользователя + +### 7. Начисление и списание баллов, получение истории списаний + +- [x] `POST /api/user/balance/withdraw` — запрос на списание баллов с накопительного счёта в счёт оплаты нового заказа +- [x] `GET /api/user/withdrawals` — получение информации о выводе средств с накопительного счёта пользователем + +### 9. Документация + +- [x] `README.md` с описанием проекта и планом реализации +- [ ] API документация (swagger) + +## Обновление шаблона + +Чтобы иметь возможность получать обновления автотестов и других частей шаблона, выполните команду: + +```bash git remote add -m master template https://github.com/yandex-praktikum/go-musthave-diploma-tpl.git ``` Для обновления кода автотестов выполните команду: -``` +```bash git fetch template && git checkout template/master .github ``` diff --git a/SPECIFICATION.md b/SPECIFICATION.md index e8e5380..9386f02 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -46,13 +46,13 @@ Накопительная система лояльности «Гофермарт» должна предоставлять следующие HTTP-хендлеры: -* `POST /api/user/register` — регистрация пользователя; -* `POST /api/user/login` — аутентификация пользователя; -* `POST /api/user/orders` — загрузка пользователем номера заказа для расчёта; -* `GET /api/user/orders` — получение списка загруженных пользователем номеров заказов, статусов их обработки и информации о начислениях; -* `GET /api/user/balance` — получение текущего баланса счёта баллов лояльности пользователя; -* `POST /api/user/balance/withdraw` — запрос на списание баллов с накопительного счёта в счёт оплаты нового заказа; -* `GET /api/user/withdrawals` — получение информации о выводе средств с накопительного счёта пользователем. +1. `POST /api/user/register` — регистрация пользователя; +2. `POST /api/user/login` — аутентификация пользователя; +3. `POST /api/user/orders` — загрузка пользователем номера заказа для расчёта; +4. `GET /api/user/orders` — получение списка загруженных пользователем номеров заказов, статусов их обработки и информации о начислениях; +5. `GET /api/user/balance` — получение текущего баланса счёта баллов лояльности пользователя; +6. `POST /api/user/balance/withdraw` — запрос на списание баллов с накопительного счёта в счёт оплаты нового заказа; +7. `GET /api/user/withdrawals` — получение информации о выводе средств с накопительного счёта пользователем. ### Общие ограничения и требования diff --git a/bin/gophermart-darwin-amd64 b/bin/gophermart-darwin-amd64 new file mode 100755 index 0000000..235e1e2 Binary files /dev/null and b/bin/gophermart-darwin-amd64 differ diff --git a/bin/gophermart-linux-amd64 b/bin/gophermart-linux-amd64 new file mode 100755 index 0000000..27ca9c6 Binary files /dev/null and b/bin/gophermart-linux-amd64 differ diff --git a/bin/randomport-darwin-amd64 b/bin/randomport-darwin-amd64 new file mode 100755 index 0000000..edd52e4 Binary files /dev/null and b/bin/randomport-darwin-amd64 differ diff --git a/bin/randomport-linux-amd64 b/bin/randomport-linux-amd64 new file mode 100755 index 0000000..c982d86 Binary files /dev/null and b/bin/randomport-linux-amd64 differ diff --git a/bin/statictest b/bin/statictest new file mode 100755 index 0000000..6cbdd9b Binary files /dev/null and b/bin/statictest differ diff --git a/cmd/gophermart/flags.go b/cmd/gophermart/flags.go new file mode 100644 index 0000000..df58002 --- /dev/null +++ b/cmd/gophermart/flags.go @@ -0,0 +1,69 @@ +package main + +import ( + "flag" + "os" + "time" +) + +const ( + defaultJWTExpirationHours = 24 +) + +// Config содержит конфигурацию приложения. +type Config struct { + RunAddress string // Адрес и порт для запуска сервера + DatabaseURI string // URI базы данных + AccrualSystemAddress string // Адрес системы расчета начислений + MigrationsDirectory string // Директория с миграциями + JWTSecret string // Секретный ключ для подписи JWT токенов + JWTExpirationPeriod time.Duration // Период действия JWT токена +} + +// parseFlags парсит флаги командной строки и переменные окружения. +func parseFlags() Config { + var cfg Config + + // Приоритет: 1. Флаги командной строки 2. Системные переменные окружения 3. Переменные из .env + flag.StringVar(&cfg.RunAddress, "a", getEnvOrDefault("RUN_ADDRESS"), "Адрес и порт для запуска сервера") + flag.StringVar(&cfg.DatabaseURI, "d", getEnvOrDefault("DATABASE_URI"), "URI базы данных") + flag.StringVar( + &cfg.AccrualSystemAddress, + "r", + getEnvOrDefault("ACCRUAL_SYSTEM_ADDRESS"), + "Адрес системы расчета начислений", + ) + flag.StringVar(&cfg.MigrationsDirectory, "m", "migrations", "Директория с миграциями") + flag.StringVar( + &cfg.JWTSecret, + "jwt-secret", + getEnvOrDefault("JWT_SECRET"), + "Секретный ключ для подписи JWT токенов", + ) + flag.DurationVar( + &cfg.JWTExpirationPeriod, + "jwt-exp", + getDurationEnv("JWT_EXPIRATION_PERIOD", defaultJWTExpirationHours*time.Hour), + "Период действия JWT токена", + ) + + return cfg +} + +// getEnvOrDefault получает значение из переменной окружения. +func getEnvOrDefault(key string) string { + if value := os.Getenv(key); value != "" { + return value + } + return "" +} + +// getDurationEnv получает значение длительности из переменной окружения. +func getDurationEnv(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + return defaultValue +} diff --git a/cmd/gophermart/logging.go b/cmd/gophermart/logging.go new file mode 100644 index 0000000..9127984 --- /dev/null +++ b/cmd/gophermart/logging.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "log/slog" + "os" +) + +const ( + minSecretLength = 4 // Минимальная длина секрета для маскирования +) + +// initLogger инициализирует логгер. +func initLogger() { + // Настраиваем уровень логирования + var programLevel = new(slog.LevelVar) + if os.Getenv("DEBUG") != "" { + programLevel.Set(slog.LevelDebug) + } + + h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: programLevel}) + logger := slog.New(h).With( + "environment", os.Getenv("ENV"), + ) + slog.SetDefault(logger) +} + +// getVarSource возвращает значение переменной и её источник. +func getVarSource(name string, value string, envFileLoaded bool) string { + // Проверяем наличие переменной в окружении + if envValue := os.Getenv(name); envValue != "" { + if envFileLoaded { + return fmt.Sprintf("%s (from .env)", value) + } + return fmt.Sprintf("%s (from environment)", value) + } + // Если значение есть, но его нет в окружении, значит оно из флага + if value != "" { + return fmt.Sprintf("%s (from flag)", value) + } + return "not set" +} + +// maskSecret маскирует секретные значения для логов. +func maskSecret(s string) string { + if len(s) <= minSecretLength { + return "***" + } + return s[:2] + "***" + s[len(s)-2:] +} diff --git a/cmd/gophermart/main.go b/cmd/gophermart/main.go index 38dd16d..cb99d7f 100644 --- a/cmd/gophermart/main.go +++ b/cmd/gophermart/main.go @@ -1,3 +1,87 @@ package main -func main() {} +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/joho/godotenv" + + "gophermart/internal/app" +) + +func main() { + // Код выхода по умолчанию + exitCode := 0 + defer func() { + if exitCode != 0 { + os.Exit(exitCode) + } + }() + + // Загрузка .env файла, если он существует (имеет низший приоритет) + envFileLoaded := false + if err := godotenv.Load(); err == nil { + envFileLoaded = true + slog.Debug("loaded .env file") + } + + // Парсим флаги (имеют высший приоритет) + cfg := parseFlags() + flag.Parse() + + // Инициализация логгера + initLogger() + + // Логируем все переменные окружения и их источники + slog.Debug("configuration sources", + "env_file_loaded", envFileLoaded, + "RUN_ADDRESS", getVarSource("RUN_ADDRESS", cfg.RunAddress, envFileLoaded), + "DATABASE_URI", getVarSource("DATABASE_URI", cfg.DatabaseURI, envFileLoaded), + "ACCRUAL_SYSTEM_ADDRESS", getVarSource("ACCRUAL_SYSTEM_ADDRESS", cfg.AccrualSystemAddress, envFileLoaded), + "JWT_SECRET", maskSecret(getVarSource("JWT_SECRET", cfg.JWTSecret, envFileLoaded)), + "JWT_EXPIRATION_PERIOD", getVarSource("JWT_EXPIRATION_PERIOD", cfg.JWTExpirationPeriod.String(), envFileLoaded), + ) + + // Создаем контекст с отменой + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем канал для получения сигналов операционной системы + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT) + + // Запускаем горутину для обработки сигналов + go func() { + sig := <-sigChan + slog.Info("received signal", "signal", sig) + cancel() // Отменяем контекст при получении сигнала + }() + + // Запускаем приложение + application, err := app.New(ctx, app.Config{ + DatabaseURI: cfg.DatabaseURI, + MigrationsDir: cfg.MigrationsDirectory, + RunAddress: cfg.RunAddress, + AccrualSystemAddress: cfg.AccrualSystemAddress, + JWTSecret: cfg.JWTSecret, + JWTExpirationPeriod: cfg.JWTExpirationPeriod, + }) + if err != nil { + slog.Error("failed to initialize application", "error", err) + exitCode = 1 + return + } + + // Запускаем приложение и ждем его завершения + if startErr := application.Start(ctx, cfg.RunAddress); startErr != nil { + slog.Error("application error", "error", startErr) + exitCode = 1 + return + } + + slog.Info("application stopped") +} diff --git a/cmd/gophermarttest/gophermarttest-darwin-amd64 b/cmd/gophermarttest/gophermarttest-darwin-amd64 new file mode 100755 index 0000000..b3d0567 Binary files /dev/null and b/cmd/gophermarttest/gophermarttest-darwin-amd64 differ diff --git a/cmd/gophermarttest/gophermarttest-darwin-arm64 b/cmd/gophermarttest/gophermarttest-darwin-arm64 new file mode 100755 index 0000000..7b1a12d Binary files /dev/null and b/cmd/gophermarttest/gophermarttest-darwin-arm64 differ diff --git a/cmd/gophermarttest/gophermarttest-linux-amd64 b/cmd/gophermarttest/gophermarttest-linux-amd64 new file mode 100755 index 0000000..9702eb8 Binary files /dev/null and b/cmd/gophermarttest/gophermarttest-linux-amd64 differ diff --git a/cmd/gophermarttest/gophermarttest-windows-amd64 b/cmd/gophermarttest/gophermarttest-windows-amd64 new file mode 100755 index 0000000..8891218 Binary files /dev/null and b/cmd/gophermarttest/gophermarttest-windows-amd64 differ diff --git a/cmd/randomport/main.go b/cmd/randomport/main.go new file mode 100644 index 0000000..efcf808 --- /dev/null +++ b/cmd/randomport/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "log" + "net" +) + +func main() { + //nolint:gosec // G102: Намеренно слушаем на всех интерфейсах для получения случайного порта + listener, err := net.Listen("tcp", ":0") + if err != nil { + log.Fatalf("Error: %v", err) + } + defer listener.Close() + + // Получаем адрес сокета и извлекаем номер порта + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + // Не используем log.Fatal, чтобы defer выполнился + log.Printf("Failed to get TCP address") + return + } + //nolint:forbidigo // Это утилита командной строки, использование fmt.Println допустимо + fmt.Println(addr.Port) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b65c186 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +name: gophermart +services: + + pgsql: + env_file: + - .env + image: 'postgres:14' + container_name: gophermart_pgsql + ports: + - '${FORWARD_DB_PORT:-5432}:5432' + environment: + POSTGRES_DB: '${DB_DATABASE:-gophermart}' + POSTGRES_USER: '${DB_USERNAME:-gophermart}' + POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + volumes: + - 'pgsql_data:/var/lib/postgresql/data' + networks: + - gophermart_network + healthcheck: + test: ["CMD", "pg_isready", "-q", "-d", "${DB_DATABASE}", "-U", "${DB_USERNAME}"] + retries: 3 + timeout: 5s + +volumes: + pgsql_data: + name: gophermart_pgsql_data + driver: local + +networks: + gophermart_network: + name: gophermart_network + driver: bridge + external: false diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..59f74e0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +# Документация проекта Gophermart + +## Содержание + +- [Технологический стек](stack.md) +- [Архитектура](architecture.md) +- [API](api.md) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f7b6ea2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,92 @@ +# Архитектура приложения + +## Общее описание + +Gophermart - это система лояльности, построенная на принципах чистой архитектуры с разделением на слои. Приложение обеспечивает регистрацию пользователей, загрузку номеров заказов, учет баллов лояльности и вывод средств. + +## Структура проекта + +```bash +gophermart/ +├── cmd/ # Точки входа приложения +│ └── gophermart/ # Основной сервис +├── internal/ # Внутренний код приложения +│ ├── app/ # Инициализация и конфигурация приложения +│ ├── domain/ # Модели и интерфейсы +│ ├── handlers/ # HTTP обработчики +│ ├── repository/ # Слой работы с БД +│ ├── service/ # Сервисный слой +│ ├── utils/ # Вспомогательные утилиты +│ └── worker/ # Фоновые задачи и воркеры +├── migrations/ # Миграции БД +└── docs/ # Документация +``` + +## Слои приложения + +### 1. Presentation Layer (handlers) + +- HTTP обработчики для обработки входящих запросов +- Валидация входных данных с помощью validator +- Сериализация/десериализация JSON +- Обработка ошибок и формирование HTTP ответов +- Middleware компоненты (аутентификация, логирование) + +### 2. Business Layer (service) + +- Реализация бизнес-логики +- Координация работы репозиториев +- Управление транзакциями +- Взаимодействие с внешней системой начисления баллов +- Обработка бизнес-правил и валидаций + +### 3. Data Layer (repository) + +- Работа с PostgreSQL через pgx и sqlx +- Реализация CRUD операций +- Управление транзакциями на уровне БД +- Маппинг данных между БД и domain моделями + +### 4. Domain Layer + +- Определение основных сущностей (User, Order, Balance) +- Интерфейсы для репозиториев и сервисов +- Бизнес-правила и константы +- Типы ошибок предметной области + +### 5. Background Workers + +- Обработка заказов в фоновом режиме +- Периодическая синхронизация с системой начисления баллов +- Управление очередями и отложенными задачами + +## Основные компоненты + +### Аутентификация + +- JWT токены для авторизации пользователей +- Middleware авторизации для защиты эндпоинтов +- Хеширование паролей с помощью bcrypt + +### База данных + +- PostgreSQL для хранения данных +- Миграции с помощью goose +- Транзакционная целостность + +### Внешние интеграции + +- Система начисления баллов (Accrual System) +- Асинхронное обновление статусов заказов +- Retry механизмы при сбоях + +### Логирование + +- Структурированное логирование с помощью slog + - Уровни логирования (debug, info, warn, error) + - Контекстная информация в логах + - Логирование HTTP запросов и ответов +- Отслеживание ошибок и исключительных ситуаций + - Ошибки взаимодействия с БД + - Ошибки внешних интеграций + - Ошибки бизнес-логики. diff --git a/docs/stack.md b/docs/stack.md new file mode 100644 index 0000000..46507ef --- /dev/null +++ b/docs/stack.md @@ -0,0 +1,64 @@ +# Технологический стек + +## Основные компоненты + +### Web Framework + +- **[Echo](https://github.com/labstack/echo/v4)** - быстрый и минималистичный веб-фреймворк + - Высокая производительность + - Простой и понятный API + - Встроенная поддержка middleware + - Хорошая документация + +### База данных и драйверы + +- **[PostgreSQL](https://www.postgresql.org/)** - основная база данных +- **[pgx](https://github.com/jackc/pgx)** - нативный PostgreSQL драйвер + - Высокая производительность + - Поддержка расширенных возможностей PostgreSQL + - Встроенный пул соединений +- **[sqlx](https://github.com/jmoiron/sqlx)** - расширение стандартного database/sql + - Поддержка сканирования в структуры + - Типобезопасные запросы + - Простота использования + +### Конфигурация + +- **[godotenv](https://github.com/joho/godotenv)** - работа с .env файлами + +### Аутентификация и безопасность + +- **[jwt-go v5](https://github.com/golang-jwt/jwt/v5)** - работа с JWT токенами +- **[bcrypt](https://pkg.go.dev/golang.org/x/crypto/bcrypt)** - хеширование паролей + +### Валидация + +- **[validator v10](https://github.com/go-playground/validator)** - валидация входящих данных + - Встроенная поддержка в Echo + - Расширяемые правила валидации + +### Логирование + +- **[slog](https://pkg.go.dev/log/slog)** - структурированный логгер из стандартной библиотеки Go + - Структурированное логирование в JSON формате + - Поддержка уровней логирования + - Встроенная поддержка атрибутов и контекста + - Часть стандартной библиотеки Go 1.21+ + - Возможность кастомизации обработчиков логов + +### Миграции БД + +- **[goose v3](https://github.com/pressly/goose)** - управление миграциями + - Поддержка PostgreSQL + - Версионирование миграций + - CLI инструмент + +### Тестирование + +- **[httptest](https://pkg.go.dev/net/http/httptest)** - тестирование HTTP handlers + +### Линтеры и форматтеры + +- **[golangci-lint](https://github.com/golangci/golangci-lint)** - набор линтеров +- **[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports)** - форматирование импортов +- **[gofmt](https://pkg.go.dev/cmd/gofmt)** - стандартный форматтер Go diff --git a/go.mod b/go.mod index 4ccb64d..411fe56 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,39 @@ -module github.com/maynagashev/gophermart +module gophermart -go 1.22.1 +go 1.22 + +require ( + github.com/go-playground/validator/v10 v10.17.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/jackc/pgx/v5 v5.5.2 + github.com/jmoiron/sqlx v1.3.5 + github.com/joho/godotenv v1.5.1 + github.com/labstack/echo/v4 v4.11.4 + github.com/pressly/goose/v3 v3.18.0 + golang.org/x/crypto v0.17.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.2.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c00c5cf --- /dev/null +++ b/go.sum @@ -0,0 +1,232 @@ +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/ClickHouse/ch-go v0.58.2 h1:jSm2szHbT9MCAB1rJ3WuCJqmGLi5UTjlNu+f530UTS0= +github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1 h1:ZCmAYWpu75IyEi7+Yrs/uaAjiCGY5wfW5kXo64exkX4= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 h1:goHVqTbFX3AIo0tzGr14pgfAW2ZfPChKO21Z9MGf/gk= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= +github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg= +github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= +github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4= +github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= +github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= +github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= +github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA= +github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= +github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475 h1:6PfEMwfInASh9hkN83aR0j4W/eKaAZt/AURtXAXlas0= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475/go.mod h1:20nXSmcf0nAscrzqsXeC2/tA3KkV2eCiJqYuyAgl+ss= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/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-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= +github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/runc v1.1.10 h1:EaL5WeO9lv9wmS6SASjszOeQdSctvpbu0DdBQBizE40= +github.com/opencontainers/runc v1.1.10/go.mod h1:+/R6+KmDlh+hOO8NkjmgkG9Qzvypzk0yXxAPYYR65+M= +github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= +github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= +github.com/paulmach/orb v0.10.0 h1:guVYVqzxHE/CQ1KpfGO077TR0ATHSNjp4s6XGLn3W9s= +github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.18.0 h1:CUQKjZ0li91GLrMekHPR0yz4UyjT21AqyhSm/ERcPTo= +github.com/pressly/goose/v3 v3.18.0/go.mod h1:NTDry9taDJXEV6IqkABnZqm1MRGOSrCWrNEz1x6f4wI= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= +github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tursodatabase/libsql-client-go v0.0.0-20231216154754-8383a53d618f h1:teZ0Pj1Wp3Wk0JObKBiKZqgxhYwLeJhVAyj6DRgmQtY= +github.com/tursodatabase/libsql-client-go v0.0.0-20231216154754-8383a53d618f/go.mod h1:UMde0InJz9I0Le/1YIR4xsB0E2vb01MrDY6k/eNdfkg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20240126124512-dbb0e1720dbf h1:ckwNHVo4bv2tqNkgx3W3HANh3ta1j6TR5qw08J1A7Tw= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20240126124512-dbb0e1720dbf/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.55.1 h1:Ebo6J5AMXgJ3A438ECYotA0aK7ETqjQx9WoZvVxzKBE= +github.com/ydb-platform/ydb-go-sdk/v3 v3.55.1/go.mod h1:udNPW8eupyH/EZocecFmaSNJacKKYjzQa7cVgX5U2nc= +go.opentelemetry.io/otel v1.20.0 h1:vsb/ggIY+hUjD/zCAQHpzTmndPqv/ml2ArbsbfBYTAc= +go.opentelemetry.io/otel v1.20.0/go.mod h1:oUIGj3D77RwJdM6PPZImDpSZGDvkD9fhesHny69JFrs= +go.opentelemetry.io/otel/trace v1.20.0 h1:+yxVAPZPbQhbC3OfAkeIVTky6iTFpcr4SiY9om7mXSQ= +go.opentelemetry.io/otel/trace v1.20.0/go.mod h1:HJSK7F/hA5RlzpZ0zKDCHCDHm556LCDtKaAo6JmBFUU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= +modernc.org/libc v1.32.0 h1:yXatHTrACp3WaKNRCoZwUK7qj5V8ep1XyY0ka4oYcNc= +modernc.org/libc v1.32.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/gophermarttest-linux.log b/gophermarttest-linux.log new file mode 100644 index 0000000..f5f41ec --- /dev/null +++ b/gophermarttest-linux.log @@ -0,0 +1,161 @@ +=== RUN TestGophermart +=== RUN TestGophermart/TestEndToEnd +=== RUN TestGophermart/TestEndToEnd/register_accrual_mechanic +=== RUN TestGophermart/TestEndToEnd/register_order_for_accrual +=== RUN TestGophermart/TestEndToEnd/register_user +=== RUN TestGophermart/TestEndToEnd/order_upload +=== RUN TestGophermart/TestEndToEnd/await_order_processed +=== RUN TestGophermart/TestEndToEnd/check_balance +=== RUN TestGophermart/TestEndToEnd/withdraw_balance +=== RUN TestGophermart/TestEndToEnd/recheck_balance +=== RUN TestGophermart/TestEndToEnd/check_withdrawals +=== RUN TestGophermart/TestUserAuth +=== RUN TestGophermart/TestUserAuth/register_user +=== RUN TestGophermart/TestUserAuth/login_user +=== RUN TestGophermart/TestUserOrders +=== RUN TestGophermart/TestUserOrders/unauthorized_order_upload +=== RUN TestGophermart/TestUserOrders/unauthorized_orders_list +=== RUN TestGophermart/TestUserOrders/register_user +=== RUN TestGophermart/TestUserOrders/bad_order_upload +=== RUN TestGophermart/TestUserOrders/order_upload +=== RUN TestGophermart/TestUserOrders/duplicate_order_upload_same_user +=== RUN TestGophermart/TestUserOrders/orders_list +=== RUN TestGophermart/TestUserOrders/duplicate_order_upload_other_user +=== NAME TestGophermart + gophermart_suite_test.go:105: останавливаем процесс gophermart + gophermart_suite_test.go:140: Получен STDOUT лог процесса: + + time=2025-02-12T10:56:37.112+07:00 level=DEBUG msg="configuration sources" environment=local env_file_loaded=true RUN_ADDRESS="localhost:8082 (from .env)" DATABASE_URI="postgres://gophermart:password@localhost:5432/gophermart?sslmode=disable (from .env)" ACCRUAL_SYSTEM_ADDRESS="http://localhost:43197 (from .env)" JWT_SECRET=yo***v) JWT_EXPIRATION_PERIOD="24h0m0s (from .env)" + time=2025-02-12T10:56:37.133+07:00 level=INFO msg="goose: no migrations to run. current version: 3" environment=local + + ____ __ + / __/___/ / ___ + / _// __/ _ \/ _ \ + /___/\__/_//_/\___/ v4.11.4 + High performance, minimalist Go web framework + https://echo.labstack.com + ____________________________________O/_______ + O\ + time=2025-02-12T10:56:37.134+07:00 level=INFO msg="воркер начал работу" environment=local package=worker component=AccrualWorker worker_id=1 + time=2025-02-12T10:56:37.134+07:00 level=INFO msg="воркер начал работу" environment=local package=worker component=AccrualWorker worker_id=0 + ⇨ http server started on 127.0.0.1:8082 + {"time":"2025-02-12T10:56:37.392224882+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/register","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":185075171,"latency_human":"185.075171ms","bytes_in":54,"bytes_out":165} + time=2025-02-12T10:56:37.397+07:00 level=INFO msg="created new order" environment=local id=49 uploaded_at=2025-02-12T10:56:37.395+07:00 user_id=125 order_number=374614184540041 status=NEW + {"time":"2025-02-12T10:56:37.397857068+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":202,"error":"","latency":4652856,"latency_human":"4.652856ms","bytes_in":15,"bytes_out":0} + {"time":"2025-02-12T10:56:38.402643341+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":3529764,"latency_human":"3.529764ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:39.402894627+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":2858278,"latency_human":"2.858278ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:40.40360849+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":3270614,"latency_human":"3.270614ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:41.400740733+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1608706,"latency_human":"1.608706ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:42.400119521+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1140674,"latency_human":"1.140674ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:43.40082836+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1754993,"latency_human":"1.754993ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:44.400175748+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1136622,"latency_human":"1.136622ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:45.402587959+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":3006424,"latency_human":"3.006424ms","bytes_in":0,"bytes_out":95} + {"time":"2025-02-12T10:56:46.400696657+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1510263,"latency_human":"1.510263ms","bytes_in":0,"bytes_out":95} + time=2025-02-12T10:56:47.134+07:00 level=DEBUG msg="начало обработки заказов" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders + time=2025-02-12T10:56:47.134+07:00 level=DEBUG msg="начало обработки заказов" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders + time=2025-02-12T10:56:47.134+07:00 level=DEBUG msg="запрос заказов" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders статусы="[NEW PROCESSING]" + time=2025-02-12T10:56:47.134+07:00 level=DEBUG msg="запрос заказов" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders статусы="[NEW PROCESSING]" + time=2025-02-12T10:56:47.137+07:00 level=DEBUG msg="поиск заказов по статусам" environment=local package=repository component=OrderRepo method=FindByStatus статусы="[NEW PROCESSING]" количество=12 + time=2025-02-12T10:56:47.137+07:00 level=DEBUG msg="кол-во заказов для обработки воркером" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders количество=12 + time=2025-02-12T10:56:47.137+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=28 "номер заказа"=12345678903 "текущий статус"=NEW + time=2025-02-12T10:56:47.138+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=12345678903 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.138+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=30 "номер заказа"=555326 "текущий статус"=NEW + time=2025-02-12T10:56:47.138+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=555326 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.138+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=32 "номер заказа"=617655814315855 "текущий статус"=NEW + time=2025-02-12T10:56:47.139+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=617655814315855 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.139+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=34 "номер заказа"=8111783 "текущий статус"=NEW + time=2025-02-12T10:56:47.139+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=8111783 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.139+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=36 "номер заказа"=607184 "текущий статус"=NEW + time=2025-02-12T10:56:47.140+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=607184 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.140+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=38 "номер заказа"=552011058 "текущий статус"=NEW + time=2025-02-12T10:56:47.140+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=552011058 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.140+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=40 "номер заказа"=703273423029 "текущий статус"=NEW + time=2025-02-12T10:56:47.141+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=703273423029 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.141+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=42 "номер заказа"=86527678 "текущий статус"=NEW + time=2025-02-12T10:56:47.141+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=86527678 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.141+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=44 "номер заказа"=773463831331 "текущий статус"=NEW + time=2025-02-12T10:56:47.141+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=773463831331 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.141+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=46 "номер заказа"=2166643 "текущий статус"=NEW + time=2025-02-12T10:56:47.142+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=2166643 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.142+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=48 "номер заказа"=1423724010 "текущий статус"=NEW + time=2025-02-12T10:56:47.142+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=1423724010 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.142+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "id заказа"=49 "номер заказа"=374614184540041 "текущий статус"=NEW + time=2025-02-12T10:56:47.143+07:00 level=DEBUG msg="получена информация о начислении" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "номер заказа"=374614184540041 статус=PROCESSED начисление=0xc000015a90 + time=2025-02-12T10:56:47.146+07:00 level=DEBUG msg="обновление суммы начисления" environment=local package=worker component=AccrualWorker worker_id=1 method=processOrders "номер заказа"=374614184540041 "начисление (руб)"=729.98 "начисление (коп)"=72998 + time=2025-02-12T10:56:47.146+07:00 level=INFO msg="обновление статуса на PROCESSED" environment=local package=repository component=OrderRepo method=UpdateAccrual "id заказа"=49 "начисление (коп)"=72998 + time=2025-02-12T10:56:47.147+07:00 level=DEBUG msg="поиск заказов по статусам" environment=local package=repository component=OrderRepo method=FindByStatus статусы="[NEW PROCESSING]" количество=11 + time=2025-02-12T10:56:47.147+07:00 level=DEBUG msg="кол-во заказов для обработки воркером" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders количество=11 + time=2025-02-12T10:56:47.147+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=28 "номер заказа"=12345678903 "текущий статус"=NEW + time=2025-02-12T10:56:47.147+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=12345678903 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.147+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=30 "номер заказа"=555326 "текущий статус"=NEW + time=2025-02-12T10:56:47.147+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=555326 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.148+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=32 "номер заказа"=617655814315855 "текущий статус"=NEW + time=2025-02-12T10:56:47.148+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=617655814315855 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.148+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=34 "номер заказа"=8111783 "текущий статус"=NEW + time=2025-02-12T10:56:47.148+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=8111783 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.148+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=36 "номер заказа"=607184 "текущий статус"=NEW + time=2025-02-12T10:56:47.148+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=607184 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.148+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=38 "номер заказа"=552011058 "текущий статус"=NEW + time=2025-02-12T10:56:47.149+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=552011058 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.149+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=40 "номер заказа"=703273423029 "текущий статус"=NEW + time=2025-02-12T10:56:47.149+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=703273423029 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.149+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=42 "номер заказа"=86527678 "текущий статус"=NEW + time=2025-02-12T10:56:47.149+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=86527678 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.149+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=44 "номер заказа"=773463831331 "текущий статус"=NEW + time=2025-02-12T10:56:47.150+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=773463831331 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.150+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=46 "номер заказа"=2166643 "текущий статус"=NEW + time=2025-02-12T10:56:47.150+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=2166643 error="заказ не найден в системе начислений" + time=2025-02-12T10:56:47.150+07:00 level=DEBUG msg="обработка заказа" environment=local package=worker component=AccrualWorker worker_id=0 method=processOrders "id заказа"=48 "номер заказа"=1423724010 "текущий статус"=NEW + time=2025-02-12T10:56:47.150+07:00 level=ERROR msg="failed to get order accrual" environment=local package=worker component=AccrualWorker order_number=1423724010 error="заказ не найден в системе начислений" + {"time":"2025-02-12T10:56:47.400812203+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1504671,"latency_human":"1.504671ms","bytes_in":0,"bytes_out":118} + {"time":"2025-02-12T10:56:47.406594189+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/balance","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":4653900,"latency_human":"4.6539ms","bytes_in":0,"bytes_out":33} + {"time":"2025-02-12T10:56:47.411734804+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/balance/withdraw","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":4096463,"latency_human":"4.096463ms","bytes_in":49,"bytes_out":0} + {"time":"2025-02-12T10:56:47.413668707+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/balance","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1390146,"latency_human":"1.390146ms","bytes_in":0,"bytes_out":38} + {"time":"2025-02-12T10:56:47.415805024+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/withdrawals","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":1473298,"latency_human":"1.473298ms","bytes_in":0,"bytes_out":85} + {"time":"2025-02-12T10:56:47.583298538+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/register","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":166089937,"latency_human":"166.089937ms","bytes_in":77,"bytes_out":158} + {"time":"2025-02-12T10:56:47.720800829+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/login","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":136086790,"latency_human":"136.08679ms","bytes_in":77,"bytes_out":158} + {"time":"2025-02-12T10:56:47.722366782+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":401,"error":"code=401, message=Отсутствует токен авторизации","latency":61511,"latency_human":"61.511µs","bytes_in":6,"bytes_out":71} + {"time":"2025-02-12T10:56:47.723224414+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":401,"error":"code=401, message=Отсутствует токен авторизации","latency":30463,"latency_human":"30.463µs","bytes_in":0,"bytes_out":71} + {"time":"2025-02-12T10:56:47.866620714+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/register","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":142493287,"latency_human":"142.493287ms","bytes_in":61,"bytes_out":169} + {"time":"2025-02-12T10:56:47.868701753+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":422,"error":"code=422, message=Неверный формат номера заказа","latency":884758,"latency_human":"884.758µs","bytes_in":11,"bytes_out":70} + time=2025-02-12T10:56:47.871+07:00 level=INFO msg="created new order" environment=local id=50 uploaded_at=2025-02-12T10:56:47.870+07:00 user_id=127 order_number=7626156655537 status=NEW + {"time":"2025-02-12T10:56:47.87118715+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":202,"error":"","latency":1971218,"latency_human":"1.971218ms","bytes_in":13,"bytes_out":0} + {"time":"2025-02-12T10:56:47.872724195+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":849113,"latency_human":"849.113µs","bytes_in":13,"bytes_out":0} + {"time":"2025-02-12T10:56:47.874343657+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"GET","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":575697,"latency_human":"575.697µs","bytes_in":0,"bytes_out":93} + {"time":"2025-02-12T10:56:48.018531835+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/register","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":200,"error":"","latency":142970143,"latency_human":"142.970143ms","bytes_in":65,"bytes_out":165} + {"time":"2025-02-12T10:56:48.020184189+07:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8082","method":"POST","uri":"/api/user/orders","user_agent":"go-resty/2.7.0 (https://github.com/go-resty/resty)","status":409,"error":"code=409, message=Номер заказа уже был загружен другим пользователем","latency":982763,"latency_human":"982.763µs","bytes_in":13,"bytes_out":109} + time=2025-02-12T10:56:48.021+07:00 level=INFO msg="received signal" environment=local signal=interrupt + time=2025-02-12T10:56:48.021+07:00 level=INFO msg="shutting down server..." environment=local + time=2025-02-12T10:56:48.021+07:00 level=INFO msg="воркер завершил работу" environment=local package=worker component=AccrualWorker worker_id=0 + time=2025-02-12T10:56:48.021+07:00 level=INFO msg="воркер завершил работу" environment=local package=worker component=AccrualWorker worker_id=1 + time=2025-02-12T10:56:48.021+07:00 level=INFO msg="all workers completed" environment=local + time=2025-02-12T10:56:48.022+07:00 level=INFO msg="application stopped" environment=local + gophermart_suite_test.go:108: останавливаем процесс accrual + gophermart_suite_test.go:140: Получен STDOUT лог процесса: + + {"level":"info","ts":1739332597.0070865,"caller":"accrual/main.go:52","msg":"starting HTTP server","addr":"localhost:43197"} + {"level":"info","ts":1739332608.0259929,"caller":"accrual/main.go:63","msg":"shutting down gracefully","signal":"interrupt"} +--- PASS: TestGophermart (11.04s) + --- PASS: TestGophermart/TestEndToEnd (10.22s) + --- PASS: TestGophermart/TestEndToEnd/register_accrual_mechanic (0.00s) + --- PASS: TestGophermart/TestEndToEnd/register_order_for_accrual (0.00s) + --- PASS: TestGophermart/TestEndToEnd/register_user (0.19s) + --- PASS: TestGophermart/TestEndToEnd/order_upload (0.01s) + --- PASS: TestGophermart/TestEndToEnd/await_order_processed (10.00s) + --- PASS: TestGophermart/TestEndToEnd/check_balance (0.01s) + --- PASS: TestGophermart/TestEndToEnd/withdraw_balance (0.00s) + --- PASS: TestGophermart/TestEndToEnd/recheck_balance (0.00s) + --- PASS: TestGophermart/TestEndToEnd/check_withdrawals (0.00s) + --- PASS: TestGophermart/TestUserAuth (0.31s) + --- PASS: TestGophermart/TestUserAuth/register_user (0.17s) + --- PASS: TestGophermart/TestUserAuth/login_user (0.14s) + --- PASS: TestGophermart/TestUserOrders (0.30s) + --- PASS: TestGophermart/TestUserOrders/unauthorized_order_upload (0.00s) + --- PASS: TestGophermart/TestUserOrders/unauthorized_orders_list (0.00s) + --- PASS: TestGophermart/TestUserOrders/register_user (0.14s) + --- PASS: TestGophermart/TestUserOrders/bad_order_upload (0.00s) + --- PASS: TestGophermart/TestUserOrders/order_upload (0.00s) + --- PASS: TestGophermart/TestUserOrders/duplicate_order_upload_same_user (0.00s) + --- PASS: TestGophermart/TestUserOrders/orders_list (0.00s) + --- PASS: TestGophermart/TestUserOrders/duplicate_order_upload_other_user (0.15s) +PASS diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..6d1b36e --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/jmoiron/sqlx" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + + "gophermart/internal/handlers" + "gophermart/internal/repository" + "gophermart/internal/service" + "gophermart/internal/worker" +) + +const ( + defaultWorkerCount = 2 + defaultTimeout = 10 * time.Second +) + +// App представляет основную структуру приложения. +type App struct { + echo *echo.Echo + db *sqlx.DB + userHandler *handlers.UserHandler + orderHandler *handlers.OrderHandler + balanceHandler *handlers.BalanceHandler + accrualWorker *worker.AccrualWorker + config Config + wg sync.WaitGroup // добавляем WaitGroup для ожидания завершения горутин +} + +// New создает новый экземпляр приложения. +func New(ctx context.Context, cfg Config) (*App, error) { + // Инициализация базы данных + db, dbErr := NewDB(ctx, cfg.DatabaseURI) + if dbErr != nil { + return nil, fmt.Errorf("failed to initialize database: %w", dbErr) + } + + // Применяем миграции + if migrateErr := MigrateDB(db.DB, cfg.MigrationsDir); migrateErr != nil { + return nil, fmt.Errorf("failed to apply migrations: %w", migrateErr) + } + + // Инициализация репозиториев + userRepo := repository.NewUserRepo(db) + orderRepo := repository.NewOrderRepo(db, slog.Default()) + balanceRepo := repository.NewBalanceRepo(db, slog.Default()) + + // Инициализация сервисов + userService := service.NewUserService(userRepo, cfg.JWTSecret, cfg.JWTExpirationPeriod) + orderService := service.NewOrderService(orderRepo) + balanceService := service.NewBalanceService(balanceRepo, slog.Default()) + accrualService := service.NewAccrualService(cfg.AccrualSystemAddress) + + // Создаем воркер для обработки начислений + accrualWorker := worker.NewAccrualWorker( + slog.Default(), + orderRepo, + accrualService, + defaultWorkerCount, // количество воркеров + defaultTimeout, + 0, // без задержки между попытками + ) + + // Инициализация обработчиков + userHandler := handlers.NewUserHandler(userService) + orderHandler := handlers.NewOrderHandler(orderService) + balanceHandler := handlers.NewBalanceHandler(balanceService) + + // Инициализация Echo + e := echo.New() + e.Validator = NewValidator() + + // Промежуточное ПО (middleware) + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + app := &App{ + echo: e, + db: db, + userHandler: userHandler, + orderHandler: orderHandler, + balanceHandler: balanceHandler, + accrualWorker: accrualWorker, + config: cfg, + } + + // Настройка маршрутов + app.setupRoutes() + + return app, nil +} + +// Start запускает приложение. +func (a *App) Start(ctx context.Context, address string) error { + // Запускаем воркер начислений в отдельной горутине + a.wg.Add(1) + go func() { + defer a.wg.Done() + a.accrualWorker.Start(ctx) + }() + + // Запускаем HTTP-сервер в фоне + serverErr := make(chan error, 1) + go func() { + serverErr <- a.echo.Start(address) + }() + + // Ожидаем либо завершения контекста, либо ошибки сервера + select { + case <-ctx.Done(): + slog.Info("shutting down server...") + + case err := <-serverErr: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server error", "error", err) + return err + } + } + + // Создаём контекст с таймаутом для graceful shutdown + shutdownCtx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + if err := a.Shutdown(shutdownCtx); err != nil { + slog.Error("failed to shutdown application", "error", err) + } + + return nil +} + +// Shutdown выполняет корректное завершение работы приложения. +func (a *App) Shutdown(ctx context.Context) error { + // Ждем завершения всех воркеров + shutdownComplete := make(chan struct{}) + go func() { + a.wg.Wait() + close(shutdownComplete) + }() + + // Ожидаем либо завершения всех воркеров, либо таймаута контекста + select { + case <-shutdownComplete: + slog.Info("all workers completed") + case <-ctx.Done(): + slog.Warn("shutdown timeout exceeded, some workers may not have completed") + } + + if err := a.db.Close(); err != nil { + return fmt.Errorf("failed to close database connection: %w", err) + } + + if err := a.echo.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown http server: %w", err) + } + + return nil +} + +// setupRoutes настраивает маршруты приложения. +func (a *App) setupRoutes() { + // Группа API + api := a.echo.Group("/api") + + // Маршруты пользователя + user := api.Group("/user") + + // Публичные маршруты + user.POST("/register", a.userHandler.Register) + user.POST("/login", a.userHandler.Authenticate) + + // Защищенные маршруты + protected := user.Group("", JWTMiddleware(a.config.JWTSecret)) + + // Маршруты заказов + protected.POST("/orders", a.orderHandler.Register) + protected.GET("/orders", a.orderHandler.GetOrders) + + // Маршруты баланса + protected.GET("/balance", a.balanceHandler.GetBalance) + protected.POST("/balance/withdraw", a.balanceHandler.Withdraw) + protected.GET("/withdrawals", a.balanceHandler.GetWithdrawals) +} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..11e1b61 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,15 @@ +package app + +import ( + "time" +) + +// Config представляет конфигурацию приложения. +type Config struct { + DatabaseURI string // URI подключения к базе данных + MigrationsDir string // Директория с миграциями + RunAddress string // Адрес и порт для запуска сервера + AccrualSystemAddress string // Адрес системы расчета начислений + JWTSecret string // Секретный ключ для подписи JWT токенов + JWTExpirationPeriod time.Duration // Период действия JWT токена +} diff --git a/internal/app/database.go b/internal/app/database.go new file mode 100644 index 0000000..f22c69f --- /dev/null +++ b/internal/app/database.go @@ -0,0 +1,38 @@ +package app + +import ( + "context" + "fmt" + "time" + + // Импортируем драйвер pgx для работы с PostgreSQL через database/sql. + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" +) + +const ( + maxOpenConns = 25 // Максимальное количество открытых соединений + connMaxLifetime = 5 * time.Minute // Максимальное время жизни соединения + maxIdleConns = 25 // Максимальное количество простаивающих соединений + connMaxIdleTime = 5 * time.Minute // Максимальное время простоя соединения +) + +// NewDB создает новое подключение к базе данных. +func NewDB(ctx context.Context, dsn string) (*sqlx.DB, error) { + db, connectErr := sqlx.ConnectContext(ctx, "pgx", dsn) + if connectErr != nil { + return nil, fmt.Errorf("не удалось подключиться к базе данных: %w", connectErr) + } + + db.SetMaxOpenConns(maxOpenConns) + db.SetConnMaxLifetime(connMaxLifetime) + db.SetMaxIdleConns(maxIdleConns) + db.SetConnMaxIdleTime(connMaxIdleTime) + + // Проверка подключения + if pingErr := db.PingContext(ctx); pingErr != nil { + return nil, fmt.Errorf("не удалось проверить подключение к базе данных: %w", pingErr) + } + + return db, nil +} diff --git a/internal/app/middleware.go b/internal/app/middleware.go new file mode 100644 index 0000000..1f01ab2 --- /dev/null +++ b/internal/app/middleware.go @@ -0,0 +1,95 @@ +package app + +import ( + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +// extractTokenFromHeader извлекает JWT токен из заголовка Authorization. +func extractTokenFromHeader(c echo.Context) (string, error) { + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + return "", echo.NewHTTPError(http.StatusUnauthorized, "Отсутствует токен авторизации") + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return "", echo.NewHTTPError(http.StatusUnauthorized, "Неверный формат токена") + } + + return parts[1], nil +} + +// validateToken проверяет JWT токен и возвращает claims. +func validateToken(tokenString string, secret string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Неверный метод подписи токена") + } + return []byte(secret), nil + }) + + if err != nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Неверный токен") + } + + if !token.Valid { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Токен недействителен") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Неверный формат данных токена") + } + + return claims, nil +} + +// extractUserData извлекает данные пользователя из claims. +func extractUserData(claims jwt.MapClaims) (int, string, error) { + userIDFloat, ok := claims["user_id"].(float64) + if !ok { + return 0, "", echo.NewHTTPError(http.StatusUnauthorized, "Отсутствует или неверный формат user_id") + } + + login, ok := claims["login"].(string) + if !ok { + return 0, "", echo.NewHTTPError(http.StatusUnauthorized, "Отсутствует или неверный формат login") + } + + return int(userIDFloat), login, nil +} + +// JWTMiddleware создает middleware для проверки JWT токена. +func JWTMiddleware(secret string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Извлекаем токен из заголовка + tokenString, err := extractTokenFromHeader(c) + if err != nil { + return err + } + + // Проверяем токен и получаем claims + claims, err := validateToken(tokenString, secret) + if err != nil { + return err + } + + // Извлекаем данные пользователя + userID, login, err := extractUserData(claims) + if err != nil { + return err + } + + // Устанавливаем данные в контекст + c.Set("user_id", userID) + c.Set("login", login) + + return next(c) + } + } +} diff --git a/internal/app/migrations.go b/internal/app/migrations.go new file mode 100644 index 0000000..6fdc0bc --- /dev/null +++ b/internal/app/migrations.go @@ -0,0 +1,21 @@ +package app + +import ( + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +// MigrateDB применяет миграции базы данных. +func MigrateDB(db *sql.DB, migrationsDir string) error { + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("не удалось установить диалект базы данных: %w", err) + } + + if err := goose.Up(db, migrationsDir); err != nil { + return fmt.Errorf("не удалось применить миграции: %w", err) + } + + return nil +} diff --git a/internal/app/validator.go b/internal/app/validator.go new file mode 100644 index 0000000..9f48a01 --- /dev/null +++ b/internal/app/validator.go @@ -0,0 +1,28 @@ +package app + +import ( + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" +) + +const ( + statusBadRequest = 400 +) + +// CustomValidator пользовательский валидатор для фреймворка Echo. +type CustomValidator struct { + validator *validator.Validate +} + +// NewValidator создает новый экземпляр валидатора. +func NewValidator() *CustomValidator { + return &CustomValidator{validator: validator.New()} +} + +// Validate проверяет переданную структуру. +func (cv *CustomValidator) Validate(i interface{}) error { + if err := cv.validator.Struct(i); err != nil { + return echo.NewHTTPError(statusBadRequest, err.Error()) + } + return nil +} diff --git a/internal/domain/balance.go b/internal/domain/balance.go new file mode 100644 index 0000000..6e7718f --- /dev/null +++ b/internal/domain/balance.go @@ -0,0 +1,37 @@ +package domain + +import "time" + +// Balance представляет баланс пользователя. +type Balance struct { + Current float64 `json:"current"` + Withdrawn float64 `json:"withdrawn"` +} + +// Withdrawal представляет списание средств. +type Withdrawal struct { + Order string `json:"order" db:"order_number"` + Sum float64 `json:"sum" db:"-"` + AmountKop int64 `json:"-" db:"amount_kop"` + ProcessedAt time.Time `json:"processed_at" db:"processed_at"` +} + +// WithdrawalRequest представляет запрос на списание средств. +type WithdrawalRequest struct { + Order string `json:"order" validate:"required"` + Sum float64 `json:"sum" validate:"required,gt=0"` +} + +// BalanceRepository определяет интерфейс для работы с балансом. +type BalanceRepository interface { + GetBalance(userID int) (*Balance, error) + CreateWithdrawal(userID int, withdrawal *Withdrawal) error + GetWithdrawals(userID int) ([]Withdrawal, error) +} + +// BalanceService определяет интерфейс для бизнес-логики работы с балансом. +type BalanceService interface { + GetBalance(userID int) (*Balance, error) + Withdraw(userID int, req *WithdrawalRequest) error + GetWithdrawals(userID int) ([]Withdrawal, error) +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..a2aa1d4 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,8 @@ +package domain + +import "errors" + +var ( + // ErrInvalidOrderNumber ошибка неверный номер заказа. + ErrInvalidOrderNumber = errors.New("неверный номер заказа") +) diff --git a/internal/domain/order.go b/internal/domain/order.go new file mode 100644 index 0000000..0a920a9 --- /dev/null +++ b/internal/domain/order.go @@ -0,0 +1,114 @@ +package domain + +import ( + "database/sql/driver" + "fmt" + "time" +) + +// OrderStatus представляет статус обработки заказа. +type OrderStatus string + +const ( + // OrderStatusNew заказ загружен в систему, но не попал в обработку. + OrderStatusNew OrderStatus = "NEW" + // OrderStatusProcessing вознаграждение за заказ рассчитывается. + OrderStatusProcessing OrderStatus = "PROCESSING" + // OrderStatusInvalid система расчета вознаграждений отказала в расчете. + OrderStatusInvalid OrderStatus = "INVALID" + // OrderStatusProcessed данные по заказу проверены и информация о расчете успешно получена. + OrderStatusProcessed OrderStatus = "PROCESSED" +) + +const ( + // KopPerRuble количество копеек в рубле. + KopPerRuble = 100 +) + +// Value реализует интерфейс driver.Valuer для OrderStatus. +func (s OrderStatus) Value() (driver.Value, error) { + return string(s), nil +} + +// Scan реализует интерфейс sql.Scanner для OrderStatus. +func (s *OrderStatus) Scan(value interface{}) error { + if value == nil { + *s = "" + return nil + } + strVal, ok := value.(string) + if !ok { + return fmt.Errorf("unable to scan %T into OrderStatus", value) + } + *s = OrderStatus(strVal) + return nil +} + +// Order представляет заказ в системе. +type Order struct { + ID int `json:"-" db:"id"` + Number string `json:"number" db:"number"` + UserID int `json:"-" db:"user_id"` + Status OrderStatus `json:"status" db:"status"` + Accrual *int64 `json:"-" db:"accrual,omitempty"` // сумма начисленных баллов в копейках + AccrualRub *float64 `json:"accrual,omitempty" db:"-"` // сумма начисленных баллов в рублях для JSON + UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"` +} + +// SetAccrual устанавливает сумму начисления в копейках и автоматически обновляет сумму в рублях. +func (o *Order) SetAccrual(kop int64) { + o.Accrual = &kop + o.CalculateAccrualRub() +} + +// CalculateAccrualRub вычисляет сумму в рублях на основе суммы в копейках. +func (o *Order) CalculateAccrualRub() { + if o.Accrual != nil { + accrualRub := float64(*o.Accrual) / KopPerRuble + o.AccrualRub = &accrualRub + } +} + +// GetAccrualRub возвращает сумму начисленных баллов в рублях. +func (o *Order) GetAccrualRub() float64 { + if o.Accrual == nil { + return 0 + } + return float64(*o.Accrual) / KopPerRuble +} + +// OrderRepository определяет интерфейс для доступа к данным заказов. +type OrderRepository interface { + // Create создает новый заказ. + Create(order *Order) error + // FindByNumber ищет заказ по номеру. + FindByNumber(number string) (*Order, error) + // FindByUserID возвращает все заказы пользователя. + FindByUserID(userID int) ([]Order, error) + // FindByStatus возвращает заказы с указанными статусами. + FindByStatus(statuses []OrderStatus) ([]Order, error) + // UpdateStatus обновляет статус заказа. + UpdateStatus(orderID int, status OrderStatus) error + // UpdateAccrual обновляет сумму начисленных баллов за заказ. + UpdateAccrual(orderID int, accrualKop int64) error +} + +// OrderService определяет интерфейс для бизнес-логики работы с заказами. +type OrderService interface { + // Register регистрирует новый заказ для пользователя. + Register(userID int, number string) error + // GetOrders возвращает список заказов пользователя. + GetOrders(userID int) ([]Order, error) +} + +// OrderRequest представляет данные запроса на регистрацию заказа. +type OrderRequest struct { + Number string `json:"number" validate:"required"` +} + +// OrderAccrual представляет информацию о начислении баллов за заказ. +type OrderAccrual struct { + Order string `json:"order"` + Status OrderStatus `json:"status"` + Accrual *float64 `json:"accrual,omitempty"` +} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..d7d7430 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,37 @@ +package domain + +import ( + "time" +) + +// User представляет пользователя в системе. +type User struct { + ID int `json:"-" db:"id"` + Login string `json:"login" db:"login"` + PasswordHash string `json:"-" db:"password_hash"` + CreatedAt time.Time `json:"-" db:"created_at"` + UpdatedAt time.Time `json:"-" db:"updated_at"` +} + +// AuthToken представляет данные авторизационного токена. +type AuthToken struct { + Token string `json:"token"` +} + +// UserRepository определяет интерфейс для доступа к данным пользователей. +type UserRepository interface { + Create(user *User) error + FindByLogin(login string) (*User, error) +} + +// UserService определяет интерфейс для бизнес-логики работы с пользователями. +type UserService interface { + Register(login, password string) (*AuthToken, error) + Authenticate(login, password string) (*AuthToken, error) +} + +// RegisterRequest представляет данные запроса на регистрацию. +type RegisterRequest struct { + Login string `json:"login" validate:"required"` + Password string `json:"password" validate:"required"` +} diff --git a/internal/handlers/balance.go b/internal/handlers/balance.go new file mode 100644 index 0000000..456e119 --- /dev/null +++ b/internal/handlers/balance.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + + "gophermart/internal/domain" + "gophermart/internal/service" +) + +// BalanceHandler обработчик запросов для работы с балансом. +type BalanceHandler struct { + balanceService domain.BalanceService +} + +// NewBalanceHandler создает новый экземпляр BalanceHandler. +func NewBalanceHandler(balanceService domain.BalanceService) *BalanceHandler { + return &BalanceHandler{ + balanceService: balanceService, + } +} + +// Register регистрирует обработчики в Echo. +func (h *BalanceHandler) Register(e *echo.Echo) { + e.GET("/api/user/balance", h.GetBalance) + e.POST("/api/user/balance/withdraw", h.Withdraw) + e.GET("/api/user/withdrawals", h.GetWithdrawals) +} + +// GetBalance возвращает текущий баланс пользователя. +func (h *BalanceHandler) GetBalance(c echo.Context) error { + userIDRaw := c.Get("user_id") + userID, ok := userIDRaw.(int) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid user_id in context") + } + + balance, err := h.balanceService.GetBalance(userID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + + return c.JSON(http.StatusOK, balance) +} + +// Withdraw обрабатывает запрос на списание средств. +func (h *BalanceHandler) Withdraw(c echo.Context) error { + userIDRaw := c.Get("user_id") + userID, ok := userIDRaw.(int) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid user_id in context") + } + + var req domain.WithdrawalRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Неверный формат запроса") + } + + if err := c.Validate(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Ошибка валидации") + } + + err := h.balanceService.Withdraw(userID, &req) + if err != nil { + if errors.Is(err, domain.ErrInvalidOrderNumber) { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Неверный номер заказа") + } + if errors.Is(err, service.ErrInsufficientFunds) { + return echo.NewHTTPError(http.StatusPaymentRequired, "Недостаточно средств") + } + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + + return c.NoContent(http.StatusOK) +} + +// GetWithdrawals возвращает историю списаний пользователя. +func (h *BalanceHandler) GetWithdrawals(c echo.Context) error { + userIDRaw := c.Get("user_id") + userID, ok := userIDRaw.(int) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid user_id in context") + } + + withdrawals, err := h.balanceService.GetWithdrawals(userID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + + if len(withdrawals) == 0 { + return c.NoContent(http.StatusNoContent) + } + + return c.JSON(http.StatusOK, withdrawals) +} diff --git a/internal/handlers/order.go b/internal/handlers/order.go new file mode 100644 index 0000000..657032d --- /dev/null +++ b/internal/handlers/order.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "database/sql" + "errors" + "io" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + + "gophermart/internal/domain" + "gophermart/internal/service" +) + +// OrderHandler обрабатывает HTTP-запросы, связанные с заказами. +type OrderHandler struct { + orderService domain.OrderService +} + +// NewOrderHandler создает новый экземпляр OrderHandler. +func NewOrderHandler(orderService domain.OrderService) *OrderHandler { + return &OrderHandler{orderService: orderService} +} + +// Register обрабатывает загрузку номера заказа. +// @Summary Загрузка номера заказа. +// @Tags orders +// @Accept text/plain +// @Produce json +// @Param number body string true "Номер заказа" +// @Success 202 "Новый номер заказа принят в обработку" +// @Success 200 "Номер заказа уже был загружен этим пользователем" +// @Failure 400 "Неверный формат запроса" +// @Failure 401 "Пользователь не аутентифицирован" +// @Failure 409 "Номер заказа уже был загружен другим пользователем" +// @Failure 422 "Неверный формат номера заказа" +// @Failure 500 "Внутренняя ошибка сервера" +// @Router /api/user/orders [post] +// @Description Загружает номер заказа для расчета начисления баллов лояльности. +func (h *OrderHandler) Register(c echo.Context) error { + userIDRaw := c.Get("user_id") + userID, ok := userIDRaw.(int) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid user_id in context") + } + + // Проверяем Content-Type + if !strings.HasPrefix(c.Request().Header.Get("Content-Type"), "text/plain") { + return echo.NewHTTPError(http.StatusBadRequest, "Неверный формат запроса (Content-Type должен быть text/plain)") + } + + // Читаем тело запроса + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Неверный формат запроса (не удалось прочитать тело запроса)") + } + defer c.Request().Body.Close() + + // Преобразуем байты в строку и убираем пробелы + number := strings.TrimSpace(string(body)) + if number == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Неверный формат запроса (тело запроса не может быть пустым)") + } + + err = h.orderService.Register(userID, number) + if err != nil { + switch { + case errors.Is(err, service.ErrOrderExists): + return c.NoContent(http.StatusOK) + case errors.Is(err, service.ErrOrderRegisteredByOther): + return echo.NewHTTPError(http.StatusConflict, "Номер заказа уже был загружен другим пользователем") + case errors.Is(err, service.ErrInvalidOrderNumber): + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Неверный формат номера заказа") + default: + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + } + + return c.NoContent(http.StatusAccepted) +} + +// GetOrders возвращает список заказов пользователя. +// @Summary Получение списка заказов. +// @Tags orders +// @Produce json +// @Success 200 {array} domain.Order "Список заказов" +// @Success 204 "Нет данных для ответа" +// @Failure 401 "Пользователь не аутентифицирован" +// @Failure 500 "Внутренняя ошибка сервера" +// @Router /api/user/orders [get] +// @Description Возвращает список загруженных пользователем номеров +// заказов, статусов их обработки и информации о начислениях. +func (h *OrderHandler) GetOrders(c echo.Context) error { + userIDRaw := c.Get("user_id") + userID, ok := userIDRaw.(int) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, "invalid user_id in context") + } + + orders, err := h.orderService.GetOrders(userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.NoContent(http.StatusNoContent) + } + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + + if len(orders) == 0 { + return c.NoContent(http.StatusNoContent) + } + + return c.JSON(http.StatusOK, orders) +} diff --git a/internal/handlers/user.go b/internal/handlers/user.go new file mode 100644 index 0000000..d1a5c37 --- /dev/null +++ b/internal/handlers/user.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "errors" + "gophermart/internal/domain" + "gophermart/internal/service" +) + +// UserHandler обрабатывает HTTP-запросы, связанные с пользователями. +type UserHandler struct { + userService domain.UserService +} + +// NewUserHandler создает новый экземпляр UserHandler. +func NewUserHandler(userService domain.UserService) *UserHandler { + return &UserHandler{userService: userService} +} + +// Register обрабатывает регистрацию пользователя. +// @Summary Регистрация нового пользователя. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body domain.RegisterRequest true "Учетные данные для регистрации" +// @Success 200 {object} domain.AuthToken "Пользователь успешно зарегистрирован" +// @Failure 400 "Неверный формат запроса" +// @Failure 409 "Логин уже занят" +// @Failure 500 "Внутренняя ошибка сервера" +// @Router /api/user/register [post] +// @Description Регистрирует нового пользователя с логином и паролем. +func (h *UserHandler) Register(c echo.Context) error { + var req domain.RegisterRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Неверный формат запроса") + } + + if err := c.Validate(req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Ошибка валидации") + } + + token, err := h.userService.Register(req.Login, req.Password) + if err != nil { + if errors.Is(err, service.ErrUserExists) { + return echo.NewHTTPError(http.StatusConflict, "Пользователь уже существует") + } + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + + // Устанавливаем токен в заголовок Authorization + c.Response().Header().Set("Authorization", "Bearer "+token.Token) + return c.JSON(http.StatusOK, token) +} + +// LoginRequest представляет данные запроса на вход. +type LoginRequest struct { + Login string `json:"login" validate:"required"` + Password string `json:"password" validate:"required"` +} + +// Authenticate обрабатывает аутентификацию пользователя. +// @Summary Аутентификация пользователя. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Учетные данные для входа" +// @Success 200 {object} domain.AuthToken "Пользователь успешно аутентифицирован" +// @Failure 400 "Неверный формат запроса" +// @Failure 401 "Неверная пара логин/пароль" +// @Failure 500 "Внутренняя ошибка сервера" +// @Router /api/user/login [post] +// @Description Аутентифицирует пользователя по логину и паролю. +func (h *UserHandler) Authenticate(c echo.Context) error { + var req LoginRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Неверный формат запроса") + } + + if err := c.Validate(req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Ошибка валидации") + } + + token, err := h.userService.Authenticate(req.Login, req.Password) + if err != nil { + if errors.Is(err, service.ErrInvalidLogin) { + return echo.NewHTTPError(http.StatusUnauthorized, "Неверный логин или пароль") + } + return echo.NewHTTPError(http.StatusInternalServerError, "Внутренняя ошибка сервера") + } + + // Устанавливаем токен в заголовок Authorization + c.Response().Header().Set("Authorization", "Bearer "+token.Token) + return c.JSON(http.StatusOK, token) +} diff --git a/internal/repository/balance.go b/internal/repository/balance.go new file mode 100644 index 0000000..ad7fd52 --- /dev/null +++ b/internal/repository/balance.go @@ -0,0 +1,89 @@ +package repository + +import ( + "log/slog" + + "github.com/jmoiron/sqlx" + + "gophermart/internal/domain" +) + +// BalanceRepo реализует интерфейс domain.BalanceRepository. +type BalanceRepo struct { + db *sqlx.DB + logger *slog.Logger +} + +// NewBalanceRepo создает новый экземпляр BalanceRepo. +func NewBalanceRepo(db *sqlx.DB, logger *slog.Logger) *BalanceRepo { + return &BalanceRepo{ + db: db, + logger: logger.With( + "package", "repository", + "component", "BalanceRepo", + ), + } +} + +// GetBalance возвращает текущий баланс пользователя. +func (r *BalanceRepo) GetBalance(userID int) (*domain.Balance, error) { + var balance domain.Balance + + // Получаем сумму всех начислений (сразу в рублях, null значения заменяются на 0) + err := r.db.Get(&balance.Current, ` + SELECT COALESCE(SUM(accrual), 0)::float / 100.0 + FROM orders + WHERE user_id = $1 AND status = 'PROCESSED'`, userID) + if err != nil { + return nil, err + } + + // Получаем сумму всех списаний + err = r.db.Get(&balance.Withdrawn, ` + SELECT COALESCE(SUM(amount_kop), 0)::float / 100.0 + FROM withdrawals + WHERE user_id = $1`, userID) + if err != nil { + return nil, err + } + + // Вычитаем списания из начислений + balance.Current -= balance.Withdrawn + return &balance, nil +} + +// CreateWithdrawal создает новую запись о списании средств. +func (r *BalanceRepo) CreateWithdrawal(userID int, withdrawal *domain.Withdrawal) error { + query := ` + INSERT INTO withdrawals (user_id, order_number, amount_kop) + VALUES ($1, $2, $3) + RETURNING processed_at` + + return r.db.QueryRow( + query, + userID, + withdrawal.Order, + withdrawal.AmountKop, + ).Scan(&withdrawal.ProcessedAt) +} + +// GetWithdrawals возвращает историю списаний пользователя. +func (r *BalanceRepo) GetWithdrawals(userID int) ([]domain.Withdrawal, error) { + var withdrawals []domain.Withdrawal + query := ` + SELECT order_number, amount_kop, processed_at + FROM withdrawals + WHERE user_id = $1 + ORDER BY processed_at DESC` + + if err := r.db.Select(&withdrawals, query, userID); err != nil { + return nil, err + } + + // Конвертируем копейки в рубли + for i := range withdrawals { + withdrawals[i].Sum = float64(withdrawals[i].AmountKop) / domain.KopPerRuble + } + + return withdrawals, nil +} diff --git a/internal/repository/order.go b/internal/repository/order.go new file mode 100644 index 0000000..3460817 --- /dev/null +++ b/internal/repository/order.go @@ -0,0 +1,121 @@ +package repository + +import ( + "fmt" + "log/slog" + + "github.com/jmoiron/sqlx" + + "gophermart/internal/domain" +) + +// OrderRepo реализует интерфейс domain.OrderRepository. +type OrderRepo struct { + db *sqlx.DB + logger *slog.Logger +} + +// NewOrderRepo создает новый экземпляр OrderRepo. +func NewOrderRepo(db *sqlx.DB, logger *slog.Logger) *OrderRepo { + return &OrderRepo{ + db: db, + logger: logger.With( + "package", "repository", + "component", "OrderRepo", + ), + } +} + +// Create создает новый заказ. +func (r *OrderRepo) Create(order *domain.Order) error { + query := ` + INSERT INTO orders (number, user_id, status) + VALUES ($1, $2, $3) + RETURNING id, uploaded_at` + + return r.db.QueryRow( + query, + order.Number, + order.UserID, + order.Status, + ).Scan(&order.ID, &order.UploadedAt) +} + +// FindByNumber ищет заказ по номеру. +func (r *OrderRepo) FindByNumber(number string) (*domain.Order, error) { + var order domain.Order + query := `SELECT * FROM orders WHERE number = $1` + err := r.db.Get(&order, query, number) + if err != nil { + return nil, err + } + return &order, nil +} + +// FindByUserID возвращает все заказы пользователя. +func (r *OrderRepo) FindByUserID(userID int) ([]domain.Order, error) { + var orders []domain.Order + query := ` + SELECT * FROM orders + WHERE user_id = $1 + ORDER BY uploaded_at DESC` + err := r.db.Select(&orders, query, userID) + if err != nil { + return nil, err + } + return orders, nil +} + +// UpdateStatus обновляет статус заказа. +func (r *OrderRepo) UpdateStatus(orderID int, status domain.OrderStatus) error { + query := ` + UPDATE orders + SET status = $1 + WHERE id = $2` + _, err := r.db.Exec(query, status, orderID) + return err +} + +// UpdateAccrual обновляет сумму начисленных баллов за заказ. +func (r *OrderRepo) UpdateAccrual(orderID int, accrualKop int64) error { + logger := r.logger.With("method", "UpdateAccrual") + logger.Info("обновление статуса на PROCESSED", + "id заказа", orderID, + "начисление (коп)", accrualKop) + + query := ` + UPDATE orders + SET accrual = $1, status = $2 + WHERE id = $3` + _, err := r.db.Exec(query, accrualKop, domain.OrderStatusProcessed, orderID) + return err +} + +// FindByStatus возвращает заказы с указанными статусами. +func (r *OrderRepo) FindByStatus(statuses []domain.OrderStatus) ([]domain.Order, error) { + logger := r.logger.With("method", "FindByStatus") + + // Преобразуем OrderStatus в []string для запроса к БД и логирования + statusStrings := make([]string, len(statuses)) + for i, s := range statuses { + statusStrings[i] = string(s) + } + + query := ` + SELECT * FROM orders + WHERE status = ANY($1) + ORDER BY uploaded_at ASC` + + var orders []domain.Order + err := r.db.Select(&orders, query, statusStrings) + if err != nil { + logger.Error("ошибка при поиске заказов", + "error", err, + "тип ошибки", fmt.Sprintf("%T", err), + ) + return nil, err + } + + logger.Debug("поиск заказов по статусам", "статусы", statusStrings, "количество", len(orders)) + return orders, nil +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..d32a99d --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,42 @@ +package repository + +import ( + "github.com/jmoiron/sqlx" + + "gophermart/internal/domain" +) + +// UserRepo реализует интерфейс domain.UserRepository. +type UserRepo struct { + db *sqlx.DB +} + +// NewUserRepo создает новый экземпляр UserRepo. +func NewUserRepo(db *sqlx.DB) *UserRepo { + return &UserRepo{db: db} +} + +// Create добавляет нового пользователя в базу данных. +func (r *UserRepo) Create(user *domain.User) error { + query := ` + INSERT INTO users (login, password_hash) + VALUES ($1, $2) + RETURNING id, created_at, updated_at` + + return r.db.QueryRow( + query, + user.Login, + user.PasswordHash, + ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) +} + +// FindByLogin ищет пользователя по логину. +func (r *UserRepo) FindByLogin(login string) (*domain.User, error) { + var user domain.User + query := `SELECT * FROM users WHERE login = $1` + err := r.db.Get(&user, query, login) + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/service/accrual.go b/internal/service/accrual.go new file mode 100644 index 0000000..b0b1c26 --- /dev/null +++ b/internal/service/accrual.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "gophermart/internal/domain" +) + +var ( + // ErrOrderNotFound ошибка заказ не найден в системе начислений. + ErrOrderNotFound = errors.New("заказ не найден в системе начислений") +) + +// AccrualResponse представляет ответ от системы начислений. +type AccrualResponse struct { + Order string `json:"order"` + Status domain.OrderStatus `json:"status"` + Accrual *float64 `json:"accrual,omitempty"` +} + +const ( + defaultClientTimeout = 10 * time.Second + defaultRetryTimeout = 60 * time.Second +) + +// AccrualService сервис для взаимодействия с системой начислений. +type AccrualService struct { + client *http.Client + baseURL string +} + +// NewAccrualService создает новый экземпляр AccrualService. +func NewAccrualService(baseURL string) *AccrualService { + return &AccrualService{ + client: &http.Client{ + Timeout: defaultClientTimeout, + }, + baseURL: baseURL, + } +} + +// GetOrderAccrual получает информацию о начислении баллов за заказ. +func (s *AccrualService) GetOrderAccrual(ctx context.Context, orderNumber string) (*domain.OrderAccrual, error) { + url := fmt.Sprintf("%s/api/orders/%s", s.baseURL, orderNumber) + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if reqErr != nil { + return nil, fmt.Errorf("failed to create request: %w", reqErr) + } + + resp, respErr := s.client.Do(req) + if respErr != nil { + return nil, fmt.Errorf("failed to do request: %w", respErr) + } + defer resp.Body.Close() + + // Если заказ не найден, возвращаем специальную ошибку + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound { + return nil, ErrOrderNotFound + } + + // Проверяем rate limit + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := resp.Header.Get("Retry-After") + if retryAfter != "" { + seconds, durationErr := time.ParseDuration(retryAfter + "s") + if durationErr != nil { + return nil, fmt.Errorf("failed to parse retry after: %w", durationErr) + } + return nil, &RateLimitError{RetryAfter: seconds} + } + } + + // Проверяем успешность ответа + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Декодируем ответ + var accrual domain.OrderAccrual + if decodeErr := json.NewDecoder(resp.Body).Decode(&accrual); decodeErr != nil { + return nil, fmt.Errorf("failed to decode response: %w", decodeErr) + } + + return &accrual, nil +} + +// RateLimitError ошибка превышения лимита запросов. +type RateLimitError struct { + RetryAfter time.Duration +} + +func (e *RateLimitError) Error() string { + return fmt.Sprintf("превышен лимит запросов, повторить через %v", e.RetryAfter) +} diff --git a/internal/service/balance.go b/internal/service/balance.go new file mode 100644 index 0000000..03e42a8 --- /dev/null +++ b/internal/service/balance.go @@ -0,0 +1,68 @@ +package service + +import ( + "errors" + "log/slog" + + "gophermart/internal/domain" + "gophermart/internal/utils" +) + +var ( + // ErrInsufficientFunds ошибка недостаточно средств. + ErrInsufficientFunds = errors.New("недостаточно средств") +) + +// BalanceService реализует интерфейс domain.BalanceService. +type BalanceService struct { + repo domain.BalanceRepository + logger *slog.Logger +} + +// NewBalanceService создает новый экземпляр BalanceService. +func NewBalanceService(repo domain.BalanceRepository, logger *slog.Logger) *BalanceService { + return &BalanceService{ + repo: repo, + logger: logger.With( + "package", "service", + "component", "BalanceService", + ), + } +} + +// GetBalance возвращает текущий баланс пользователя. +func (s *BalanceService) GetBalance(userID int) (*domain.Balance, error) { + return s.repo.GetBalance(userID) +} + +// Withdraw списывает средства с баланса пользователя. +func (s *BalanceService) Withdraw(userID int, req *domain.WithdrawalRequest) error { + // Проверяем номер заказа по алгоритму Луна + if !utils.ValidateLuhn(req.Order) { + return domain.ErrInvalidOrderNumber + } + + // Получаем текущий баланс + balance, err := s.repo.GetBalance(userID) + if err != nil { + return err + } + + // Проверяем достаточно ли средств + if balance.Current < req.Sum { + return ErrInsufficientFunds + } + + // Создаем запись о списании + withdrawal := &domain.Withdrawal{ + Order: req.Order, + AmountKop: int64(req.Sum * domain.KopPerRuble), + } + + return s.repo.CreateWithdrawal(userID, withdrawal) +} + +// GetWithdrawals возвращает историю списаний пользователя. +func (s *BalanceService) GetWithdrawals(userID int) ([]domain.Withdrawal, error) { + return s.repo.GetWithdrawals(userID) +} diff --git a/internal/service/errors.go b/internal/service/errors.go new file mode 100644 index 0000000..339cf8c --- /dev/null +++ b/internal/service/errors.go @@ -0,0 +1,24 @@ +package service + +import "errors" + +var ( + // Ошибки пользователя. + + // ErrUserExists возникает при попытке зарегистрировать пользователя с существующим логином. + ErrUserExists = errors.New("пользователь уже существует") + // ErrInvalidLogin возникает при неверной паре логин/пароль. + ErrInvalidLogin = errors.New("неверный логин или пароль") + + // Ошибки заказов. + + // ErrOrderExists возникает при попытке зарегистрировать заказ, который уже был зарегистрирован этим пользователем. + ErrOrderExists = errors.New("заказ уже был зарегистрирован этим пользователем") + // ErrOrderRegisteredByOther возникает при попытке зарегистрировать заказ, + // который уже был зарегистрирован другим пользователем. + ErrOrderRegisteredByOther = errors.New("заказ уже был зарегистрирован другим пользователем") + // ErrInvalidOrderNumber возникает при попытке зарегистрировать заказ с неверным номером. + ErrInvalidOrderNumber = errors.New( + "неверный формат номера заказа, номер заказа должен состоять из 10 цифр и проходить по алгоритму Луна", + ) +) diff --git a/internal/service/order.go b/internal/service/order.go new file mode 100644 index 0000000..2522fb8 --- /dev/null +++ b/internal/service/order.go @@ -0,0 +1,77 @@ +package service + +import ( + "database/sql" + "errors" + "log/slog" + + "gophermart/internal/domain" + "gophermart/internal/utils" +) + +// OrderService реализует интерфейс domain.OrderService. +type OrderService struct { + repo domain.OrderRepository +} + +// NewOrderService создает новый экземпляр OrderService. +func NewOrderService(repo domain.OrderRepository) *OrderService { + return &OrderService{repo: repo} +} + +// Register регистрирует новый заказ для пользователя. +func (s *OrderService) Register(userID int, number string) error { + // Проверяем, существует ли заказ + existingOrder, err := s.repo.FindByNumber(number) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + // Если заказ существует + if existingOrder != nil { + // Если заказ принадлежит текущему пользователю + if existingOrder.UserID == userID { + return ErrOrderExists + } + // Если заказ принадлежит другому пользователю + return ErrOrderRegisteredByOther + } + + // Проверяем номер заказа по алгоритму Луна + if !utils.ValidateLuhn(number) { + return ErrInvalidOrderNumber + } + + // Создаем новый заказ + order := &domain.Order{ + Number: number, + UserID: userID, + Status: domain.OrderStatusNew, + } + + err = s.repo.Create(order) + + slog.Info("created new order", + "id", order.ID, + "uploaded_at", order.UploadedAt, + "user_id", userID, + "order_number", number, + "status", domain.OrderStatusNew) + + return err +} + +// GetOrders возвращает список заказов пользователя. +func (s *OrderService) GetOrders(userID int) ([]domain.Order, error) { + orders, err := s.repo.FindByUserID(userID) + if err != nil { + return nil, err + } + + // Вычисляем сумму в рублях для каждого заказа + for i := range orders { + orders[i].CalculateAccrualRub() + } + + return orders, nil +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..a834026 --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,99 @@ +package service + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "gophermart/internal/domain" +) + +// UserService реализует интерфейс domain.UserService. +type UserService struct { + repo domain.UserRepository + jwtSecret []byte + jwtExpiryHours int +} + +// NewUserService создает новый экземпляр UserService. +func NewUserService(repo domain.UserRepository, jwtSecret string, jwtExpirationTime time.Duration) *UserService { + return &UserService{ + repo: repo, + jwtSecret: []byte(jwtSecret), + jwtExpiryHours: int(jwtExpirationTime.Hours()), + } +} + +// generateToken создает новый JWT токен для пользователя. +func (s *UserService) generateToken(userID int, login string) (*domain.AuthToken, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": userID, + "login": login, + "exp": time.Now().Add(time.Duration(s.jwtExpiryHours) * time.Hour).Unix(), + }) + + tokenString, err := token.SignedString(s.jwtSecret) + if err != nil { + return nil, err + } + + return &domain.AuthToken{Token: tokenString}, nil +} + +// Register создает нового пользователя с указанными учетными данными. +func (s *UserService) Register(login, password string) (*domain.AuthToken, error) { + // Проверяем, существует ли пользователь + existingUser, findErr := s.repo.FindByLogin(login) + if findErr == nil && existingUser != nil { + return nil, ErrUserExists + } + + // Хешируем пароль + hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if hashErr != nil { + return nil, fmt.Errorf("failed to hash password: %w", hashErr) + } + + // Создаем нового пользователя + user := &domain.User{ + Login: login, + PasswordHash: string(hashedPassword), + } + + // Сохраняем пользователя в базу + if createErr := s.repo.Create(user); createErr != nil { + return nil, fmt.Errorf("failed to create user: %w", createErr) + } + + // Генерируем JWT токен + token, tokenErr := s.generateToken(user.ID, user.Login) + if tokenErr != nil { + return nil, fmt.Errorf("failed to generate token: %w", tokenErr) + } + + return token, nil +} + +// Authenticate проверяет учетные данные пользователя и возвращает токен, если данные верны. +func (s *UserService) Authenticate(login, password string) (*domain.AuthToken, error) { + // Ищем пользователя по логину + user, findErr := s.repo.FindByLogin(login) + if findErr != nil { + return nil, ErrInvalidLogin + } + + // Проверяем пароль + if compareErr := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); compareErr != nil { + return nil, ErrInvalidLogin + } + + // Генерируем JWT токен + token, tokenErr := s.generateToken(user.ID, user.Login) + if tokenErr != nil { + return nil, fmt.Errorf("failed to generate token: %w", tokenErr) + } + + return token, nil +} diff --git a/internal/utils/luhn.go b/internal/utils/luhn.go new file mode 100644 index 0000000..56afa57 --- /dev/null +++ b/internal/utils/luhn.go @@ -0,0 +1,28 @@ +package utils + +const ( + maxDigit = 9 // Максимальное значение цифры +) + +// ValidateLuhn проверяет номер заказа по алгоритму Луна. +func ValidateLuhn(number string) bool { + sum := 0 + isSecond := false + + // Проходим по цифрам справа налево + for i := len(number) - 1; i >= 0; i-- { + d := int(number[i] - '0') + + if isSecond { + d *= 2 + if d > maxDigit { + d -= maxDigit + } + } + + sum += d + isSecond = !isSecond + } + + return sum%10 == 0 +} diff --git a/internal/worker/accrual.go b/internal/worker/accrual.go new file mode 100644 index 0000000..06a1f1c --- /dev/null +++ b/internal/worker/accrual.go @@ -0,0 +1,203 @@ +package worker + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "errors" + "gophermart/internal/domain" + "gophermart/internal/service" +) + +// contextKey используется для ключей контекста. +type contextKey string + +const ( + defaultWorkerCount = 5 + defaultPollInterval = 1 * time.Second + defaultRetryTimeout = 1 * time.Minute + + workerIDKey = contextKey("worker_id") +) + +// AccrualWorker обработчик заказов для получения информации о начислениях. +type AccrualWorker struct { + logger *slog.Logger + orderRepo domain.OrderRepository + accrualService *service.AccrualService + workerCount int + pollInterval time.Duration + retryTimeout time.Duration +} + +// NewAccrualWorker создает новый экземпляр AccrualWorker. +func NewAccrualWorker( + logger *slog.Logger, + orderRepo domain.OrderRepository, + accrualService *service.AccrualService, + workerCount int, + pollInterval time.Duration, + retryTimeout time.Duration, +) *AccrualWorker { + if workerCount <= 0 { + workerCount = defaultWorkerCount + } + if pollInterval <= 0 { + pollInterval = defaultPollInterval + } + if retryTimeout <= 0 { + retryTimeout = defaultRetryTimeout + } + + return &AccrualWorker{ + logger: logger.With( + "package", "worker", + "component", "AccrualWorker", + ), + orderRepo: orderRepo, + accrualService: accrualService, + workerCount: workerCount, + pollInterval: pollInterval, + retryTimeout: retryTimeout, + } +} + +// Start запускает обработку заказов. +func (w *AccrualWorker) Start(ctx context.Context) { + var wg sync.WaitGroup + + // Запускаем пул воркеров + for workerID := range w.workerCount { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + w.worker(ctx, workerID) + }(workerID) + } + + // Ждем завершения всех воркеров + wg.Wait() +} + +// worker обрабатывает заказы. +func (w *AccrualWorker) worker(ctx context.Context, id int) { + // Создаем отдельный логгер для этого воркера + workerLogger := w.logger.With("worker_id", id) + workerLogger.Info("воркер начал работу") + + // Добавляем worker_id в контекст + ctx = context.WithValue(ctx, workerIDKey, id) + + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + workerLogger.Info("воркер завершил работу") + return + case <-ticker.C: + if err := w.processOrders(ctx, workerLogger); err != nil { + workerLogger.Error("ошибка обработки заказов", "error", err) + // Увеличиваем интервал опроса при ошибках + ticker.Reset(w.retryTimeout) + } else { + // Возвращаем нормальный интервал опроса + ticker.Reset(w.pollInterval) + } + } + } +} + +// processOrders обрабатывает заказы, ожидающие обновления статуса. +func (w *AccrualWorker) processOrders(ctx context.Context, logger *slog.Logger) error { + logger = logger.With("method", "processOrders") + logger.Debug("начало обработки заказов") + + // Получаем заказы для обработки (NEW или PROCESSING) + statuses := []domain.OrderStatus{domain.OrderStatusNew, domain.OrderStatusProcessing} + logger.Debug("запрос заказов", "статусы", statuses) + + orders, findErr := w.orderRepo.FindByStatus(statuses) + if findErr != nil { + logger.Error("ошибка при поиске заказов", + "error", findErr, + "error_type", fmt.Sprintf("%T", findErr), + ) + return findErr + } + + logger.Debug("кол-во заказов для обработки воркером", "количество", len(orders)) + + for _, order := range orders { + // Проверяем контекст перед каждым заказом + if ctx.Err() != nil { + logger.Debug("контекст отменен", "error", ctx.Err()) + return ctx.Err() + } + + logger.Debug("обработка заказа", + "id заказа", order.ID, + "номер заказа", order.Number, + "текущий статус", order.Status) + + // Получаем информацию о начислении + accrual, accrualErr := w.accrualService.GetOrderAccrual(ctx, order.Number) + if accrualErr != nil { + var rateLimitErr *service.RateLimitError + if errors.As(accrualErr, &rateLimitErr) { + w.logger.Info("rate limit exceeded, waiting", + "order_number", order.Number, + "retry_after", rateLimitErr.RetryAfter) + time.Sleep(rateLimitErr.RetryAfter) + continue + } + w.logger.Error("failed to get order accrual", + "order_number", order.Number, + "error", accrualErr) + continue + } + + // Если заказ не найден, пропускаем + if accrual == nil { + logger.Debug("заказ не найден в системе начислений", + "номер заказа", order.Number) + continue + } + + logger.Debug("получена информация о начислении", + "номер заказа", order.Number, + "статус", accrual.Status, + "начисление", accrual.Accrual) + + // Обновляем статус заказа + if updateStatusErr := w.orderRepo.UpdateStatus(order.ID, accrual.Status); updateStatusErr != nil { + logger.Error("ошибка обновления статуса заказа", + "номер заказа", order.Number, + "статус", accrual.Status, + "error", updateStatusErr) + continue + } + + // Если есть начисление, обновляем сумму + if accrual.Status == domain.OrderStatusProcessed && accrual.Accrual != nil { + accrualKop := int64(*accrual.Accrual * domain.KopPerRuble) // конвертируем рубли в копейки + logger.Debug("обновление суммы начисления", + "номер заказа", order.Number, + "начисление (руб)", *accrual.Accrual, + "начисление (коп)", accrualKop) + + if updateAccrualErr := w.orderRepo.UpdateAccrual(order.ID, accrualKop); updateAccrualErr != nil { + logger.Error("ошибка обновления суммы начисления", + "номер заказа", order.Number, + "начисление (коп)", accrualKop, + "error", updateAccrualErr) + } + } + } + + return nil +} diff --git a/lint.log b/lint.log new file mode 100644 index 0000000..6a3ebaa --- /dev/null +++ b/lint.log @@ -0,0 +1 @@ +0 issues. diff --git a/migrations/001_create_users_table.sql b/migrations/001_create_users_table.sql new file mode 100644 index 0000000..515c213 --- /dev/null +++ b/migrations/001_create_users_table.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + login VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_login ON users(login); + +-- +goose Down +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/002_create_orders_table.sql b/migrations/002_create_orders_table.sql new file mode 100644 index 0000000..53cd91d --- /dev/null +++ b/migrations/002_create_orders_table.sql @@ -0,0 +1,19 @@ +-- +goose Up +CREATE TYPE order_status AS ENUM ('NEW', 'PROCESSING', 'INVALID', 'PROCESSED'); + +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + number VARCHAR(255) NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id), + status order_status NOT NULL DEFAULT 'NEW', + accrual BIGINT, -- сумма начисленных баллов в копейках + uploaded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_orders_user_id ON orders(user_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_orders_number ON orders(number); + +-- +goose Down +DROP TABLE IF EXISTS orders; +DROP TYPE IF EXISTS order_status; \ No newline at end of file diff --git a/migrations/003_create_balance_tables.sql b/migrations/003_create_balance_tables.sql new file mode 100644 index 0000000..4d4b766 --- /dev/null +++ b/migrations/003_create_balance_tables.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE withdrawals ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + order_number VARCHAR(255) NOT NULL, + amount_kop BIGINT NOT NULL, + processed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_withdrawals_user_id ON withdrawals(user_id); + +-- +goose Down +DROP TABLE IF EXISTS withdrawals; \ No newline at end of file