From 284dae9e13b95d210bb182717e8c5245a6748cba Mon Sep 17 00:00:00 2001 From: DKharchenko <DKharchenko@mtt.ru> Date: Mon, 3 Mar 2025 01:38:25 +0300 Subject: [PATCH] Add tg and notion adapters --- go.mod | 2 + go.sum | 4 + .../repository/adapter/api/cs_market.api.go | 1 - .../adapter/api/impl/cs_market.api.go | 1 - .../adapter/api/impl/lis_skins.api.go | 1 - .../repository/adapter/api/impl/nition.api.go | 1 - .../repository/adapter/api/lis_skins.api.go | 1 - internal/repository/adapter/api/nition.api.go | 1 - internal/repository/adapter/notion/notion.go | 173 ++++++++++++++++++ .../repository/adapter/notion/skin_profit.go | 147 +++++++++++++++ .../repository/adapter/notion/total_profit.go | 91 +++++++++ .../repository/adapter/telegram/parser_bot.go | 37 ++++ 12 files changed, 454 insertions(+), 6 deletions(-) delete mode 100644 internal/repository/adapter/api/cs_market.api.go delete mode 100644 internal/repository/adapter/api/impl/cs_market.api.go delete mode 100644 internal/repository/adapter/api/impl/lis_skins.api.go delete mode 100644 internal/repository/adapter/api/impl/nition.api.go delete mode 100644 internal/repository/adapter/api/lis_skins.api.go delete mode 100644 internal/repository/adapter/api/nition.api.go create mode 100644 internal/repository/adapter/notion/notion.go create mode 100644 internal/repository/adapter/notion/skin_profit.go create mode 100644 internal/repository/adapter/notion/total_profit.go create mode 100644 internal/repository/adapter/telegram/parser_bot.go diff --git a/go.mod b/go.mod index 0ae00d0..1bd56a5 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22 require ( github.com/go-playground/validator/v10 v10.20.0 + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.25.11 ) @@ -30,6 +31,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.uber.org/atomic v1.7.0 // indirect diff --git a/go.sum b/go.sum index 07adc2f..40e88fd 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -257,6 +259,8 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= diff --git a/internal/repository/adapter/api/cs_market.api.go b/internal/repository/adapter/api/cs_market.api.go deleted file mode 100644 index 778f64e..0000000 --- a/internal/repository/adapter/api/cs_market.api.go +++ /dev/null @@ -1 +0,0 @@ -package api diff --git a/internal/repository/adapter/api/impl/cs_market.api.go b/internal/repository/adapter/api/impl/cs_market.api.go deleted file mode 100644 index 4f9d22e..0000000 --- a/internal/repository/adapter/api/impl/cs_market.api.go +++ /dev/null @@ -1 +0,0 @@ -package impl diff --git a/internal/repository/adapter/api/impl/lis_skins.api.go b/internal/repository/adapter/api/impl/lis_skins.api.go deleted file mode 100644 index 4f9d22e..0000000 --- a/internal/repository/adapter/api/impl/lis_skins.api.go +++ /dev/null @@ -1 +0,0 @@ -package impl diff --git a/internal/repository/adapter/api/impl/nition.api.go b/internal/repository/adapter/api/impl/nition.api.go deleted file mode 100644 index 4f9d22e..0000000 --- a/internal/repository/adapter/api/impl/nition.api.go +++ /dev/null @@ -1 +0,0 @@ -package impl diff --git a/internal/repository/adapter/api/lis_skins.api.go b/internal/repository/adapter/api/lis_skins.api.go deleted file mode 100644 index 778f64e..0000000 --- a/internal/repository/adapter/api/lis_skins.api.go +++ /dev/null @@ -1 +0,0 @@ -package api diff --git a/internal/repository/adapter/api/nition.api.go b/internal/repository/adapter/api/nition.api.go deleted file mode 100644 index 778f64e..0000000 --- a/internal/repository/adapter/api/nition.api.go +++ /dev/null @@ -1 +0,0 @@ -package api diff --git a/internal/repository/adapter/notion/notion.go b/internal/repository/adapter/notion/notion.go new file mode 100644 index 0000000..0a433f5 --- /dev/null +++ b/internal/repository/adapter/notion/notion.go @@ -0,0 +1,173 @@ +package notion + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "sync" +) + +const ( + BaseURL = "https://api.notion.com/v1" + NotionVersion = "2022-06-28" +) + +// Client инкапсулирует HTTP-клиент Рё общие параметры для работы СЃ Notion API. +type Client struct { + HTTPClient *http.Client + APIKey string +} + +// NewClient создаёт РЅРѕРІРѕРіРѕ клиента для Notion. +// Если proxyURL РЅРµ пустой, то РѕРЅ используется для проксирования запросов. +func NewClient(apiKey, proxyURL string) (*Client, error) { + transport := &http.Transport{} + if proxyURL != "" { + proxy, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + transport.Proxy = http.ProxyURL(proxy) + } + client := &http.Client{ + Transport: transport, + } + return &Client{ + HTTPClient: client, + APIKey: apiKey, + }, nil +} + +// QueryDatabase выполняет запрос Рє базе данных Notion. +// queryBody может быть nil – РІ этом случае отправляется пустой JSON-объект. +func (c *Client) QueryDatabase(databaseID string, queryBody interface{}) ([]byte, error) { + queryURL := fmt.Sprintf("%s/databases/%s/query", BaseURL, databaseID) + var bodyBytes []byte + if queryBody == nil { + bodyBytes = []byte("{}") + } else { + b, err := json.Marshal(queryBody) + if err != nil { + return nil, err + } + bodyBytes = b + } + req, err := http.NewRequest("POST", queryURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Notion-Version", NotionVersion) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to query database, status: %d, body: %s", resp.StatusCode, string(b)) + } + + return ioutil.ReadAll(resp.Body) +} + +// PatchPage выполняет PATCH-запрос для обновления страницы СЃ указанными свойствами. +func (c *Client) PatchPage(pageID string, properties map[string]interface{}) error { + updateURL := fmt.Sprintf("%s/pages/%s", BaseURL, pageID) + updateBody := map[string]interface{}{ + "properties": properties, + } + bodyBytes, err := json.Marshal(updateBody) + if err != nil { + return err + } + req, err := http.NewRequest("PATCH", updateURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Notion-Version", NotionVersion) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := ioutil.ReadAll(resp.Body) + return fmt.Errorf("failed to patch page, status: %d, body: %s", resp.StatusCode, string(b)) + } + return nil +} + +// LimitedBatchUpdate выполняет пакетное обновление СЃ ограничением количества одновременных запросов. +func (c *Client) LimitedBatchUpdate(tasks []func() error, concurrencyLimit int) error { + semaphore := make(chan struct{}, concurrencyLimit) + var wg sync.WaitGroup + errChan := make(chan error, len(tasks)) + + for _, task := range tasks { + wg.Add(1) + go func(t func() error) { + defer wg.Done() + semaphore <- struct{}{} + if err := t(); err != nil { + errChan <- err + } + <-semaphore + }(task) + } + wg.Wait() + close(errChan) + var err error + for e := range errChan { + if err == nil { + err = e + } else { + err = fmt.Errorf("%v; %w", err, e) + } + } + return err +} + +// Определения для разбора ответа Notion. + +type RichText struct { + Text struct { + Content string `json:"content"` + } `json:"text"` +} + +type StatusProp struct { + Name string `json:"name"` +} + +type DateProp struct { + Start string `json:"start"` +} + +type Property struct { + Type string `json:"type"` + Number *float64 `json:"number,omitempty"` + Title []RichText `json:"title,omitempty"` + Status *StatusProp `json:"status,omitempty"` + Date *DateProp `json:"date,omitempty"` + URL *string `json:"url,omitempty"` +} + +type Page struct { + ID string `json:"id"` + Properties map[string]Property `json:"properties"` +} + +type QueryResponse struct { + Results []Page `json:"results"` +} diff --git a/internal/repository/adapter/notion/skin_profit.go b/internal/repository/adapter/notion/skin_profit.go new file mode 100644 index 0000000..c2c9331 --- /dev/null +++ b/internal/repository/adapter/notion/skin_profit.go @@ -0,0 +1,147 @@ +package notion + +import ( + "encoding/json" + "log" +) + +// SkinDocument представляет данные записи РёР· таблицы SkinProfit. +type SkinDocument struct { + ID string + Name string + BuyPrice float64 + CurrentPrice float64 + Status string + CurrentAutobuyPrice float64 + SellPrice float64 + SellDate string + CsmUrl string + PreliminaryProfit float64 + PreliminaryAutobuyProfit float64 + Profit float64 +} + +// SkinProfitRepository реализует работу СЃ таблицей SkinProfit. +type SkinProfitRepository struct { + notionClient *Client + DatabaseID string +} + +// NewSkinProfitRepository создаёт новый репозиторий для работы СЃ таблицей SkinProfit. +func NewSkinProfitRepository(apiKey, skinsDatabaseID, proxyURL string) (*SkinProfitRepository, error) { + client, err := NewClient(apiKey, proxyURL) + if err != nil { + return nil, err + } + return &SkinProfitRepository{ + notionClient: client, + DatabaseID: skinsDatabaseID, + }, nil +} + +// Find выполняет запрос Рє базе данных СЃРєРёРЅРѕРІ Рё маппит полученные данные РІ срез структур SkinDocument. +func (r *SkinProfitRepository) Find() ([]SkinDocument, error) { + respBytes, err := r.notionClient.QueryDatabase(r.DatabaseID, nil) + if err != nil { + return nil, err + } + + var queryResp QueryResponse + if err := json.Unmarshal(respBytes, &queryResp); err != nil { + return nil, err + } + + var skins []SkinDocument + for _, page := range queryResp.Results { + var skin SkinDocument + skin.ID = page.ID + + // Рзвлекаем название РёР· свойства "Название" + if prop, ok := page.Properties["Название"]; ok && len(prop.Title) > 0 { + skin.Name = prop.Title[0].Text.Content + } + // Рзвлекаем числовые значения + if prop, ok := page.Properties["РљСѓРїРёР»"]; ok && prop.Number != nil { + skin.BuyPrice = *prop.Number + } + if prop, ok := page.Properties["Текущая стоимость"]; ok && prop.Number != nil { + skin.CurrentPrice = *prop.Number + } + if prop, ok := page.Properties["Текущий автобай"]; ok && prop.Number != nil { + skin.CurrentAutobuyPrice = *prop.Number + } + if prop, ok := page.Properties["Продажа"]; ok && prop.Number != nil { + skin.SellPrice = *prop.Number + } + // Дата продажи + if prop, ok := page.Properties["Дата продажи"]; ok && prop.Date != nil { + skin.SellDate = prop.Date.Start + } + // URL для CSGOMARKET + if prop, ok := page.Properties["CSGOMARKET URL"]; ok && prop.URL != nil { + skin.CsmUrl = *prop.URL + } + // Предварительный профит + if prop, ok := page.Properties["Предварительный профит"]; ok && prop.Number != nil { + skin.PreliminaryProfit = *prop.Number + } + // Предварительный профит РїРѕ автобаю + if prop, ok := page.Properties["Предварительный профит РїРѕ автобаю"]; ok && prop.Number != nil { + skin.PreliminaryAutobuyProfit = *prop.Number + } + // Фактический профит + if prop, ok := page.Properties["Фактический профит"]; ok && prop.Number != nil { + skin.Profit = *prop.Number + } + + skins = append(skins, skin) + } + + return skins, nil +} + +// SkinUpdate содержит данные для обновления страницы СЃРєРёРЅР°. +type SkinUpdate struct { + ID string + BuyPrice float64 // Для логирования; РЅРµ обновляется + Price float64 // Обновление "Текущая стоимость" + AutobuyPrice float64 // Обновление "Текущий автобай" + PreliminaryProfit float64 // Обновление "Предварительный профит" + PreliminaryAutobuyProfit float64 // Обновление "Предварительный профит РїРѕ автобаю" +} + +// mapPropertiesToNotionSkin формирует JSON-представление обновляемых свойств для СЃРєРёРЅР°. +func mapPropertiesToNotionSkin(s SkinUpdate) map[string]interface{} { + properties := make(map[string]interface{}) + properties["Текущая стоимость"] = map[string]interface{}{ + "number": s.Price, + } + properties["Текущий автобай"] = map[string]interface{}{ + "number": s.AutobuyPrice, + } + properties["Предварительный профит"] = map[string]interface{}{ + "number": s.PreliminaryProfit, + } + properties["Предварительный профит РїРѕ автобаю"] = map[string]interface{}{ + "number": s.PreliminaryAutobuyProfit, + } + return properties +} + +// BatchUpdatePages выполняет пакетное обновление страниц СЃРєРёРЅРѕРІ СЃ ограничением одновременных запросов (РґРѕ 3-С…). +func (r *SkinProfitRepository) BatchUpdatePages(updatedSkins []SkinUpdate) error { + var tasks []func() error + for _, skin := range updatedSkins { + skinCopy := skin // избегаем проблем СЃ замыканиями + tasks = append(tasks, func() error { + props := mapPropertiesToNotionSkin(skinCopy) + err := r.notionClient.PatchPage(skinCopy.ID, props) + if err != nil { + log.Printf("Error updating page %s: %v", skinCopy.ID, err) + } + return err + }) + } + // Ограничиваем выполнение РґРѕ 3 одновременных запросов. + return r.notionClient.LimitedBatchUpdate(tasks, 3) +} diff --git a/internal/repository/adapter/notion/total_profit.go b/internal/repository/adapter/notion/total_profit.go new file mode 100644 index 0000000..b4819ab --- /dev/null +++ b/internal/repository/adapter/notion/total_profit.go @@ -0,0 +1,91 @@ +package notion + +import ( + "log" + "time" +) + +// TotalProfit представляет данные итогового профита. +type TotalProfit struct { + ID string `json:"id"` + PreliminaryProfit float64 `json:"preliminaryProfit"` + PreliminaryAutobuyProfit float64 `json:"preliminaryAutobuyProfit"` + Profit float64 `json:"profit"` + Invested float64 `json:"invested"` + SyncAt time.Time `json:"syncAt"` +} + +// TotalProfitRepository реализует операции для таблицы TotalProfit. +type TotalProfitRepository struct { + notionClient *Client + databaseID string +} + +// NewTotalProfitRepository создаёт репозиторий для работы СЃ таблицей TotalProfit. +// proxyURL используется для настройки РїСЂРѕРєСЃРё, если РѕРЅ задан. +func NewTotalProfitRepository(apiKey, databaseID, proxyURL string) (*TotalProfitRepository, error) { + client, err := NewClient(apiKey, proxyURL) + if err != nil { + return nil, err + } + return &TotalProfitRepository{ + notionClient: client, + databaseID: databaseID, + }, nil +} + +// TotalProfitUpdate содержит данные для обновления страницы итогового профита. +type TotalProfitUpdate struct { + ID string + PreliminaryProfit float64 + PreliminaryAutobuyProfit float64 + Profit float64 + Invested float64 + SyncAt time.Time +} + +// mapPropertiesToNotionTotalProfit формирует свойства для обновления страницы РІ Notion. +func mapPropertiesToNotionTotalProfit(data TotalProfitUpdate) map[string]interface{} { + properties := make(map[string]interface{}) + properties["Предварительный итог"] = map[string]interface{}{ + "number": data.PreliminaryProfit, + } + properties["Предварительный итог РїРѕ автобаю"] = map[string]interface{}{ + "number": data.PreliminaryAutobuyProfit, + } + properties["Фактический итог"] = map[string]interface{}{ + "number": data.Profit, + } + properties["Вложил"] = map[string]interface{}{ + "number": data.Invested, + } + properties["Дата последней синхронизации"] = map[string]interface{}{ + "date": map[string]interface{}{ + "start": data.SyncAt.Format(time.RFC3339), + }, + } + return properties +} + +// UpdateFullProfit обновляет страницу итогового профита РІ Notion. +func (r *TotalProfitRepository) UpdateFullProfit(data TotalProfitUpdate) error { + props := mapPropertiesToNotionTotalProfit(data) + return r.notionClient.PatchPage(data.ID, props) +} + +// BatchUpdatePages выполняет пакетное обновление страниц СЃ ограничением количества одновременных запросов. +func (r *TotalProfitRepository) BatchUpdatePages(updated []TotalProfitUpdate, concurrencyLimit int) error { + var tasks []func() error + for _, upd := range updated { + updCopy := upd + tasks = append(tasks, func() error { + props := mapPropertiesToNotionTotalProfit(updCopy) + err := r.notionClient.PatchPage(updCopy.ID, props) + if err != nil { + log.Printf("Error updating page %s: %v", updCopy.ID, err) + } + return err + }) + } + return r.notionClient.LimitedBatchUpdate(tasks, concurrencyLimit) +} diff --git a/internal/repository/adapter/telegram/parser_bot.go b/internal/repository/adapter/telegram/parser_bot.go new file mode 100644 index 0000000..93d5e8e --- /dev/null +++ b/internal/repository/adapter/telegram/parser_bot.go @@ -0,0 +1,37 @@ +package telegram + +import ( + "log" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" +) + +// Adapter инкапсулирует работу СЃ Adapter Bot API +type Adapter struct { + Bot *tgbotapi.BotAPI + ChatID int64 +} + +// NewTelegram создаёт экземпляр Adapter +func NewAdapter(botToken string, chatID int64) (*Adapter, error) { + bot, err := tgbotapi.NewBotAPI(botToken) + if err != nil { + return nil, err + } + return &Adapter{ + Bot: bot, + ChatID: chatID, + }, nil +} + +// CreatePost отправляет сообщение РІ указанный Adapter чат +func (r *Adapter) CreatePost(text string) error { + msg := tgbotapi.NewMessage(r.ChatID, text) + + _, err := r.Bot.Send(msg) + if err != nil { + log.Printf("Ошибка отправки сообщения: %v", err) + return err + } + return nil +} -- GitLab