Restructured project
- backend moved to backend directory - added and initialized frontend with vue - moved infrastructure files to infra directory
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user