From 2ee1837fcc9868b756824c315a916a28be3aca8c Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Tue, 27 Jan 2026 20:27:06 +0300 Subject: [PATCH] Renamed and updated project. --- .gitignore | 4 +- docker-compose.yml | 15 +++ go.mod | 28 ++--- migrations/000001_create_users.down.sql | 1 + migrations/000001_create_users.up.sql | 13 +++ .../000002_create_telegram_chats.down.sql | 1 + .../000002_create_telegram_chats.up.sql | 9 ++ migrations/000003_add_indexes.down.sql | 2 - migrations/000003_add_indexes.up.sql | 2 - migrations/000003_create_families.down.sql | 1 + migrations/000003_create_families.up.sql | 12 ++ .../000004_create_family_members.down.sql | 2 + .../000004_create_family_members.up.sql | 26 +++++ migrations/000005_create_threads.down.sql | 2 + migrations/000005_create_threads.up.sql | 27 +++++ ...wn.sql => 000006_create_receipts.down.sql} | 0 ...s.up.sql => 000006_create_receipts.up.sql} | 25 +--- ...n.sql => 000007_create_positions.down.sql} | 0 ....up.sql => 000007_create_positions.up.sql} | 16 +-- response.json | 31 ----- src/api/handlers/hello.go | 37 ------ src/api/handlers/receipts.go | 32 +++-- src/api/server.go | 18 +-- src/bot/bot.go | 6 +- src/bot/handlers.go | 11 +- src/database/database.go | 109 ++++++++++++++++-- src/database/migrations.go | 32 ----- src/database/postgresql.go | 65 +++++++++++ src/database/sqlite.go | 43 ++++++- src/domain/models/family.go | 67 +++++++++++ src/integrations/receiptApi/http_client.go | 5 +- .../receiptService/receipt_service.go | 21 ++-- {cmd => src}/main.go | 6 +- src/repositories/receipt_repository.go | 2 +- src/repositories/receipt_sql.go | 31 ++--- {cmd => src}/runtime.go | 0 src/utils/date.go | 2 - src/utils/qr_parser.go | 30 +++++ 38 files changed, 499 insertions(+), 235 deletions(-) create mode 100644 docker-compose.yml create mode 100644 migrations/000001_create_users.down.sql create mode 100644 migrations/000001_create_users.up.sql create mode 100644 migrations/000002_create_telegram_chats.down.sql create mode 100644 migrations/000002_create_telegram_chats.up.sql delete mode 100644 migrations/000003_add_indexes.down.sql delete mode 100644 migrations/000003_add_indexes.up.sql create mode 100644 migrations/000003_create_families.down.sql create mode 100644 migrations/000003_create_families.up.sql create mode 100644 migrations/000004_create_family_members.down.sql create mode 100644 migrations/000004_create_family_members.up.sql create mode 100644 migrations/000005_create_threads.down.sql create mode 100644 migrations/000005_create_threads.up.sql rename migrations/{000001_create_receipts.down.sql => 000006_create_receipts.down.sql} (100%) rename migrations/{000001_create_receipts.up.sql => 000006_create_receipts.up.sql} (76%) rename migrations/{000002_create_positions.down.sql => 000007_create_positions.down.sql} (100%) rename migrations/{000002_create_positions.up.sql => 000007_create_positions.up.sql} (59%) delete mode 100644 response.json delete mode 100644 src/api/handlers/hello.go delete mode 100644 src/database/migrations.go create mode 100644 src/database/postgresql.go create mode 100644 src/domain/models/family.go rename {cmd => src}/main.go (90%) rename {cmd => src}/runtime.go (100%) create mode 100644 src/utils/qr_parser.go diff --git a/.gitignore b/.gitignore index 9e4c0f0..b0e0904 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ .idea .env secret_key.json -data \ No newline at end of file +data +archive +volumes \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9f6de88 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.9' + +services: + db: + image: postgres:16 + container_name: postgres + restart: always + environment: + POSTGRES_USER: familyUser + POSTGRES_PASSWORD: familyPass + POSTGRES_DB: familyHubDB + ports: + - "5432:5432" + volumes: + - ./volumes/postgres:/var/lib/postgresql/data diff --git a/go.mod b/go.mod index 34fd5b5..80ddafd 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module GoFinanceManager +module FamilyHub go 1.25 @@ -8,7 +8,9 @@ require ( github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/joho/godotenv v1.5.1 - github.com/mattn/go-sqlite3 v1.14.33 + github.com/lib/pq v1.10.9 + github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea + github.com/mattn/go-sqlite3 v1.14.34 github.com/stretchr/testify v1.11.1 ) @@ -19,19 +21,20 @@ require ( cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/vision/v2 v2.9.5 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -40,36 +43,33 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + 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.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.38.0 // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/migrations/000001_create_users.down.sql b/migrations/000001_create_users.down.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/migrations/000001_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/000001_create_users.up.sql b/migrations/000001_create_users.up.sql new file mode 100644 index 0000000..9c4e28d --- /dev/null +++ b/migrations/000001_create_users.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE users +( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT UNIQUE NOT NULL, + username TEXT, + first_name TEXT NOT NULL, + last_name TEXT, + language_code TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_telegram_id ON users (telegram_id); \ No newline at end of file diff --git a/migrations/000002_create_telegram_chats.down.sql b/migrations/000002_create_telegram_chats.down.sql new file mode 100644 index 0000000..edce235 --- /dev/null +++ b/migrations/000002_create_telegram_chats.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS telegram_chats; \ No newline at end of file diff --git a/migrations/000002_create_telegram_chats.up.sql b/migrations/000002_create_telegram_chats.up.sql new file mode 100644 index 0000000..4b6110b --- /dev/null +++ b/migrations/000002_create_telegram_chats.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE telegram_chats +( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT UNIQUE NOT NULL, + title TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_telegram_chats_telegram_id ON telegram_chats (telegram_id); \ No newline at end of file diff --git a/migrations/000003_add_indexes.down.sql b/migrations/000003_add_indexes.down.sql deleted file mode 100644 index e54f170..0000000 --- a/migrations/000003_add_indexes.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX idx_positions_receipt_id; -DROP INDEX idx_receipts_issued_at; diff --git a/migrations/000003_add_indexes.up.sql b/migrations/000003_add_indexes.up.sql deleted file mode 100644 index 555db8a..0000000 --- a/migrations/000003_add_indexes.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX idx_receipts_issued_at ON receipts(issued_at); -CREATE INDEX idx_positions_receipt_id ON positions(receipt_id); diff --git a/migrations/000003_create_families.down.sql b/migrations/000003_create_families.down.sql new file mode 100644 index 0000000..1cc7b39 --- /dev/null +++ b/migrations/000003_create_families.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS families; \ No newline at end of file diff --git a/migrations/000003_create_families.up.sql b/migrations/000003_create_families.up.sql new file mode 100644 index 0000000..25a32ac --- /dev/null +++ b/migrations/000003_create_families.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE families +( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + owner_id BIGINT NOT NULL REFERENCES users (id), + telegram_chat_id BIGINT NOT NULL UNIQUE REFERENCES telegram_chats (id), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_families_owner_id ON families (owner_id); + +CREATE UNIQUE INDEX idx_families_chat_id ON families (telegram_chat_id); \ No newline at end of file diff --git a/migrations/000004_create_family_members.down.sql b/migrations/000004_create_family_members.down.sql new file mode 100644 index 0000000..fe8f595 --- /dev/null +++ b/migrations/000004_create_family_members.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS family_members; +DROP TYPE IF EXISTS family_role; \ No newline at end of file diff --git a/migrations/000004_create_family_members.up.sql b/migrations/000004_create_family_members.up.sql new file mode 100644 index 0000000..973bf2b --- /dev/null +++ b/migrations/000004_create_family_members.up.sql @@ -0,0 +1,26 @@ +CREATE TYPE family_role AS ENUM ( + 'owner', + 'admin', + 'member', + 'child' +); + +CREATE TABLE family_members +( + id BIGSERIAL PRIMARY KEY, + family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + role family_role NOT NULL DEFAULT 'member', + joined_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (family_id, user_id) +); + + +-- быстрый поиск всех членов семьи +CREATE INDEX idx_family_members_family_id ON family_members (family_id); + +-- быстрый поиск всех семей пользователя +CREATE INDEX idx_family_members_user_id ON family_members (user_id); + +-- composite для частых join’ов +CREATE INDEX idx_family_members_user_family ON family_members (user_id, family_id); \ No newline at end of file diff --git a/migrations/000005_create_threads.down.sql b/migrations/000005_create_threads.down.sql new file mode 100644 index 0000000..32fdf42 --- /dev/null +++ b/migrations/000005_create_threads.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS threads; +DROP TYPE IF EXISTS thread_type; \ No newline at end of file diff --git a/migrations/000005_create_threads.up.sql b/migrations/000005_create_threads.up.sql new file mode 100644 index 0000000..cc0d8c1 --- /dev/null +++ b/migrations/000005_create_threads.up.sql @@ -0,0 +1,27 @@ +CREATE TYPE thread_type AS ENUM ( + 'expenses', + 'movies', + 'schedule', + 'recipes', + 'custom' +); + +CREATE TABLE threads +( + id BIGSERIAL PRIMARY KEY, + family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE, + type thread_type NOT NULL, + title TEXT NOT NULL, + telegram_topic_id BIGINT NOT NULL, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT NOT NULL REFERENCES users (id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + UNIQUE (family_id, telegram_topic_id) +); + +CREATE UNIQUE INDEX idx_unique_system_threads ON threads (family_id, type) WHERE is_system = TRUE; + +CREATE INDEX idx_threads_family_id ON threads (family_id); + +CREATE INDEX idx_threads_family_type ON threads(family_id, type); diff --git a/migrations/000001_create_receipts.down.sql b/migrations/000006_create_receipts.down.sql similarity index 100% rename from migrations/000001_create_receipts.down.sql rename to migrations/000006_create_receipts.down.sql diff --git a/migrations/000001_create_receipts.up.sql b/migrations/000006_create_receipts.up.sql similarity index 76% rename from migrations/000001_create_receipts.up.sql rename to migrations/000006_create_receipts.up.sql index c382c28..acabcf2 100644 --- a/migrations/000001_create_receipts.up.sql +++ b/migrations/000006_create_receipts.up.sql @@ -1,52 +1,35 @@ CREATE TABLE receipts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - - -- основные поля + id BIGSERIAL PRIMARY KEY, receipt_number TEXT NOT NULL UNIQUE, ui TEXT NOT NULL, - status INTEGER NOT NULL, - issued_at TIMESTAMP NOT NULL, - - -- суммы total_amount REAL NOT NULL, payment_amount REAL NOT NULL, cash_amount REAL NOT NULL, another_amount REAL NOT NULL, clearing_amount REAL NOT NULL, margin REAL NOT NULL, - currency TEXT NOT NULL, payment_type INTEGER NOT NULL, - - -- касса / продавец cashbox_number INTEGER NOT NULL, cashier TEXT, - - -- организация / адрес name_spd TEXT, name_to TEXT, name_np TEXT, type_np TEXT, - street_to TEXT, house_to TEXT, - - -- SOATO (nullable) kod_soato TEXT, oblast_soato TEXT, rayon_soato TEXT, selsovet_soato TEXT, - - -- прочее doc_num TEXT, skno_number TEXT, unp TEXT, - success TEXT, - - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT NOW() ); + +CREATE INDEX idx_receipts_issued_at ON receipts(issued_at); \ No newline at end of file diff --git a/migrations/000002_create_positions.down.sql b/migrations/000007_create_positions.down.sql similarity index 100% rename from migrations/000002_create_positions.down.sql rename to migrations/000007_create_positions.down.sql diff --git a/migrations/000002_create_positions.up.sql b/migrations/000007_create_positions.up.sql similarity index 59% rename from migrations/000002_create_positions.up.sql rename to migrations/000007_create_positions.up.sql index da33535..84de90e 100644 --- a/migrations/000002_create_positions.up.sql +++ b/migrations/000007_create_positions.up.sql @@ -1,25 +1,19 @@ CREATE TABLE positions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - - receipt_id INTEGER NOT NULL, - + id BIGSERIAL PRIMARY KEY, + receipt_id BIGINT NOT NULL, section_number TEXT, gtin_code TEXT, - product_name TEXT NOT NULL, - product_count REAL NOT NULL, amount REAL NOT NULL, - discount REAL, surcharge REAL, - tag TEXT, marking_code TEXT, ukz_code TEXT, - FOREIGN KEY (receipt_id) - REFERENCES receipts (id) - ON DELETE CASCADE + FOREIGN KEY (receipt_id) REFERENCES receipts (id) ON DELETE CASCADE ); + +CREATE INDEX idx_positions_receipt_id ON positions (receipt_id); \ No newline at end of file diff --git a/response.json b/response.json deleted file mode 100644 index a969d77..0000000 --- a/response.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "STATUS": 1, - "another_amount": 0, - "cash_amount": 0, - "cashbox_number": 119066664, - "cashier": "Замена Магазин 3", - "clearing_amount": 13.54, - "currency": "BYN", - "doc_num": "153896", - "house_to": "11, 3 этаж", - "issued_at": "21/01/2026, 21:15:20", - "kod_soato": "5000000000", - "margin": 0, - "name_np": "Минск", - "name_spd": "Общество с ограниченной ответственностью \"СМАРТОН\"", - "name_to": "\"Офистон Маркет\", г.Минск, ул.Петра Мстиславца, 11", - "oblast_soato": null, - "payment_amount": 13.54, - "payment_type": 1, - "positions": "[{\"section_number\": \"0\", \"gtin_code\": \"8801068922011\", \"product_count\": \"1.000\", \"amount\": \"7.60\", \"discount\": \"0.00\", \"surcharge\": \"0.00\", \"tag\": \"0\", \"marking_code\": \"None\", \"ukz_code\": \"None\", \"product_name\": \"\\u041d\\u0430\\u043f\\u0438\\u0442\\u043e\\u043a \\\"Samlip\\\" 230 \\u043c\\u043b., \\u0441\\u043e \\u0432\\u043a\\u0443\\u0441\\u043e\\u043c \\u043b\\u0438\\u0447\\u0438\"}, {\"section_number\": \"0\", \"gtin_code\": \"4606008517920\", \"product_count\": \"1.000\", \"amount\": \"5.94\", \"discount\": \"0.00\", \"surcharge\": \"0.00\", \"tag\": \"0\", \"marking_code\": \"None\", \"ukz_code\": \"None\", \"product_name\": \"\\u0421\\u0442\\u0438\\u043a\\u0435\\u0440\\u044b \\u0434/\\u0437\\u0430\\u043c\\u0435\\u0442\\u043e\\u043a \\u0431\\u0443\\u043c\\u0430\\u0436\\u043d\\u044b\\u0435 \\\"\\u0421\\u0435\\u0440\\u0434\\u0446\\u0435\\\" 80 \\u0448\\u0442., \\u0444\\u0438\\u0433\\u0443\\u0440\\u043d\"}]", - "rayon_soato": null, - "receipt_number": "C0AD964BD53AC59A0718D028", - "selsovet_soato": null, - "skno_number": "AVQ24170087307", - "street_to": "УЛ. ПЕТРА МСТИСЛАВЦА", - "success": "A check is correct", - "total_amount": 13.54, - "type_np": "г.", - "ui": "C0AD964BD53AC59A0718D028", - "unp": "190635842" -} diff --git a/src/api/handlers/hello.go b/src/api/handlers/hello.go deleted file mode 100644 index 9c25bbe..0000000 --- a/src/api/handlers/hello.go +++ /dev/null @@ -1,37 +0,0 @@ -package handlers - -import ( - "net/http" - - "GoFinanceManager/src/api/dto" - - "github.com/gin-gonic/gin" -) - -// Hello GoDoc -// @Summary Say hello -// @Description Returns greeting -// @Tags hello -// @Accept JSON -// @Produce JSON -// @Param name query string true "User name" -// @Success 200 {object} dto.HelloResponse -// @Failure 400 {object} dto.ErrorResponse -// @Router /hello [get] -func Hello(c *gin.Context) { - var req dto.HelloRequest - - // биндинг + валидация - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{ - Message: err.Error(), - }) - return - } - - resp := dto.HelloResponse{ - Message: "Hello " + req.Name, - } - - c.JSON(http.StatusOK, resp) -} diff --git a/src/api/handlers/receipts.go b/src/api/handlers/receipts.go index faf6d81..b6f6001 100644 --- a/src/api/handlers/receipts.go +++ b/src/api/handlers/receipts.go @@ -1,9 +1,9 @@ package handlers import ( - "GoFinanceManager/src/api/dto" - "GoFinanceManager/src/integrations/receiptService" - "GoFinanceManager/src/utils" + "FamilyHub/src/api/dto" + "FamilyHub/src/integrations/receiptService" + "FamilyHub/src/utils" "context" "log" "net/http" @@ -20,30 +20,26 @@ func NewReceiptHandler(s *receiptService.ReceiptService) *ReceiptHandler { return &ReceiptHandler{service: s} } -func (h *ReceiptHandler) AddReceipt(c *gin.Context) { +func (handler *ReceiptHandler) AddReceipt(context_ *gin.Context) { var req dto.AddReceiptRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := context_.ShouldBindJSON(&req); err != nil { log.Println("bind error:", err) - c.JSON(http.StatusBadRequest, dto.ErrorResponse{ - Message: err.Error(), - }) + context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } isoDate, err := utils.NormalizeDateToISO(req.Date) if err != nil { - c.JSON(400, gin.H{"error": "invalid date format"}) + context_.JSON(400, gin.H{"error": "invalid date format"}) return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - receipt, err := h.service.GetReceipt( - ctx, - isoDate, - req.Number, - ) + + receipt, err := handler.service.GetReceipt(ctx, isoDate, req.Number) if err != nil { - c.JSON(400, gin.H{"error": "Cant get receipt"}) - log.Print(err.Error()) + context_.JSON(400, gin.H{"error": err.Error()}) + log.Printf("API error, %s", err.Error()) return } @@ -52,5 +48,5 @@ func (h *ReceiptHandler) AddReceipt(c *gin.Context) { Number: receipt.ReceiptNumber, Date: receipt.IssuedAt, } - c.JSON(http.StatusOK, resp) + context_.JSON(http.StatusOK, resp) } diff --git a/src/api/server.go b/src/api/server.go index 9a36ddd..7ab5bcf 100644 --- a/src/api/server.go +++ b/src/api/server.go @@ -1,11 +1,11 @@ package api import ( - "GoFinanceManager/src/api/handlers" - "GoFinanceManager/src/config" - "GoFinanceManager/src/database" - "GoFinanceManager/src/integrations/receiptService" - "GoFinanceManager/src/repositories" + "FamilyHub/src/api/handlers" + "FamilyHub/src/config" + "FamilyHub/src/database" + "FamilyHub/src/integrations/receiptService" + "FamilyHub/src/repositories" "context" "log" "net/http" @@ -19,12 +19,16 @@ type Server struct { func NewServer(cfg config.Config) *Server { handler := gin.Default() - dbConn, err := database.Connect(cfg.DBConnectionString) + dbManager := &database.Database{ + ConnectionString: cfg.DBConnectionString, + MigrationsPath: "file://migrations", + } + dbConn, err := dbManager.Connect() if err != nil { log.Fatal(err) } - if err := database.RunMigrations(dbConn); err != nil { + if err := dbManager.RunMigrations(dbConn); err != nil { log.Fatal(err) } diff --git a/src/bot/bot.go b/src/bot/bot.go index 07e4134..b73de9a 100644 --- a/src/bot/bot.go +++ b/src/bot/bot.go @@ -1,9 +1,9 @@ package bot import ( - "GoFinanceManager/src/config" - "GoFinanceManager/src/integrations/ocr" - "GoFinanceManager/src/integrations/receiptApi" + "FamilyHub/src/config" + "FamilyHub/src/integrations/ocr" + "FamilyHub/src/integrations/receiptApi" "context" "log" "time" diff --git a/src/bot/handlers.go b/src/bot/handlers.go index eace3a0..7a91e03 100644 --- a/src/bot/handlers.go +++ b/src/bot/handlers.go @@ -1,8 +1,8 @@ package bot import ( - "GoFinanceManager/src/integrations/receiptApi" - "GoFinanceManager/src/utils" + "FamilyHub/src/integrations/receiptApi" + "FamilyHub/src/utils" "context" "io" "log" @@ -13,7 +13,6 @@ import ( ) func (bot *Bot) handleMessage(msg *tgbotapi.Message) { - println(msg.Text) switch msg.Text { case "/start": bot.handleStart(msg) @@ -26,7 +25,6 @@ func (bot *Bot) handleMessage(msg *tgbotapi.Message) { } func (bot *Bot) handlePhoto(msg *tgbotapi.Message) { - // Берём самое большое фото photo := msg.Photo[len(msg.Photo)-1] file, err := bot.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID}) @@ -68,9 +66,12 @@ func (bot *Bot) handlePhoto(msg *tgbotapi.Message) { Date: receiptMeta.Date, } - ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() + txt, err := utils.DecodeQR(imageBytes) + println(txt) + err = bot.receiptApi.SendReceipt(ctx, payload) reply := "📄 *Результат распознавания*\n\n" diff --git a/src/database/database.go b/src/database/database.go index a0af814..60c50e1 100644 --- a/src/database/database.go +++ b/src/database/database.go @@ -2,22 +2,117 @@ package database import ( "database/sql" + "errors" "fmt" "net/url" + "os" + "path/filepath" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" + + _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" ) -func Connect(dsn string) (*sql.DB, error) { - u, err := url.Parse(dsn) - if err != nil { - return nil, err +type Database struct { + ConnectionString string + MigrationsPath string + MaxOpenConns int + MaxIdleConns int +} + +func (d *Database) Connect() (*sql.DB, error) { + u, _ := url.Parse(d.ConnectionString) + if u == nil { + return nil, errors.New("nil url") } switch u.Scheme { - case "sqlite": - return connectSQLite(u) + case "sqlite", "sqlite3": + path := filepath.Join(u.Host, u.Path) + if path == "" { + return nil, fmt.Errorf("empty sqlite path") + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, err + } + + if d.MaxOpenConns == 0 { + d.MaxOpenConns = 1 + } + db.SetMaxOpenConns(d.MaxOpenConns) + return db, nil + + case "postgres", "postgresql": + db, err := sql.Open("postgres", u.String()) + if err != nil { + return nil, err + } + + if d.MaxOpenConns == 0 { + d.MaxOpenConns = 20 + } + if d.MaxIdleConns == 0 { + d.MaxIdleConns = 10 + } + + db.SetMaxOpenConns(d.MaxOpenConns) + db.SetMaxIdleConns(d.MaxIdleConns) + return db, nil + default: - return nil, fmt.Errorf("unsupported db scheme: %s", u.Scheme) + return nil, fmt.Errorf("unsupported database scheme: %s", u.Scheme) } } + +func (d *Database) RunMigrations(db *sql.DB) error { + u, _ := url.Parse(d.ConnectionString) + if u == nil { + return errors.New("nil url") + } + + if db == nil { + return errors.New("nil db") + } + + if d.MigrationsPath == "" { + d.MigrationsPath = "file://migrations" + } + + var m *migrate.Migrate + + switch u.Scheme { + case "sqlite", "sqlite3": + driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) + if err != nil { + return err + } + m, err = migrate.NewWithDatabaseInstance(d.MigrationsPath, "sqlite", driver) + + case "postgres", "postgresql": + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return err + } + m, err = migrate.NewWithDatabaseInstance(d.MigrationsPath, "postgres", driver) + + default: + return fmt.Errorf("unsupported database scheme for migrations: %s", u.Scheme) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migration failed: %w", err) + } + + return nil +} diff --git a/src/database/migrations.go b/src/database/migrations.go deleted file mode 100644 index f461b90..0000000 --- a/src/database/migrations.go +++ /dev/null @@ -1,32 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/sqlite3" - _ "github.com/golang-migrate/migrate/v4/source/file" -) - -func RunMigrations(db *sql.DB) error { - driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) - if err != nil { - return err - } - - m, err := migrate.NewWithDatabaseInstance( - "file://migrations", - "sqlite", - driver, - ) - if err != nil { - return err - } - - if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return err - } - - return nil -} diff --git a/src/database/postgresql.go b/src/database/postgresql.go new file mode 100644 index 0000000..312a8a3 --- /dev/null +++ b/src/database/postgresql.go @@ -0,0 +1,65 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + "net/url" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +type PostgresDB struct { + MigrationsPath string + MaxOpenConns int + MaxIdleConns int +} + +// Connect открывает соединение с Postgres +func (p *PostgresDB) Connect(u *url.URL) (*sql.DB, error) { + db, err := sql.Open("postgres", u.String()) + if err != nil { + return nil, err + } + + if p.MaxOpenConns == 0 { + p.MaxOpenConns = 20 + } + if p.MaxIdleConns == 0 { + p.MaxIdleConns = 10 + } + + db.SetMaxOpenConns(p.MaxOpenConns) + db.SetMaxIdleConns(p.MaxIdleConns) + + return db, nil +} + +// RunMigrations запускает миграции PostgreSQL +func (p *PostgresDB) RunMigrations(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return fmt.Errorf("failed to create postgres driver: %w", err) + } + + if p.MigrationsPath == "" { + p.MigrationsPath = "file://migrations" + } + + m, err := migrate.NewWithDatabaseInstance( + p.MigrationsPath, + "postgres", + driver, + ) + if err != nil { + return fmt.Errorf("failed to create migrate instance: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migration failed: %w", err) + } + + return nil +} diff --git a/src/database/sqlite.go b/src/database/sqlite.go index 17a95f2..13bb485 100644 --- a/src/database/sqlite.go +++ b/src/database/sqlite.go @@ -2,13 +2,23 @@ package database import ( "database/sql" + "errors" "fmt" "net/url" "os" "path/filepath" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" ) -func connectSQLite(u *url.URL) (*sql.DB, error) { +type SQLiteDB struct { + MigrationsPath string + MaxOpenConns int +} + +func (s *SQLiteDB) Connect(u *url.URL) (*sql.DB, error) { path := filepath.Join(u.Host, u.Path) if path == "" { return nil, fmt.Errorf("empty sqlite path") @@ -24,6 +34,35 @@ func connectSQLite(u *url.URL) (*sql.DB, error) { return nil, err } - db.SetMaxOpenConns(1) // важно для sqlite + if s.MaxOpenConns == 0 { + s.MaxOpenConns = 1 + } + db.SetMaxOpenConns(s.MaxOpenConns) return db, nil } + +func (s *SQLiteDB) RunMigrations(db *sql.DB) error { + driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) + if err != nil { + return err + } + + if s.MigrationsPath == "" { + s.MigrationsPath = "file://migrations" + } + + m, err := migrate.NewWithDatabaseInstance( + s.MigrationsPath, + "sqlite", + driver, + ) + if err != nil { + return err + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + + return nil +} diff --git a/src/domain/models/family.go b/src/domain/models/family.go new file mode 100644 index 0000000..8eef5ad --- /dev/null +++ b/src/domain/models/family.go @@ -0,0 +1,67 @@ +package models + +import "time" + +type User struct { + ID int64 + TelegramID int64 + Username *string + FirstName string + LastName *string + LanguageCode *string + CreatedAt time.Time + UpdatedAt time.Time +} + +type TelegramChat struct { + ID int64 + TelegramID int64 + Title string + CreatedAt time.Time +} + +type Family struct { + ID int64 + Name string + OwnerID int64 + TelegramChatID int64 + CreatedAt time.Time +} + +type FamilyRole string + +const ( + FamilyRoleOwner FamilyRole = "owner" + FamilyRoleAdmin FamilyRole = "admin" + FamilyRoleMember FamilyRole = "member" + FamilyRoleChild FamilyRole = "child" +) + +type FamilyMember struct { + ID int64 + FamilyID int64 + UserID int64 + Role FamilyRole + JoinedAt time.Time +} + +type ThreadType string + +const ( + ThreadExpenses ThreadType = "expenses" + ThreadMovies ThreadType = "movies" + ThreadSchedule ThreadType = "schedule" + ThreadRecipes ThreadType = "recipes" + ThreadCustom ThreadType = "custom" +) + +type Thread struct { + ID int64 + FamilyID int64 + Type ThreadType + Title string + TelegramTopicID int64 + IsSystem bool + CreatedBy int64 + CreatedAt time.Time +} diff --git a/src/integrations/receiptApi/http_client.go b/src/integrations/receiptApi/http_client.go index f3cc3f1..89745fa 100644 --- a/src/integrations/receiptApi/http_client.go +++ b/src/integrations/receiptApi/http_client.go @@ -19,7 +19,7 @@ func NewHTTPClient(baseURL string) (*HTTPClient, error) { return &HTTPClient{ baseURL: baseURL, client: &http.Client{ - Timeout: 30 * time.Second, + Timeout: 60 * time.Second, }, }, nil } @@ -44,9 +44,6 @@ func (c *HTTPClient) SendReceipt( } req.Header.Set("Content-Type", "application/json") - //if c.apiKey != "" { - // req.Header.Set("Authorization", "Bearer "+c.apiKey) - //} resp, err := c.client.Do(req) if err != nil { diff --git a/src/integrations/receiptService/receipt_service.go b/src/integrations/receiptService/receipt_service.go index 1befe87..8094096 100644 --- a/src/integrations/receiptService/receipt_service.go +++ b/src/integrations/receiptService/receipt_service.go @@ -1,13 +1,14 @@ package receiptService import ( - "GoFinanceManager/src/domain/models" - "GoFinanceManager/src/repositories" - "GoFinanceManager/src/utils" + "FamilyHub/src/domain/models" + "FamilyHub/src/repositories" + "FamilyHub/src/utils" "bytes" "context" "crypto/tls" "encoding/json" + "errors" "fmt" "log" "mime/multipart" @@ -23,7 +24,7 @@ type ReceiptService struct { func NewReceiptService(repo repositories.ReceiptRepository) *ReceiptService { return &ReceiptService{ client: &http.Client{ - Timeout: 30 * time.Second, + Timeout: 60 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, @@ -40,8 +41,8 @@ func (s *ReceiptService) GetReceipt( number string, ) (*models.Receipt, error) { url := "https://ch.info-center.by/ajax/check1.php" - var receipt models.Receipt + body, contentType := buildMultipartBody(date, number) req, err := http.NewRequestWithContext( ctx, @@ -71,26 +72,26 @@ func (s *ReceiptService) GetReceipt( var raw struct { Message map[string]interface{} `json:"message"` } - log.Println(raw.Message) if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { log.Printf("external service returned %s\n", err.Error()) return nil, err } - raw.Message["receipt_number"] = number - bytes_, _ := json.Marshal(raw.Message) if err := json.Unmarshal(bytes_, &receipt); err != nil { return nil, err } - log.Println(receipt) + + if receipt.IssuedAtRaw == "" { + return nil, errors.New("receipt not found") + } + positions, err := parsePositions(receipt.PositionsRaw) if err != nil { log.Printf("failed to parse positions: %s", err.Error()) return nil, err } - log.Println(receipt.IssuedAtRaw) receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw) if err != nil { log.Printf("failed to parse issued at: %s", err.Error()) diff --git a/cmd/main.go b/src/main.go similarity index 90% rename from cmd/main.go rename to src/main.go index 4c9adc0..3cd12f0 100644 --- a/cmd/main.go +++ b/src/main.go @@ -1,9 +1,9 @@ package main import ( - "GoFinanceManager/src/api" - "GoFinanceManager/src/bot" - "GoFinanceManager/src/config" + "FamilyHub/src/api" + "FamilyHub/src/bot" + "FamilyHub/src/config" "context" "log" diff --git a/src/repositories/receipt_repository.go b/src/repositories/receipt_repository.go index 2cd88b2..c13daed 100644 --- a/src/repositories/receipt_repository.go +++ b/src/repositories/receipt_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" - "GoFinanceManager/src/domain/models" + "FamilyHub/src/domain/models" ) type ReceiptRepository interface { diff --git a/src/repositories/receipt_sql.go b/src/repositories/receipt_sql.go index 86fe65d..8bbf71d 100644 --- a/src/repositories/receipt_sql.go +++ b/src/repositories/receipt_sql.go @@ -5,7 +5,7 @@ import ( "database/sql" "errors" - "GoFinanceManager/src/domain/models" + "FamilyHub/src/domain/models" ) type ReceiptSQLRepository struct { @@ -16,17 +16,16 @@ func NewReceiptSQLRepository(db *sql.DB) *ReceiptSQLRepository { return &ReceiptSQLRepository{db: db} } -func (r *ReceiptSQLRepository) Create( - ctx context.Context, - receipt *models.Receipt, -) (int64, error) { +func (r *ReceiptSQLRepository) Create(ctx context.Context, receipt *models.Receipt) (int64, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer tx.Rollback() - + if receipt.ReceiptNumber != receipt.UI { + receipt.ReceiptNumber = receipt.UI + } res, err := tx.ExecContext(ctx, ` INSERT INTO receipts ( receipt_number, ui, status, issued_at, @@ -130,10 +129,7 @@ func (r *ReceiptSQLRepository) Create( return receiptID, tx.Commit() } -func (r *ReceiptSQLRepository) GetByID( - ctx context.Context, - id int64, -) (*models.Receipt, error) { +func (r *ReceiptSQLRepository) GetByID(ctx context.Context, id int64) (*models.Receipt, error) { var receipt models.Receipt @@ -234,10 +230,7 @@ func (r *ReceiptSQLRepository) GetByID( return &receipt, nil } -func (r *ReceiptSQLRepository) GetAll( - ctx context.Context, - limit, offset int, -) ([]*models.Receipt, error) { +func (r *ReceiptSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*models.Receipt, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, receipt_number, issued_at, total_amount, currency @@ -269,10 +262,7 @@ func (r *ReceiptSQLRepository) GetAll( return receipts, nil } -func (r *ReceiptSQLRepository) Update( - ctx context.Context, - receipt *models.Receipt, -) error { +func (r *ReceiptSQLRepository) Update(ctx context.Context, receipt *models.Receipt) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -315,10 +305,7 @@ func (r *ReceiptSQLRepository) Update( return tx.Commit() } -func (r *ReceiptSQLRepository) Delete( - ctx context.Context, - id int64, -) error { +func (r *ReceiptSQLRepository) Delete(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM receipts WHERE id = ?`, id, diff --git a/cmd/runtime.go b/src/runtime.go similarity index 100% rename from cmd/runtime.go rename to src/runtime.go diff --git a/src/utils/date.go b/src/utils/date.go index 0e4a8f4..660a9c9 100644 --- a/src/utils/date.go +++ b/src/utils/date.go @@ -2,7 +2,6 @@ package utils import ( "errors" - "log" "strings" "time" ) @@ -21,7 +20,6 @@ var knownDateFormats = []string{ func NormalizeDateToISO(input string) (string, error) { input = strings.TrimSpace(input) - log.Println(input) for _, layout := range knownDateFormats { if t, err := time.Parse(layout, input); err == nil { return t.Format("2006-01-02"), nil diff --git a/src/utils/qr_parser.go b/src/utils/qr_parser.go new file mode 100644 index 0000000..a7eb409 --- /dev/null +++ b/src/utils/qr_parser.go @@ -0,0 +1,30 @@ +package utils + +import ( + "bytes" + "image" + _ "image/jpeg" + _ "image/png" + + "github.com/liyue201/goqr" +) + +func DecodeQR(imageBytes []byte) ([]string, error) { + img, _, err := image.Decode(bytes.NewReader(imageBytes)) + if err != nil { + return nil, err + } + + codes, err := goqr.Recognize(img) + if err != nil { + return nil, err + } + println(codes) + + var result []string + for _, code := range codes { + result = append(result, string(code.Payload)) + } + + return result, nil +}