Restructured project

- backend moved to backend directory
- added and initialized frontend with vue
- moved infrastructure files to infra directory
This commit is contained in:
2026-04-01 22:27:26 +03:00
parent 48ef7217eb
commit 9d845c8899
96 changed files with 1591 additions and 118 deletions
@@ -0,0 +1,177 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)
var errUserNotFound = errors.New("user not found")
func NewApiClient(config config.Config) (*HTTPClient, error) {
return &HTTPClient{
config: config,
client: &http.Client{
Timeout: 60 * time.Second,
},
}, nil
}
func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.config.APIHost+c.config.APIPort+"/receipts",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
func (c *HTTPClient) EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error {
registered, err := c.IsUserRegistered(ctx, payload.TelegramID)
if err != nil {
return err
}
if registered {
return nil
}
return c.RegisterUser(ctx, payload)
}
func (c *HTTPClient) IsUserRegistered(ctx context.Context, telegramID int64) (bool, error) {
_, err := c.GetUserByTelegramID(ctx, telegramID)
if err == nil {
return true, nil
}
if errors.Is(err, errUserNotFound) {
return false, nil
}
return false, err
}
func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.config.APIHost+c.config.APIPort+"/api/v1/users",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
c.config.APIHost+c.config.APIPort+"/api/v1/users/by-telegram/"+strconv.FormatInt(telegramID, 10),
nil,
)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, errUserNotFound
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
}
var user domain.UserResponse
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.config.APIHost+c.config.APIPort+"/api/v1/families",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,139 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func strPtr(v string) *string {
return &v
}
func testConfig(baseURL string) config.Config {
return config.Config{
APIHost: baseURL,
}
}
func TestHTTPClient_EnsureUser_AlreadyExists(t *testing.T) {
var postCalls int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(domain.UserResponse{
TelegramID: 100500,
FirstName: strPtr("John"),
})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
atomic.AddInt32(&postCalls, 1)
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
TelegramID: 100500,
FirstName: strPtr("John"),
})
if err != nil {
t.Fatalf("EnsureUser returned error: %v", err)
}
if got := atomic.LoadInt32(&postCalls); got != 0 {
t.Fatalf("expected no POST calls, got %d", got)
}
}
func TestHTTPClient_EnsureUser_CreateOnNotFound(t *testing.T) {
var getCalls int32
var postCalls int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
atomic.AddInt32(&getCalls, 1)
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
atomic.AddInt32(&postCalls, 1)
var req domain.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if req.TelegramID != 100500 || req.FirstName == nil || *req.FirstName != "John" {
t.Fatalf("unexpected payload: %+v", req)
}
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
TelegramID: 100500,
FirstName: strPtr("John"),
})
if err != nil {
t.Fatalf("EnsureUser returned error: %v", err)
}
if got := atomic.LoadInt32(&getCalls); got != 1 {
t.Fatalf("expected 1 GET call, got %d", got)
}
if got := atomic.LoadInt32(&postCalls); got != 1 {
t.Fatalf("expected 1 POST call, got %d", got)
}
}
func TestHTTPClient_EnsureUser_ReturnsErrorWhenLookupFails(t *testing.T) {
var postCalls int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
w.WriteHeader(http.StatusInternalServerError)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
atomic.AddInt32(&postCalls, 1)
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer ts.Close()
client, err := NewApiClient(testConfig(ts.URL))
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
TelegramID: 100500,
FirstName: strPtr("John"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
if got := atomic.LoadInt32(&postCalls); got != 0 {
t.Fatalf("expected no POST calls, got %d", got)
}
}
@@ -0,0 +1,36 @@
package familyHub
import (
"FamilyHub/src/config"
"context"
"net/http"
"strconv"
"time"
)
func NewBotClient(config config.Config) (*HTTPClient, error) {
return &HTTPClient{
config: config,
client: &http.Client{
Timeout: 60 * time.Second,
},
}, nil
}
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message,
nil,
)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
@@ -0,0 +1,26 @@
package familyHub
import (
"FamilyHub/src/config"
"FamilyHub/src/domain"
"context"
"net/http"
)
type ApiClient interface {
SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error
EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error
IsUserRegistered(ctx context.Context, telegramID int64) (bool, error)
RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error
GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error)
CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error
}
type BotClient interface {
SendMessage(ctx context.Context, chatId int64, message string) error
}
type HTTPClient struct {
config config.Config
client *http.Client
}
@@ -0,0 +1,6 @@
package familyHub
type ReceiptPayload struct {
Number string `json:"number"`
Date string `json:"date"`
}
+48
View File
@@ -0,0 +1,48 @@
package ocr
import (
"bytes"
"context"
"fmt"
vision "cloud.google.com/go/vision/apiv1"
)
type GoogleOCR struct {
client *vision.ImageAnnotatorClient
}
func NewGoogleOCR(ctx context.Context) (*GoogleOCR, error) {
client, err := vision.NewImageAnnotatorClient(ctx)
if err != nil {
return nil, err
}
return &GoogleOCR{client: client}, nil
}
func (g *GoogleOCR) Close() error {
if g == nil || g.client == nil {
return nil
}
return g.client.Close()
}
func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) {
img, err := vision.NewImageFromReader(bytes.NewReader(image))
if err != nil {
return "", fmt.Errorf("load image: %w", err)
}
annotations, err := g.client.DetectTexts(ctx, img, nil, 1)
if err != nil {
return "", fmt.Errorf("detect text: %w", err)
}
if len(annotations) == 0 {
return "", nil
}
return annotations[0].Description, nil
}
+9
View File
@@ -0,0 +1,9 @@
package ocr
import "context"
// OCR — контракт для любого OCR сервиса
type OCR interface {
Recognize(ctx context.Context, image []byte) (string, error)
Close() error
}
@@ -0,0 +1,152 @@
package receiptService
import (
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log"
"mime/multipart"
"net/http"
"time"
)
type ReceiptService struct {
client *http.Client
repo repositories.ReceiptsRepository
}
func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService {
return &ReceiptService{
client: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
},
repo: repo,
}
}
func (s *ReceiptService) GetReceipt(
ctx context.Context,
date string,
number string,
) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number)
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
body,
)
if err != nil {
log.Println(err.Error())
return nil, err
}
req.Header.Set("Content-Type", contentType)
resp, err := s.client.Do(req)
if err != nil {
log.Println(err.Error())
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("external service returned %d\n", resp.StatusCode)
return nil, fmt.Errorf("external service returned %d", resp.StatusCode)
}
var raw struct {
Message map[string]interface{} `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
log.Printf("external service returned %s\n", err.Error())
return nil, err
}
bytes_, _ := json.Marshal(raw.Message)
if err := json.Unmarshal(bytes_, &receipt); err != nil {
return nil, err
}
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
}
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
if err != nil {
log.Printf("failed to parse issued at: %s", err.Error())
return nil, err
}
receipt.Positions = positions
for i := range receipt.Positions {
p := &receipt.Positions[i]
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
if err != nil {
log.Printf("failed to parse product count: %s", err.Error())
return nil, err
}
p.Amount, err = utils.ParseFloat(p.AmountRaw)
if err != nil {
log.Printf("failed to parse amount: %s", err.Error())
return nil, err
}
p.Discount, _ = utils.ParseFloat(p.DiscountRaw)
p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw)
}
if _, err := s.repo.Create(ctx, &receipt); err != nil {
return nil, err
}
return &receipt, nil
}
func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("orig_date", date)
_ = writer.WriteField("orig_ui", number)
_ = writer.Close()
return body, writer.FormDataContentType()
}
func parsePositions(raw string) ([]domain.Position, error) {
var positions []domain.Position
if raw == "" {
return positions, nil
}
if err := json.Unmarshal([]byte(raw), &positions); err != nil {
return nil, err
}
return positions, nil
}