diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..1ac31b1 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,90 @@ +name: CI/CD Pipeline + +on: + pull_request: + branches: [ trunk ] + push: + branches: [ trunk ] + workflow_dispatch: + inputs: + environment: + description: 'Deploy environment' + required: true + default: 'testing' + type: choice + options: + - testing + - production + +jobs: +# lint: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - name: Set up Go +# uses: actions/setup-go@v5 +# with: +# go-version: '1.22' +# - name: Run golangci-lint +# uses: golangci/golangci-lint-action@v4 +# with: +# version: v1.55 +# working-directory: backend + + test: + runs-on: ubuntu-latest +# needs: lint + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Run tests + run: cd backend && go test ./... + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Build + run: cd backend && go build -v ./... + + release: + if: github.event_name == 'push' && github.ref == 'refs/heads/trunk' + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Bump version and push tag + id: tag + uses: anothrNick/github-tag-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_V: true + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.new_tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + deploy: + needs: release + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + environment: + name: ${{ github.event.inputs.environment }} + steps: + - uses: actions/checkout@v4 + - name: Deploy to ${{ github.event.inputs.environment }} + run: echo "Deploying to ${{ github.event.inputs.environment }}..." + - name: Manual approval + uses: trstringer/manual-approval@v1 + with: + secret: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index f9481d6..2dfe743 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -2,14 +2,20 @@ package main import ( "flag" - "github.com/labstack/echo/v4" - gomiddleware "github.com/labstack/echo/v4/middleware" "log" + "net/http" + "os" "trackly-backend/internal/api" "trackly-backend/internal/config" "trackly-backend/internal/db" "trackly-backend/internal/middleware" "trackly-backend/internal/repositories" + "trackly-backend/internal/service" + + "github.com/labstack/echo/v4" + "github.com/prometheus/client_golang/prometheus/promhttp" + + gomiddleware "github.com/labstack/echo/v4/middleware" ) type Server struct { @@ -20,6 +26,14 @@ type Server struct { } func main() { + // Инициализация логирования в файл + logFile, err := os.OpenFile("/Users/nt1dc/GolandProjects/Trackly/backend/logs/app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Не удалось открыть файл логов: %v", err) + } + defer logFile.Close() + log.SetOutput(logFile) + // Загрузка конфигурации configFilePath := flag.String("configs", "", "Path to the configuration file") flag.Parse() @@ -55,12 +69,17 @@ func main() { statisitcRepo := repositories.NewStatisticRepository(database) - statisticApi := api.NewStatisticApi(habitRepo, statisitcRepo) + aiServoce := service.NewYandexGptService(cfg.AiConfig) + + statisticApi := api.NewStatisticApi(habitRepo, statisitcRepo, aiServoce) progressApi := api.NewProgressApi(statisitcRepo, habitRepo, planRepo) server := &Server{userApi, habitsApi, statisticApi, progressApi} RegisterHandlers(e, server, cfg.JwtSecret) + http.Handle("/metrics", promhttp.Handler()) + go http.ListenAndServe(":2112", nil) // порт для метрик + // Запуск сервера e.Logger.Fatal(e.Start(":" + cfg.Port)) } @@ -70,8 +89,7 @@ func RegisterHandlers(router *echo.Echo, si api.ServerInterface, jwtSecret strin wrapper := api.ServerInterfaceWrapper{ Handler: si, } - router.Use(gomiddleware.Logger()) - router.Use(middleware.Cors()) + router.Use(gomiddleware.Logger(), middleware.Cors()) publicGroup := router.Group("") publicGroup.POST("/api/auth/login", wrapper.PostApiAuthLogin) @@ -89,6 +107,7 @@ func RegisterHandlers(router *echo.Echo, si api.ServerInterface, jwtSecret strin protectedGroup.POST("/api/habits/:habitId/score", wrapper.PostApiHabitsHabitIdScore) protectedGroup.GET("/api/habits/:habitId/statistic", wrapper.GetApiHabitsHabitIdStatistic) protectedGroup.GET("/api/habits/:habitId/statistic/total", wrapper.GetApiHabitsHabitIdStatisticTotal) + protectedGroup.GET("/api/habits/:habitId/statistic/ai-comment", wrapper.GetApiHabitsHabitIdStatisticAiComment) protectedGroup.POST("/api/users/avatar", wrapper.PostApiUsersAvatar) protectedGroup.GET("/api/users/profile", wrapper.GetApiUsersProfile) protectedGroup.PUT("/api/users/profile", wrapper.PutApiUsersProfile) diff --git a/backend/docker-compose.loki.yml b/backend/docker-compose.loki.yml new file mode 100644 index 0000000..c98660b --- /dev/null +++ b/backend/docker-compose.loki.yml @@ -0,0 +1,23 @@ +version: '3.7' +services: + loki: + image: grafana/loki:2.9.7 + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + + promtail: + image: grafana/promtail:2.9.7 + volumes: + - ./promtail-config.yaml:/etc/promtail/config.yaml + - ./logs:/app/logs + command: -config.file=/etc/promtail/config.yaml + + grafana: + image: grafana/grafana:10.4.2 + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + depends_on: + - loki \ No newline at end of file diff --git a/backend/docker-compose.monitoring.yml b/backend/docker-compose.monitoring.yml new file mode 100644 index 0000000..fef6f9a --- /dev/null +++ b/backend/docker-compose.monitoring.yml @@ -0,0 +1,18 @@ +version: '3.7' +services: + prometheus: + image: prom/prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + grafana: + image: grafana/grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + node-exporter: + image: prom/node-exporter + ports: + - "9100:9100" \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 11ba99a..c0ee283 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,6 +17,8 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/goccy/go-json v0.10.4 // indirect @@ -29,13 +31,18 @@ require ( github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/labstack/gommon v0.4.2 // 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/minio/md5-simd v1.1.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -43,7 +50,8 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect + golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 9a585c6..d631f56 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -5,7 +5,11 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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= @@ -61,11 +65,14 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= @@ -89,6 +96,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -99,6 +108,14 @@ 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/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -133,10 +150,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/internal/api/api.gen.go b/backend/internal/api/api.gen.go index 2939d41..735002b 100644 --- a/backend/internal/api/api.gen.go +++ b/backend/internal/api/api.gen.go @@ -55,6 +55,11 @@ type Habit struct { TodayValue int `json:"todayValue"` } +// HabitStatisticAiCommentResponse defines model for HabitStatisticAiCommentResponse. +type HabitStatisticAiCommentResponse struct { + Comment *string `json:"comment,omitempty"` +} + // HabitStatisticResponse defines model for HabitStatisticResponse. type HabitStatisticResponse struct { GroupBy StatisticGroupBy `json:"groupBy"` @@ -161,6 +166,12 @@ type GetApiHabitsHabitIdStatisticParams struct { GroupBy StatisticGroupBy `form:"group-by" json:"group-by"` } +// GetApiHabitsHabitIdStatisticAiCommentParams defines parameters for GetApiHabitsHabitIdStatisticAiComment. +type GetApiHabitsHabitIdStatisticAiCommentParams struct { + DateFrom openapi_types.Date `form:"date-from" json:"date-from"` + DateTo openapi_types.Date `form:"date-to" json:"date-to"` +} + // PostApiUsersAvatarMultipartBody defines parameters for PostApiUsersAvatar. type PostApiUsersAvatarMultipartBody struct { File *openapi_types.File `json:"file,omitempty"` @@ -294,6 +305,9 @@ type ClientInterface interface { // GetApiHabitsHabitIdStatistic request GetApiHabitsHabitIdStatistic(ctx context.Context, habitId int, params *GetApiHabitsHabitIdStatisticParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetApiHabitsHabitIdStatisticAiComment request + GetApiHabitsHabitIdStatisticAiComment(ctx context.Context, habitId int, params *GetApiHabitsHabitIdStatisticAiCommentParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetApiHabitsHabitIdStatisticTotal request GetApiHabitsHabitIdStatisticTotal(ctx context.Context, habitId int, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -468,6 +482,18 @@ func (c *Client) GetApiHabitsHabitIdStatistic(ctx context.Context, habitId int, return c.Client.Do(req) } +func (c *Client) GetApiHabitsHabitIdStatisticAiComment(ctx context.Context, habitId int, params *GetApiHabitsHabitIdStatisticAiCommentParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetApiHabitsHabitIdStatisticAiCommentRequest(c.Server, habitId, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetApiHabitsHabitIdStatisticTotal(ctx context.Context, habitId int, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetApiHabitsHabitIdStatisticTotalRequest(c.Server, habitId) if err != nil { @@ -891,6 +917,70 @@ func NewGetApiHabitsHabitIdStatisticRequest(server string, habitId int, params * return req, nil } +// NewGetApiHabitsHabitIdStatisticAiCommentRequest generates requests for GetApiHabitsHabitIdStatisticAiComment +func NewGetApiHabitsHabitIdStatisticAiCommentRequest(server string, habitId int, params *GetApiHabitsHabitIdStatisticAiCommentParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "habitId", runtime.ParamLocationPath, habitId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/habits/%s/statistic/ai-comment", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "date-from", runtime.ParamLocationQuery, params.DateFrom); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "date-to", runtime.ParamLocationQuery, params.DateTo); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetApiHabitsHabitIdStatisticTotalRequest generates requests for GetApiHabitsHabitIdStatisticTotal func NewGetApiHabitsHabitIdStatisticTotalRequest(server string, habitId int) (*http.Request, error) { var err error @@ -1125,6 +1215,9 @@ type ClientWithResponsesInterface interface { // GetApiHabitsHabitIdStatisticWithResponse request GetApiHabitsHabitIdStatisticWithResponse(ctx context.Context, habitId int, params *GetApiHabitsHabitIdStatisticParams, reqEditors ...RequestEditorFn) (*GetApiHabitsHabitIdStatisticResponse, error) + // GetApiHabitsHabitIdStatisticAiCommentWithResponse request + GetApiHabitsHabitIdStatisticAiCommentWithResponse(ctx context.Context, habitId int, params *GetApiHabitsHabitIdStatisticAiCommentParams, reqEditors ...RequestEditorFn) (*GetApiHabitsHabitIdStatisticAiCommentResponse, error) + // GetApiHabitsHabitIdStatisticTotalWithResponse request GetApiHabitsHabitIdStatisticTotalWithResponse(ctx context.Context, habitId int, reqEditors ...RequestEditorFn) (*GetApiHabitsHabitIdStatisticTotalResponse, error) @@ -1321,6 +1414,28 @@ func (r GetApiHabitsHabitIdStatisticResponse) StatusCode() int { return 0 } +type GetApiHabitsHabitIdStatisticAiCommentResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *HabitStatisticAiCommentResponse +} + +// Status returns HTTPResponse.Status +func (r GetApiHabitsHabitIdStatisticAiCommentResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetApiHabitsHabitIdStatisticAiCommentResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetApiHabitsHabitIdStatisticTotalResponse struct { Body []byte HTTPResponse *http.Response @@ -1540,6 +1655,15 @@ func (c *ClientWithResponses) GetApiHabitsHabitIdStatisticWithResponse(ctx conte return ParseGetApiHabitsHabitIdStatisticResponse(rsp) } +// GetApiHabitsHabitIdStatisticAiCommentWithResponse request returning *GetApiHabitsHabitIdStatisticAiCommentResponse +func (c *ClientWithResponses) GetApiHabitsHabitIdStatisticAiCommentWithResponse(ctx context.Context, habitId int, params *GetApiHabitsHabitIdStatisticAiCommentParams, reqEditors ...RequestEditorFn) (*GetApiHabitsHabitIdStatisticAiCommentResponse, error) { + rsp, err := c.GetApiHabitsHabitIdStatisticAiComment(ctx, habitId, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetApiHabitsHabitIdStatisticAiCommentResponse(rsp) +} + // GetApiHabitsHabitIdStatisticTotalWithResponse request returning *GetApiHabitsHabitIdStatisticTotalResponse func (c *ClientWithResponses) GetApiHabitsHabitIdStatisticTotalWithResponse(ctx context.Context, habitId int, reqEditors ...RequestEditorFn) (*GetApiHabitsHabitIdStatisticTotalResponse, error) { rsp, err := c.GetApiHabitsHabitIdStatisticTotal(ctx, habitId, reqEditors...) @@ -1809,6 +1933,32 @@ func ParseGetApiHabitsHabitIdStatisticResponse(rsp *http.Response) (*GetApiHabit return response, nil } +// ParseGetApiHabitsHabitIdStatisticAiCommentResponse parses an HTTP response from a GetApiHabitsHabitIdStatisticAiCommentWithResponse call +func ParseGetApiHabitsHabitIdStatisticAiCommentResponse(rsp *http.Response) (*GetApiHabitsHabitIdStatisticAiCommentResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetApiHabitsHabitIdStatisticAiCommentResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest HabitStatisticAiCommentResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseGetApiHabitsHabitIdStatisticTotalResponse parses an HTTP response from a GetApiHabitsHabitIdStatisticTotalWithResponse call func ParseGetApiHabitsHabitIdStatisticTotalResponse(rsp *http.Response) (*GetApiHabitsHabitIdStatisticTotalResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -1935,6 +2085,9 @@ type ServerInterface interface { // Получение статустики // (GET /api/habits/{habitId}/statistic) GetApiHabitsHabitIdStatistic(ctx echo.Context, habitId int, params GetApiHabitsHabitIdStatisticParams) error + // Получение комметария от нейронке по статистике + // (GET /api/habits/{habitId}/statistic/ai-comment) + GetApiHabitsHabitIdStatisticAiComment(ctx echo.Context, habitId int, params GetApiHabitsHabitIdStatisticAiCommentParams) error // Получение статустики // (GET /api/habits/{habitId}/statistic/total) GetApiHabitsHabitIdStatisticTotal(ctx echo.Context, habitId int) error @@ -2092,6 +2245,40 @@ func (w *ServerInterfaceWrapper) GetApiHabitsHabitIdStatistic(ctx echo.Context) return err } +// GetApiHabitsHabitIdStatisticAiComment converts echo context to params. +func (w *ServerInterfaceWrapper) GetApiHabitsHabitIdStatisticAiComment(ctx echo.Context) error { + var err error + // ------------- Path parameter "habitId" ------------- + var habitId int + + err = runtime.BindStyledParameterWithOptions("simple", "habitId", ctx.Param("habitId"), &habitId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter habitId: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetApiHabitsHabitIdStatisticAiCommentParams + // ------------- Required query parameter "date-from" ------------- + + err = runtime.BindQueryParameter("form", true, true, "date-from", ctx.QueryParams(), ¶ms.DateFrom) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter date-from: %s", err)) + } + + // ------------- Required query parameter "date-to" ------------- + + err = runtime.BindQueryParameter("form", true, true, "date-to", ctx.QueryParams(), ¶ms.DateTo) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter date-to: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetApiHabitsHabitIdStatisticAiComment(ctx, habitId, params) + return err +} + // GetApiHabitsHabitIdStatisticTotal converts echo context to params. func (w *ServerInterfaceWrapper) GetApiHabitsHabitIdStatisticTotal(ctx echo.Context) error { var err error @@ -2190,6 +2377,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.PUT(baseURL+"/api/habits/:habitId", wrapper.PutApiHabitsHabitId) router.POST(baseURL+"/api/habits/:habitId/score", wrapper.PostApiHabitsHabitIdScore) router.GET(baseURL+"/api/habits/:habitId/statistic", wrapper.GetApiHabitsHabitIdStatistic) + router.GET(baseURL+"/api/habits/:habitId/statistic/ai-comment", wrapper.GetApiHabitsHabitIdStatisticAiComment) router.GET(baseURL+"/api/habits/:habitId/statistic/total", wrapper.GetApiHabitsHabitIdStatisticTotal) router.GET(baseURL+"/api/users/avatar", wrapper.GetApiUsersAvatar) router.POST(baseURL+"/api/users/avatar", wrapper.PostApiUsersAvatar) diff --git a/backend/internal/api/statistic.go b/backend/internal/api/statistic.go index 9206a6d..b5b7324 100644 --- a/backend/internal/api/statistic.go +++ b/backend/internal/api/statistic.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "fmt" "github.com/labstack/echo/v4" "net/http" @@ -8,15 +9,17 @@ import ( "time" "trackly-backend/internal/models" "trackly-backend/internal/repositories" + "trackly-backend/internal/service" ) type StatisticApi struct { habitRepo *repositories.HabitRepository statisticRepo *repositories.StatisticRepository + aiService service.AiService } -func NewStatisticApi(habitRepo *repositories.HabitRepository, statisticRepo *repositories.StatisticRepository) *StatisticApi { - return &StatisticApi{habitRepo: habitRepo, statisticRepo: statisticRepo} +func NewStatisticApi(habitRepo *repositories.HabitRepository, statisticRepo *repositories.StatisticRepository, aiService service.AiService) *StatisticApi { + return &StatisticApi{habitRepo: habitRepo, statisticRepo: statisticRepo, aiService: aiService} } func (s StatisticApi) GetApiHabitsHabitIdStatistic(ctx echo.Context, habitId int, params GetApiHabitsHabitIdStatisticParams) error { @@ -46,7 +49,7 @@ func (s StatisticApi) GetApiHabitsHabitIdStatistic(ctx echo.Context, habitId int return ctx.JSON(http.StatusOK, HabitStatisticResponse{ GroupBy: params.GroupBy, Period: byDay, - PlanUnit: PlanUnit(fmt.Sprintf("%v", currentPlan.PlanUnit)), + PlanUnit: PlanUnit(*currentPlan.PlanUnit), }) case Month: return ctx.JSON(http.StatusOK, HabitStatisticResponse{ @@ -89,6 +92,52 @@ func periodsByDay(habit *models.Habit, from time.Time, to time.Time) []PeriodVal } return scores } +func (s StatisticApi) GetApiHabitsHabitIdStatisticAiComment(ctx echo.Context, habitId int, params GetApiHabitsHabitIdStatisticAiCommentParams) error { + if err := ctx.Bind(¶ms); err != nil { + return ctx.JSON(http.StatusBadRequest, map[string]interface{}{}) + } + dateFrom := params.DateFrom.Time + dateTo := params.DateTo.Time.Add(time.Hour*24 - 1) + + userId := ctx.Get("user_id").(int) + + habit, err := s.habitRepo.GetHabitWithStatInInterval(habitId, userId, dateFrom, dateTo) + if err != nil { + return ctx.JSON(http.StatusInternalServerError, map[string]interface{}{}) + } + + // сортируем + sort.Slice(habit.HabitScore, func(i, j int) bool { + return habit.HabitScore[i].DateTime.Before(habit.HabitScore[i].DateTime) + }) + currentPlan := findCurrentPlan(habit.Plans) + byDay := periodsByDay(habit, dateFrom, dateTo) + + aiReq := HabbitAiReq{ + GroupBy: Day, + Period: byDay, + PlanUnit: PlanUnit(fmt.Sprintf("%v", currentPlan.PlanUnit)), + HabitName: habit.HabitName, + } + marshaledStatistic, err := json.Marshal(aiReq) + if err != nil { + return err + } + + comment, err := s.aiService.GetStatisticComment(string(marshaledStatistic)) + if err != nil { + return err + } + + return ctx.JSON(200, HabitStatisticAiCommentResponse{Comment: &comment.Comment}) +} + +type HabbitAiReq struct { + GroupBy StatisticGroupBy `json:"groupBy"` + Period []PeriodValue `json:"period"` + PlanUnit PlanUnit `json:"planUnit"` + HabitName string `json:"habitName"` +} func periodsByMonth(habit *models.Habit, from time.Time, to time.Time) []PeriodValue { monthlySums := make(map[string]int) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 63918a2..f0cce81 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { Database DbConfig `yaml:"database"` JwtSecret string `yaml:"jwt_secret"` MinioConfig MinioConfig `yaml:"minio"` + AiConfig AiConfig `yaml:"aiConfig"` } type DbConfig struct { @@ -32,6 +33,11 @@ type MinioConfig struct { MinioUseSSL bool `yaml:"use_ssl"` } +type AiConfig struct { + Token string `yaml:"token"` + Folder string `yaml:"folder"` +} + func LoadConfig(filePath string) (*Config, error) { file, err := os.ReadFile(filePath) @@ -62,6 +68,10 @@ func LoadConfig(filePath string) (*Config, error) { MinioRootPassword: getEnv("MINIO_ROOT_PASSWORD", config.MinioConfig.MinioRootPassword), MinioUseSSL: getEnvAsBool("MINIO_USE_SSL", config.MinioConfig.MinioUseSSL), }, + AiConfig: AiConfig{ + Token: getEnv("AI_TOKEN", config.AiConfig.Token), + Folder: getEnv("AI_FOLDER", config.AiConfig.Folder), + }, } log.Printf("Loaded config: %+v", config.MinioConfig) diff --git a/backend/internal/db/minio.go b/backend/internal/db/minio.go index 0438dbc..143b760 100644 --- a/backend/internal/db/minio.go +++ b/backend/internal/db/minio.go @@ -2,6 +2,7 @@ package db import ( "context" + "fmt" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "log" @@ -24,16 +25,17 @@ func NewMinioClient(cfg *config.Config) (*MinioClient, error) { }) if err != nil { conErr = err + log.Printf("Failed to connect to MinIO (attempt %d/%d): %v", i+1, retryConnectCount, err) time.Sleep(5 * time.Second) continue } client = c conErr = nil break - } + if conErr != nil { - return nil, conErr + return nil, fmt.Errorf("failed to connect to MinIO after %d attempts: %w", retryConnectCount, conErr) } err := initMinio(context.Background(), cfg, client) diff --git a/backend/internal/service/yandex_gpt.go b/backend/internal/service/yandex_gpt.go new file mode 100644 index 0000000..49726ae --- /dev/null +++ b/backend/internal/service/yandex_gpt.go @@ -0,0 +1,113 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "trackly-backend/internal/config" +) + +type GptPrompt struct { + ModelUri string `json:"modelUri"` + CompletionOptions map[string]interface{} `json:"completionOptions"` + Messages []map[string]string `json:"messages"` +} + +type YandexGptResponse struct { + Result struct { + Alternatives []struct { + Message struct { + Role string `json:"role"` + Text string `json:"text"` + } `json:"message"` + Status string `json:"status"` + } `json:"alternatives"` + Usage struct { + InputTextTokens string `json:"inputTextTokens"` + CompletionTokens string `json:"completionTokens"` + TotalTokens string `json:"totalTokens"` + CompletionTokensDetails struct { + ReasoningTokens string `json:"reasoningTokens"` + } `json:"completionTokensDetails"` + } `json:"usage"` + ModelVersion string `json:"modelVersion"` + } `json:"result"` +} + +type GetCommentResponse struct { + Comment string `json:"comment"` +} + +type AiService interface { + GetStatisticComment(statistic string) (*GetCommentResponse, error) +} + +type YandexGptService struct { + token string + folder string + client *http.Client +} + +func NewYandexGptService(config config.AiConfig) *YandexGptService { + return &YandexGptService{config.Token, config.Folder, http.DefaultClient} +} + +const url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" + +func (y YandexGptService) GetStatisticComment(statistic string) (*GetCommentResponse, error) { + prompt := newGptPrompt(y.folder, hobbyAnalyticsComment, statistic) + + promptJson, err := json.Marshal(prompt) + if err != nil { + return nil, err + } + b := bytes.NewReader(promptJson) + request, err := http.NewRequest("POST", url, b) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", y.token)) + if err != nil { + return nil, err + } + do, err := y.client.Do(request) + if err != nil { + return nil, err + } + defer do.Body.Close() + body, err := io.ReadAll(do.Body) + + var yandexResponse YandexGptResponse + err = json.Unmarshal(body, &yandexResponse) + if err != nil { + return nil, err + } + + // Извлекаем текст комментария из первого альтернативного ответа + if len(yandexResponse.Result.Alternatives) > 0 { + comment := yandexResponse.Result.Alternatives[0].Message.Text + return &GetCommentResponse{Comment: comment}, nil + } + + return nil, fmt.Errorf("no alternatives found in response") +} + +const hobbyAnalyticsComment = "дай короткий комментарий по хобби пользователя и дай ему полезный совет по хобби, по типу: - ты молодец, продолжнай в том же духе, -стоит поднажать, ты выбиваешься из графика" + +func newGptPrompt(folder string, systemPrompt string, message string) *GptPrompt { + return &GptPrompt{ + ModelUri: fmt.Sprintf("gpt://%s/yandexgpt", folder), + CompletionOptions: map[string]interface{}{ + "stream": false, + "temperature": 0.6, + "maxTokens": 2000, + "reasoningOptions": map[string]interface{}{ + "mode": "DISABLED", + }, + }, + Messages: []map[string]string{ + {"role": "system", "text": systemPrompt}, + {"role": "user", "text": message}, + }, + } +} diff --git a/backend/logs/app.log b/backend/logs/app.log new file mode 100644 index 0000000..8baf037 --- /dev/null +++ b/backend/logs/app.log @@ -0,0 +1,2 @@ +2025/06/24 14:15:51 Loaded config: {MinioEndpoint:localhost:9000 BucketName:best-bucket MinioRootUser:root MinioRootPassword:password MinioUseSSL:false} +2025/06/24 14:15:51 Migrations applied successfully! diff --git a/backend/open-api/openapi.yaml b/backend/open-api/openapi.yaml index df55c8a..32f0107 100644 --- a/backend/open-api/openapi.yaml +++ b/backend/open-api/openapi.yaml @@ -293,6 +293,40 @@ paths: application/json: schema: $ref: '#/components/schemas/HabitStatisticTotalResponse' + /api/habits/{habitId}/statistic/ai-comment: + parameters: + - name: habitId + in: path + required: true + schema: + type: integer + - name: date-from + in: query + required: true + schema: + type: string + format: date + example: "2017-01-01" + - name: date-to + in: query + required: true + schema: + type: string + format: date + example: "2017-01-01" + get: + tags: + - Hobbies statistic + summary: Получение комметария от нейронке по статистике + security: + - BearerAuth: [ ] + responses: + '200': + description: Суммарная статистика хобби + content: + application/json: + schema: + $ref: '#/components/schemas/HabitStatisticAiCommentResponse' components: securitySchemes: @@ -497,6 +531,12 @@ components: averagePerDay: type: integer + HabitStatisticAiCommentResponse: + type: object + properties: + comment: + type: string + StatisticGroupBy: type: string enum: [ day, month, year ] diff --git a/backend/pen-test/1.http b/backend/pen-test/1.http index 384728c..f15c03e 100644 --- a/backend/pen-test/1.http +++ b/backend/pen-test/1.http @@ -4,7 +4,7 @@ POST http://localhost:8080/api/auth/register Content-Type: application/json { - "email": "ars2@mail.ru", + "email": "ars3@mail.ru", "password": "123", "username":"ars", "age":5 @@ -18,7 +18,7 @@ POST http://localhost:8080/api/auth/login Content-Type: application/json { - "email": "ars2@mail.ru", + "email": "ars3@mail.ru", "password": "123" } @@ -94,19 +94,25 @@ Authorization: Bearer {{auth_token}} ### -POST http://localhost:8080/api/habits/1/score +POST http://localhost:8080/api/habits/2/score Content-Type: application/json Authorization: Bearer {{auth_token}} { - "date": "2025-02-06T16:49:25.061Z", + "date": "2025-06-22T15:04:05Z", "value": 111 } ### ### -http://localhost:8080/api/habits/1/statistic?date-from=2025-01-30&date-to=2025-02-06&group-by=day +http://localhost:8080/api/habits/2/statistic?date-from=2025-05-30&date-to=2025-11-06&group-by=day +accept: application/json +Authorization: Bearer {{auth_token}} +### + +### +http://localhost:8080/api/habits/2/statistic/ai-comment?date-from=2025-05-30&date-to=2025-11-06&group-by=day accept: application/json Authorization: Bearer {{auth_token}} ### \ No newline at end of file diff --git a/backend/prometheus.yml b/backend/prometheus.yml new file mode 100644 index 0000000..2ff0d8c --- /dev/null +++ b/backend/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'go-app' + static_configs: + - targets: ['host.docker.internal:2112'] + - job_name: 'node' + static_configs: + - targets: ['node-exporter:9100'] \ No newline at end of file diff --git a/backend/promtail-config.yaml b/backend/promtail-config.yaml new file mode 100644 index 0000000..1312b60 --- /dev/null +++ b/backend/promtail-config.yaml @@ -0,0 +1,18 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: go-app-logs + static_configs: + - targets: + - localhost + labels: + job: go-app + __path__: /app/logs/*.log \ No newline at end of file