mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавлен биллинг и тарифы
добавлена интеграция с юкасса
This commit is contained in:
16
cmd/main.go
16
cmd/main.go
@@ -13,12 +13,14 @@ import (
|
|||||||
"rmser/config"
|
"rmser/config"
|
||||||
"rmser/internal/infrastructure/db"
|
"rmser/internal/infrastructure/db"
|
||||||
"rmser/internal/infrastructure/ocr_client"
|
"rmser/internal/infrastructure/ocr_client"
|
||||||
|
"rmser/internal/infrastructure/yookassa"
|
||||||
|
|
||||||
"rmser/internal/transport/http/middleware"
|
"rmser/internal/transport/http/middleware"
|
||||||
tgBot "rmser/internal/transport/telegram"
|
tgBot "rmser/internal/transport/telegram"
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
accountPkg "rmser/internal/infrastructure/repository/account"
|
accountPkg "rmser/internal/infrastructure/repository/account"
|
||||||
|
billingPkg "rmser/internal/infrastructure/repository/billing"
|
||||||
catalogPkg "rmser/internal/infrastructure/repository/catalog"
|
catalogPkg "rmser/internal/infrastructure/repository/catalog"
|
||||||
draftsPkg "rmser/internal/infrastructure/repository/drafts"
|
draftsPkg "rmser/internal/infrastructure/repository/drafts"
|
||||||
invoicesPkg "rmser/internal/infrastructure/repository/invoices"
|
invoicesPkg "rmser/internal/infrastructure/repository/invoices"
|
||||||
@@ -31,6 +33,7 @@ import (
|
|||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
billingServicePkg "rmser/internal/services/billing"
|
||||||
draftsServicePkg "rmser/internal/services/drafts"
|
draftsServicePkg "rmser/internal/services/drafts"
|
||||||
ocrServicePkg "rmser/internal/services/ocr"
|
ocrServicePkg "rmser/internal/services/ocr"
|
||||||
recServicePkg "rmser/internal/services/recommend"
|
recServicePkg "rmser/internal/services/recommend"
|
||||||
@@ -68,6 +71,7 @@ func main() {
|
|||||||
|
|
||||||
// 4. Repositories
|
// 4. Repositories
|
||||||
accountRepo := accountPkg.NewRepository(database)
|
accountRepo := accountPkg.NewRepository(database)
|
||||||
|
billingRepo := billingPkg.NewRepository(database)
|
||||||
catalogRepo := catalogPkg.NewRepository(database)
|
catalogRepo := catalogPkg.NewRepository(database)
|
||||||
recipesRepo := recipesPkg.NewRepository(database)
|
recipesRepo := recipesPkg.NewRepository(database)
|
||||||
invoicesRepo := invoicesPkg.NewRepository(database)
|
invoicesRepo := invoicesPkg.NewRepository(database)
|
||||||
@@ -82,25 +86,29 @@ func main() {
|
|||||||
|
|
||||||
// 6. Services
|
// 6. Services
|
||||||
pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL)
|
pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL)
|
||||||
|
ykClient := yookassa.NewClient(cfg.YooKassa.ShopID, cfg.YooKassa.SecretKey)
|
||||||
|
billingService := billingServicePkg.NewService(billingRepo, accountRepo, ykClient)
|
||||||
|
|
||||||
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
|
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
|
||||||
recService := recServicePkg.NewService(recRepo)
|
recService := recServicePkg.NewService(recRepo)
|
||||||
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath)
|
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath)
|
||||||
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory)
|
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory, billingService)
|
||||||
|
|
||||||
// 7. Handlers
|
// 7. Handlers
|
||||||
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
||||||
|
billingHandler := handlers.NewBillingHandler(billingService)
|
||||||
ocrHandler := handlers.NewOCRHandler(ocrService)
|
ocrHandler := handlers.NewOCRHandler(ocrService)
|
||||||
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
||||||
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
||||||
|
|
||||||
// 8. Telegram Bot (Передаем syncService)
|
// 8. Telegram Bot (Передаем syncService)
|
||||||
if cfg.Telegram.Token != "" {
|
if cfg.Telegram.Token != "" {
|
||||||
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, accountRepo, rmsFactory, cryptoManager)
|
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
||||||
}
|
}
|
||||||
settingsHandler.SetNotifier(bot) // Внедряем зависимость
|
billingService.SetNotifier(bot)
|
||||||
|
settingsHandler.SetNotifier(bot)
|
||||||
go bot.Start()
|
go bot.Start()
|
||||||
defer bot.Stop()
|
defer bot.Stop()
|
||||||
}
|
}
|
||||||
@@ -111,6 +119,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
r.POST("/api/webhooks/yookassa", billingHandler.YooKassaWebhook)
|
||||||
|
|
||||||
corsConfig := cors.DefaultConfig()
|
corsConfig := cors.DefaultConfig()
|
||||||
corsConfig.AllowAllOrigins = true
|
corsConfig.AllowAllOrigins = true
|
||||||
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ telegram:
|
|||||||
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
||||||
admin_ids: [665599275]
|
admin_ids: [665599275]
|
||||||
web_app_url: "https://rmser.serty.top"
|
web_app_url: "https://rmser.serty.top"
|
||||||
|
|
||||||
|
yookassa:
|
||||||
|
shop_id: "1236145"
|
||||||
|
secret_key: "test_HxUkDTirAycj7xooYcu_-gURsHMETbE_onIJYXGkj5Y"
|
||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
OCR OCRConfig
|
OCR OCRConfig
|
||||||
Telegram TelegramConfig
|
Telegram TelegramConfig
|
||||||
Security SecurityConfig
|
Security SecurityConfig
|
||||||
|
YooKassa YooKassaConfig `mapstructure:"yookassa"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
@@ -54,6 +55,11 @@ type SecurityConfig struct {
|
|||||||
SecretKey string `mapstructure:"secret_key"` // 32 bytes for AES-256
|
SecretKey string `mapstructure:"secret_key"` // 32 bytes for AES-256
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type YooKassaConfig struct {
|
||||||
|
ShopID string `mapstructure:"shop_id"`
|
||||||
|
SecretKey string `mapstructure:"secret_key"`
|
||||||
|
}
|
||||||
|
|
||||||
// LoadConfig загружает конфигурацию из файла и переменных окружения
|
// LoadConfig загружает конфигурацию из файла и переменных окружения
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
viper.AddConfigPath(path)
|
viper.AddConfigPath(path)
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ type RMSServer struct {
|
|||||||
RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"`
|
RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"`
|
||||||
AutoProcess bool `gorm:"default:false" json:"auto_process"`
|
AutoProcess bool `gorm:"default:false" json:"auto_process"`
|
||||||
|
|
||||||
// Billing / Stats
|
// Billing
|
||||||
|
Balance int `gorm:"default:0" json:"balance"`
|
||||||
|
PaidUntil *time.Time `json:"paid_until"`
|
||||||
|
|
||||||
|
// Stats
|
||||||
InvoiceCount int `gorm:"default:0" json:"invoice_count"`
|
InvoiceCount int `gorm:"default:0" json:"invoice_count"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -77,10 +81,12 @@ type Repository interface {
|
|||||||
// Users
|
// Users
|
||||||
GetOrCreateUser(telegramID int64, username, first, last string) (*User, error)
|
GetOrCreateUser(telegramID int64, username, first, last string) (*User, error)
|
||||||
GetUserByTelegramID(telegramID int64) (*User, error)
|
GetUserByTelegramID(telegramID int64) (*User, error)
|
||||||
|
GetUserByID(id uuid.UUID) (*User, error)
|
||||||
|
|
||||||
// ConnectServer - Основной метод подключения.
|
// ConnectServer - Основной метод подключения.
|
||||||
// Реализует логику: Новый URL -> Owner, Старый URL -> Operator.
|
|
||||||
ConnectServer(userID uuid.UUID, url, login, encryptedPass, name string) (*RMSServer, error)
|
ConnectServer(userID uuid.UUID, url, login, encryptedPass, name string) (*RMSServer, error)
|
||||||
|
GetServerByURL(url string) (*RMSServer, error)
|
||||||
|
GetServerByID(id uuid.UUID) (*RMSServer, error)
|
||||||
|
|
||||||
SaveServerSettings(server *RMSServer) error
|
SaveServerSettings(server *RMSServer) error
|
||||||
|
|
||||||
@@ -106,7 +112,10 @@ type Repository interface {
|
|||||||
AddUserToServer(serverID, userID uuid.UUID, role Role) error
|
AddUserToServer(serverID, userID uuid.UUID, role Role) error
|
||||||
RemoveUserFromServer(serverID, userID uuid.UUID) error
|
RemoveUserFromServer(serverID, userID uuid.UUID) error
|
||||||
|
|
||||||
|
// Billing & Stats
|
||||||
IncrementInvoiceCount(serverID uuid.UUID) error
|
IncrementInvoiceCount(serverID uuid.UUID) error
|
||||||
|
UpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error
|
||||||
|
DecrementBalance(serverID uuid.UUID) error
|
||||||
|
|
||||||
// Super Admin Functions
|
// Super Admin Functions
|
||||||
GetAllServersSystemWide() ([]RMSServer, error)
|
GetAllServersSystemWide() ([]RMSServer, error)
|
||||||
|
|||||||
57
internal/domain/billing/entity.go
Normal file
57
internal/domain/billing/entity.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// internal/domain/billing/entity.go
|
||||||
|
|
||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TariffType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TariffPack TariffType = "PACK" // Пакет накладных
|
||||||
|
TariffSubscription TariffType = "SUBSCRIPTION" // Подписка (безлимит на время)
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending OrderStatus = "PENDING"
|
||||||
|
StatusPaid OrderStatus = "PAID"
|
||||||
|
StatusCanceled OrderStatus = "CANCELED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tariff - Описание услуги
|
||||||
|
type Tariff struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type TariffType `json:"type"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
InvoicesCount int `json:"invoices_count"` // Для PACK - сколько штук, для SUBSCRIPTION - 1000 (лимит)
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order - Заказ на покупку тарифа
|
||||||
|
type Order struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` // Кто платит
|
||||||
|
TargetServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"target_server_id"` // Кому начисляем
|
||||||
|
TariffID string `gorm:"type:varchar(50);not null" json:"tariff_id"`
|
||||||
|
Amount float64 `gorm:"type:numeric(19,4);not null" json:"amount"`
|
||||||
|
Status OrderStatus `gorm:"type:varchar(20);default:'PENDING'" json:"status"`
|
||||||
|
|
||||||
|
PaymentID string `gorm:"type:varchar(100)" json:"payment_id"` // ID транзакции в ЮКассе
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository для работы с заказами
|
||||||
|
type Repository interface {
|
||||||
|
CreateOrder(order *Order) error
|
||||||
|
GetOrder(id uuid.UUID) (*Order, error)
|
||||||
|
UpdateOrderStatus(id uuid.UUID, status OrderStatus, paymentID string) error
|
||||||
|
GetActiveOrdersByUser(userID uuid.UUID) ([]Order, error)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/internal/domain/billing"
|
||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
"rmser/internal/domain/drafts"
|
"rmser/internal/domain/drafts"
|
||||||
"rmser/internal/domain/invoices"
|
"rmser/internal/domain/invoices"
|
||||||
@@ -51,6 +52,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
|||||||
&account.User{},
|
&account.User{},
|
||||||
&account.RMSServer{},
|
&account.RMSServer{},
|
||||||
&account.ServerUser{},
|
&account.ServerUser{},
|
||||||
|
&billing.Order{},
|
||||||
&catalog.Product{},
|
&catalog.Product{},
|
||||||
&catalog.MeasureUnit{},
|
&catalog.MeasureUnit{},
|
||||||
&catalog.ProductContainer{},
|
&catalog.ProductContainer{},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
|
|
||||||
@@ -60,6 +61,15 @@ func (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, err
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) {
|
||||||
|
var user account.User
|
||||||
|
err := r.db.Where("id = ?", id).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectServer - Основная точка входа для добавления сервера
|
// ConnectServer - Основная точка входа для добавления сервера
|
||||||
func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {
|
func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {
|
||||||
// 1. Нормализация URL (удаляем слеш в конце, приводим к нижнему регистру)
|
// 1. Нормализация URL (удаляем слеш в конце, приводим к нижнему регистру)
|
||||||
@@ -401,3 +411,37 @@ func (r *pgRepository) GetConnectionByID(id uuid.UUID) (*account.ServerUser, err
|
|||||||
}
|
}
|
||||||
return &link, nil
|
return &link, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetServerByURL(rawURL string) (*account.RMSServer, error) {
|
||||||
|
cleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), "/")
|
||||||
|
var server account.RMSServer
|
||||||
|
err := r.db.Where("base_url = ?", cleanURL).First(&server).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetServerByID(id uuid.UUID) (*account.RMSServer, error) {
|
||||||
|
var server account.RMSServer
|
||||||
|
err := r.db.First(&server, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBalance начисляет пакет или продлевает подписку
|
||||||
|
func (r *pgRepository) UpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error {
|
||||||
|
return r.db.Model(&account.RMSServer{}).Where("id = ?", serverID).Updates(map[string]interface{}{
|
||||||
|
"balance": gorm.Expr("balance + ?", amountChange),
|
||||||
|
"paid_until": newPaidUntil,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecrementBalance списывает 1 единицу при отправке накладной
|
||||||
|
func (r *pgRepository) DecrementBalance(serverID uuid.UUID) error {
|
||||||
|
return r.db.Model(&account.RMSServer{}).
|
||||||
|
Where("id = ? AND balance > 0", serverID).
|
||||||
|
UpdateColumn("balance", gorm.Expr("balance - ?", 1)).Error
|
||||||
|
}
|
||||||
|
|||||||
47
internal/infrastructure/repository/billing/postgres.go
Normal file
47
internal/infrastructure/repository/billing/postgres.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"rmser/internal/domain/billing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB) billing.Repository {
|
||||||
|
return &pgRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) CreateOrder(order *billing.Order) error {
|
||||||
|
return r.db.Create(order).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetOrder(id uuid.UUID) (*billing.Order, error) {
|
||||||
|
var order billing.Order
|
||||||
|
err := r.db.Where("id = ?", id).First(&order).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) UpdateOrderStatus(id uuid.UUID, status billing.OrderStatus, paymentID string) error {
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if paymentID != "" {
|
||||||
|
updates["payment_id"] = paymentID
|
||||||
|
}
|
||||||
|
return r.db.Model(&billing.Order{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetActiveOrdersByUser(userID uuid.UUID) ([]billing.Order, error) {
|
||||||
|
var orders []billing.Order
|
||||||
|
err := r.db.Where("user_id = ? AND status = ?", userID, billing.StatusPending).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Find(&orders).Error
|
||||||
|
return orders, err
|
||||||
|
}
|
||||||
79
internal/infrastructure/yookassa/client.go
Normal file
79
internal/infrastructure/yookassa/client.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
shopID string
|
||||||
|
secretKey string
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(shopID, secretKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
shopID: shopID,
|
||||||
|
secretKey: secretKey,
|
||||||
|
baseURL: "https://api.yookassa.ru/v3",
|
||||||
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreatePayment(ctx context.Context, amount decimal.Decimal, description string, orderID uuid.UUID, returnURL string) (*PaymentResponse, error) {
|
||||||
|
reqBody := PaymentRequest{
|
||||||
|
Amount: Amount{
|
||||||
|
Value: amount.StringFixed(2),
|
||||||
|
Currency: "RUB",
|
||||||
|
},
|
||||||
|
Capture: true, // Автоматическое списание
|
||||||
|
Confirmation: Confirmation{
|
||||||
|
Type: "redirect",
|
||||||
|
ReturnURL: returnURL,
|
||||||
|
},
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"order_id": orderID.String(),
|
||||||
|
},
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/payments", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic Auth
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte(c.shopID + ":" + c.secretKey))
|
||||||
|
req.Header.Set("Authorization", "Basic "+auth)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Идемпотентность: используем наш order_id как ключ
|
||||||
|
req.Header.Set("Idempotence-Key", orderID.String())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("yookassa error: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result PaymentResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
34
internal/infrastructure/yookassa/dto.go
Normal file
34
internal/infrastructure/yookassa/dto.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
type Amount struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Confirmation struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ReturnURL string `json:"return_url,omitempty"`
|
||||||
|
URL string `json:"confirmation_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaymentRequest struct {
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
Capture bool `json:"capture"`
|
||||||
|
Confirmation Confirmation `json:"confirmation"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaymentResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
Confirmation Confirmation `json:"confirmation"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookEvent struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Object PaymentResponse `json:"object"`
|
||||||
|
}
|
||||||
200
internal/services/billing/service.go
Normal file
200
internal/services/billing/service.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// internal/services/billing/service.go
|
||||||
|
|
||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/internal/domain/billing"
|
||||||
|
"rmser/internal/infrastructure/yookassa"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Список доступных тарифов
|
||||||
|
var AvailableTariffs = []billing.Tariff{
|
||||||
|
{ID: "pack_10", Name: "Пакет 10", Type: billing.TariffPack, Price: 500, InvoicesCount: 10, DurationDays: 30},
|
||||||
|
{ID: "pack_50", Name: "Пакет 50", Type: billing.TariffPack, Price: 1000, InvoicesCount: 50, DurationDays: 180},
|
||||||
|
{ID: "pack_100", Name: "Пакет 100", Type: billing.TariffPack, Price: 1500, InvoicesCount: 100, DurationDays: 365},
|
||||||
|
{ID: "sub_7", Name: "Безлимит 7 дней", Type: billing.TariffSubscription, Price: 700, InvoicesCount: 1000, DurationDays: 7},
|
||||||
|
{ID: "sub_14", Name: "Безлимит 14 дней", Type: billing.TariffSubscription, Price: 1300, InvoicesCount: 1000, DurationDays: 14},
|
||||||
|
{ID: "sub_30", Name: "Безлимит 30 дней", Type: billing.TariffSubscription, Price: 2500, InvoicesCount: 1000, DurationDays: 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentNotifier определяет интерфейс для уведомления о успешном платеже
|
||||||
|
type PaymentNotifier interface {
|
||||||
|
NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
billingRepo billing.Repository
|
||||||
|
accountRepo account.Repository
|
||||||
|
ykClient *yookassa.Client
|
||||||
|
notifier PaymentNotifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetNotifier(n PaymentNotifier) {
|
||||||
|
s.notifier = n
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(bRepo billing.Repository, aRepo account.Repository, yk *yookassa.Client) *Service {
|
||||||
|
return &Service{
|
||||||
|
billingRepo: bRepo,
|
||||||
|
accountRepo: aRepo,
|
||||||
|
ykClient: yk,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTariffs() []billing.Tariff {
|
||||||
|
return AvailableTariffs
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder теперь возвращает (*billing.Order, string, error)
|
||||||
|
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, tariffID string, targetServerURL string, returnURL string) (*billing.Order, string, error) {
|
||||||
|
// 1. Ищем тариф
|
||||||
|
var selectedTariff *billing.Tariff
|
||||||
|
for _, t := range AvailableTariffs {
|
||||||
|
if t.ID == tariffID {
|
||||||
|
selectedTariff = &t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedTariff == nil {
|
||||||
|
return nil, "", errors.New("тариф не найден")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Определяем целевой сервер
|
||||||
|
var targetServerID uuid.UUID
|
||||||
|
if targetServerURL != "" {
|
||||||
|
srv, err := s.accountRepo.GetServerByURL(targetServerURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("сервер не найден: %w", err)
|
||||||
|
}
|
||||||
|
targetServerID = srv.ID
|
||||||
|
} else {
|
||||||
|
srv, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || srv == nil {
|
||||||
|
return nil, "", errors.New("активный сервер не выбран")
|
||||||
|
}
|
||||||
|
targetServerID = srv.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Создаем заказ в БД
|
||||||
|
order := &billing.Order{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: userID,
|
||||||
|
TargetServerID: targetServerID,
|
||||||
|
TariffID: tariffID,
|
||||||
|
Amount: selectedTariff.Price,
|
||||||
|
Status: billing.StatusPending,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.billingRepo.CreateOrder(order); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Запрос к ЮКассе
|
||||||
|
description := fmt.Sprintf("Оплата тарифа %s", selectedTariff.Name)
|
||||||
|
ykResp, err := s.ykClient.CreatePayment(ctx, decimal.NewFromFloat(order.Amount), description, order.ID, returnURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("yookassa error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем payment_id от ЮКассы в наш заказ
|
||||||
|
if err := s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPending, ykResp.ID); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return order, ykResp.Confirmation.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessWebhook — вызывается из HTTP хендлера
|
||||||
|
func (s *Service) ProcessWebhook(ctx context.Context, event yookassa.WebhookEvent) error {
|
||||||
|
if event.Event != "payment.succeeded" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orderIDStr := event.Object.Metadata["order_id"]
|
||||||
|
orderID, err := uuid.Parse(orderIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := s.billingRepo.GetOrder(orderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.Status == billing.StatusPaid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.applyOrderToBalance(order, event.Object.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOrderToBalance — внутренняя логика начисления (DRY)
|
||||||
|
func (s *Service) applyOrderToBalance(order *billing.Order, externalID string) error {
|
||||||
|
var tariff *billing.Tariff
|
||||||
|
for _, t := range AvailableTariffs {
|
||||||
|
if t.ID == order.TariffID {
|
||||||
|
tariff = &t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.accountRepo.GetServerByID(order.TargetServerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var newPaidUntil time.Time
|
||||||
|
if server.PaidUntil != nil && server.PaidUntil.After(now) {
|
||||||
|
newPaidUntil = server.PaidUntil.AddDate(0, 0, tariff.DurationDays)
|
||||||
|
} else {
|
||||||
|
newPaidUntil = now.AddDate(0, 0, tariff.DurationDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.accountRepo.UpdateBalance(server.ID, tariff.InvoicesCount, &newPaidUntil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.notifier != nil {
|
||||||
|
// Мы запускаем в горутине, чтобы не тормозить ответ ЮКассе
|
||||||
|
go s.notifier.NotifySuccess(order.UserID, order.Amount, server.Balance+tariff.InvoicesCount, server.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPaid, externalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmOrder — оставляем для совместимости или ручного подтверждения (если нужно)
|
||||||
|
func (s *Service) ConfirmOrder(orderID uuid.UUID) error {
|
||||||
|
order, err := s.billingRepo.GetOrder(orderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.applyOrderToBalance(order, "manual_confirmation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanProcessInvoice проверяет возможность отправки накладной
|
||||||
|
func (s *Service) CanProcessInvoice(serverID uuid.UUID) (bool, error) {
|
||||||
|
server, err := s.accountRepo.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.Balance <= 0 {
|
||||||
|
return false, errors.New("недостаточно накладных на балансе")
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.PaidUntil == nil || server.PaidUntil.Before(time.Now()) {
|
||||||
|
return false, errors.New("срок действия услуг истек")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -17,16 +17,18 @@ import (
|
|||||||
"rmser/internal/domain/ocr"
|
"rmser/internal/domain/ocr"
|
||||||
"rmser/internal/domain/suppliers"
|
"rmser/internal/domain/suppliers"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
|
"rmser/internal/services/billing"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
draftRepo drafts.Repository
|
draftRepo drafts.Repository
|
||||||
ocrRepo ocr.Repository
|
ocrRepo ocr.Repository
|
||||||
catalogRepo catalog.Repository
|
catalogRepo catalog.Repository
|
||||||
accountRepo account.Repository
|
accountRepo account.Repository
|
||||||
supplierRepo suppliers.Repository
|
supplierRepo suppliers.Repository
|
||||||
rmsFactory *rms.Factory
|
rmsFactory *rms.Factory
|
||||||
|
billingService *billing.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
@@ -36,14 +38,16 @@ func NewService(
|
|||||||
accountRepo account.Repository,
|
accountRepo account.Repository,
|
||||||
supplierRepo suppliers.Repository,
|
supplierRepo suppliers.Repository,
|
||||||
rmsFactory *rms.Factory,
|
rmsFactory *rms.Factory,
|
||||||
|
billingService *billing.Service,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
draftRepo: draftRepo,
|
draftRepo: draftRepo,
|
||||||
ocrRepo: ocrRepo,
|
ocrRepo: ocrRepo,
|
||||||
catalogRepo: catalogRepo,
|
catalogRepo: catalogRepo,
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
supplierRepo: supplierRepo,
|
supplierRepo: supplierRepo,
|
||||||
rmsFactory: rmsFactory,
|
rmsFactory: rmsFactory,
|
||||||
|
billingService: billingService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +232,11 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- BILLING CHECK ---
|
||||||
|
if can, err := s.billingService.CanProcessInvoice(server.ID); !can {
|
||||||
|
return "", fmt.Errorf("ошибка биллинга: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Черновик
|
// 2. Черновик
|
||||||
draft, err := s.draftRepo.GetByID(draftID)
|
draft, err := s.draftRepo.GetByID(draftID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -300,10 +309,16 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
draft.Status = drafts.StatusCompleted
|
draft.Status = drafts.StatusCompleted
|
||||||
s.draftRepo.Update(draft)
|
s.draftRepo.Update(draft)
|
||||||
|
|
||||||
// 7. БИЛЛИНГ и Обучение
|
// --- БИЛЛИНГ: Списание баланса и инкремент счетчика ---
|
||||||
|
if err := s.accountRepo.DecrementBalance(server.ID); err != nil {
|
||||||
|
logger.Log.Error("Billing decrement failed", zap.Error(err), zap.String("server_id", server.ID.String()))
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Запуск обучения
|
||||||
go s.learnFromDraft(draft, server.ID)
|
go s.learnFromDraft(draft, server.ID)
|
||||||
|
|
||||||
return docNum, nil
|
return docNum, nil
|
||||||
|
|||||||
40
internal/transport/http/handlers/billing.go
Normal file
40
internal/transport/http/handlers/billing.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"rmser/internal/infrastructure/yookassa"
|
||||||
|
"rmser/internal/services/billing"
|
||||||
|
"rmser/pkg/logger"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BillingHandler struct {
|
||||||
|
service *billing.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBillingHandler(s *billing.Service) *BillingHandler {
|
||||||
|
return &BillingHandler{service: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BillingHandler) YooKassaWebhook(c *gin.Context) {
|
||||||
|
var event yookassa.WebhookEvent
|
||||||
|
if err := c.ShouldBindJSON(&event); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Info("YooKassa Webhook received",
|
||||||
|
zap.String("event", event.Event),
|
||||||
|
zap.String("payment_id", event.Object.ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := h.service.ProcessWebhook(c.Request.Context(), event); err != nil {
|
||||||
|
logger.Log.Error("Failed to process webhook", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"status": "error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// internal/transport/telegram/bot.go
|
||||||
|
|
||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"rmser/config"
|
"rmser/config"
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
|
"rmser/internal/services/billing"
|
||||||
"rmser/internal/services/ocr"
|
"rmser/internal/services/ocr"
|
||||||
"rmser/internal/services/sync"
|
"rmser/internal/services/sync"
|
||||||
"rmser/pkg/crypto"
|
"rmser/pkg/crypto"
|
||||||
@@ -23,19 +26,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
b *tele.Bot
|
b *tele.Bot
|
||||||
ocrService *ocr.Service
|
ocrService *ocr.Service
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
accountRepo account.Repository
|
billingService *billing.Service
|
||||||
rmsFactory *rms.Factory
|
accountRepo account.Repository
|
||||||
cryptoManager *crypto.CryptoManager
|
rmsFactory *rms.Factory
|
||||||
|
cryptoManager *crypto.CryptoManager
|
||||||
|
|
||||||
fsm *StateManager
|
fsm *StateManager
|
||||||
adminIDs map[int64]struct{}
|
adminIDs map[int64]struct{}
|
||||||
webAppURL string
|
webAppURL string
|
||||||
|
|
||||||
// UI Elements (Menus)
|
|
||||||
// menuMain удаляем как статическое поле, так как оно теперь динамическое
|
|
||||||
menuServers *tele.ReplyMarkup
|
menuServers *tele.ReplyMarkup
|
||||||
menuDicts *tele.ReplyMarkup
|
menuDicts *tele.ReplyMarkup
|
||||||
menuBalance *tele.ReplyMarkup
|
menuBalance *tele.ReplyMarkup
|
||||||
@@ -45,6 +47,7 @@ func NewBot(
|
|||||||
cfg config.TelegramConfig,
|
cfg config.TelegramConfig,
|
||||||
ocrService *ocr.Service,
|
ocrService *ocr.Service,
|
||||||
syncService *sync.Service,
|
syncService *sync.Service,
|
||||||
|
billingService *billing.Service,
|
||||||
accountRepo account.Repository,
|
accountRepo account.Repository,
|
||||||
rmsFactory *rms.Factory,
|
rmsFactory *rms.Factory,
|
||||||
cryptoManager *crypto.CryptoManager,
|
cryptoManager *crypto.CryptoManager,
|
||||||
@@ -69,15 +72,16 @@ func NewBot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
bot := &Bot{
|
bot := &Bot{
|
||||||
b: b,
|
b: b,
|
||||||
ocrService: ocrService,
|
ocrService: ocrService,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
accountRepo: accountRepo,
|
billingService: billingService,
|
||||||
rmsFactory: rmsFactory,
|
accountRepo: accountRepo,
|
||||||
cryptoManager: cryptoManager,
|
rmsFactory: rmsFactory,
|
||||||
fsm: NewStateManager(),
|
cryptoManager: cryptoManager,
|
||||||
adminIDs: admins,
|
fsm: NewStateManager(),
|
||||||
webAppURL: cfg.WebAppURL,
|
adminIDs: admins,
|
||||||
|
webAppURL: cfg.WebAppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if bot.webAppURL == "" {
|
if bot.webAppURL == "" {
|
||||||
@@ -89,12 +93,9 @@ func NewBot(
|
|||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initMenus инициализирует статические кнопки (кроме Главного меню)
|
|
||||||
func (bot *Bot) initMenus() {
|
func (bot *Bot) initMenus() {
|
||||||
// --- SERVERS MENU (Dynamic part logic is in handler) ---
|
|
||||||
bot.menuServers = &tele.ReplyMarkup{}
|
bot.menuServers = &tele.ReplyMarkup{}
|
||||||
|
|
||||||
// --- DICTIONARIES MENU ---
|
|
||||||
bot.menuDicts = &tele.ReplyMarkup{}
|
bot.menuDicts = &tele.ReplyMarkup{}
|
||||||
btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync")
|
btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync")
|
||||||
btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main")
|
btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main")
|
||||||
@@ -103,49 +104,33 @@ func (bot *Bot) initMenus() {
|
|||||||
bot.menuDicts.Row(btnBack),
|
bot.menuDicts.Row(btnBack),
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- BALANCE MENU ---
|
|
||||||
bot.menuBalance = &tele.ReplyMarkup{}
|
bot.menuBalance = &tele.ReplyMarkup{}
|
||||||
btnDeposit := bot.menuBalance.Data("💳 Пополнить (Demo)", "act_deposit")
|
// Кнопки пополнения теперь создаются динамически в renderBalanceMenu
|
||||||
bot.menuBalance.Inline(
|
|
||||||
bot.menuBalance.Row(btnDeposit),
|
|
||||||
bot.menuBalance.Row(btnBack),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) initHandlers() {
|
func (bot *Bot) initHandlers() {
|
||||||
bot.b.Use(middleware.Logger())
|
bot.b.Use(middleware.Logger())
|
||||||
bot.b.Use(bot.registrationMiddleware)
|
bot.b.Use(bot.registrationMiddleware)
|
||||||
|
|
||||||
// Commands
|
|
||||||
bot.b.Handle("/start", bot.handleStartCommand)
|
bot.b.Handle("/start", bot.handleStartCommand)
|
||||||
|
|
||||||
// Admin Commands
|
|
||||||
bot.b.Handle("/admin", bot.handleAdminCommand)
|
bot.b.Handle("/admin", bot.handleAdminCommand)
|
||||||
|
|
||||||
// Navigation Callbacks
|
|
||||||
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu)
|
bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "nav_dicts"}, bot.renderDictsMenu)
|
bot.b.Handle(&tele.Btn{Unique: "nav_dicts"}, bot.renderDictsMenu)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "nav_balance"}, bot.renderBalanceMenu)
|
bot.b.Handle(&tele.Btn{Unique: "nav_balance"}, bot.renderBalanceMenu)
|
||||||
|
|
||||||
// Actions Callbacks
|
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow)
|
bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync)
|
bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu)
|
bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes)
|
bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo)
|
bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error {
|
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Dynamic Handler for server selection ("set_server_UUID")
|
|
||||||
bot.b.Handle(&tele.Btn{Unique: "adm_list_servers"}, bot.adminListServers)
|
bot.b.Handle(&tele.Btn{Unique: "adm_list_servers"}, bot.adminListServers)
|
||||||
bot.b.Handle(tele.OnCallback, bot.handleCallback)
|
bot.b.Handle(tele.OnCallback, bot.handleCallback)
|
||||||
|
|
||||||
// Input Handlers
|
|
||||||
bot.b.Handle(tele.OnText, bot.handleText)
|
bot.b.Handle(tele.OnText, bot.handleText)
|
||||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) Start() {
|
func (bot *Bot) Start() {
|
||||||
@@ -166,19 +151,14 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStartCommand обрабатывает /start и deep linking (приглашения)
|
|
||||||
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
||||||
payload := c.Message().Payload // То, что после /start <payload>
|
payload := c.Message().Payload
|
||||||
|
|
||||||
// Если есть payload, пробуем разобрать как приглашение
|
|
||||||
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
||||||
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return bot.renderMainMenu(c)
|
return bot.renderMainMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInviteLink обрабатывает приглашение пользователя на сервер
|
|
||||||
func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
|
func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
|
||||||
serverID, err := uuid.Parse(serverIDStr)
|
serverID, err := uuid.Parse(serverIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,43 +166,33 @@ func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newUser := c.Sender()
|
newUser := c.Sender()
|
||||||
// Гарантируем, что юзер есть в БД (хотя middleware это делает, тут для надежности перед логикой)
|
|
||||||
userDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName)
|
userDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName)
|
||||||
|
|
||||||
// Добавляем пользователя (RoleOperator - желаемая, но репозиторий может оставить более высокую)
|
|
||||||
err = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator)
|
err = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send(fmt.Sprintf("❌ Не удалось подключиться к серверу: %v", err))
|
return c.Send(fmt.Sprintf("❌ Не удалось подключиться к серверу: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сбрасываем кэш подключений
|
|
||||||
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
|
|
||||||
// Получаем актуальные данные о роли и сервере ПОСЛЕ добавления
|
|
||||||
activeServer, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
activeServer, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
if err != nil || activeServer == nil || activeServer.ID != serverID {
|
if err != nil || activeServer == nil || activeServer.ID != serverID {
|
||||||
// Крайний случай, если что-то пошло не так с активацией
|
|
||||||
return c.Send("✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.")
|
return c.Send("✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.")
|
||||||
}
|
}
|
||||||
|
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID)
|
||||||
|
|
||||||
// 1. Отправляем сообщение пользователю
|
|
||||||
c.Send(fmt.Sprintf("✅ Вы подключены к серверу <b>%s</b>.\nВаша роль: <b>%s</b>.\nТеперь вы можете загружать чеки.", activeServer.Name, role), tele.ModeHTML)
|
c.Send(fmt.Sprintf("✅ Вы подключены к серверу <b>%s</b>.\nВаша роль: <b>%s</b>.\nТеперь вы можете загружать чеки.", activeServer.Name, role), tele.ModeHTML)
|
||||||
|
|
||||||
// 2. Уведомляем Владельца (только если это реально новый человек или роль изменилась, но упростим - шлем всегда при переходе по ссылке)
|
|
||||||
// Но не шлем уведомление, если Владелец перешел по своей же ссылке
|
|
||||||
if role != account.RoleOwner {
|
if role != account.RoleOwner {
|
||||||
go func() {
|
go func() {
|
||||||
users, err := bot.accountRepo.GetServerUsers(serverID)
|
users, err := bot.accountRepo.GetServerUsers(serverID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.Role == account.RoleOwner {
|
if u.Role == account.RoleOwner {
|
||||||
// Не уведомляем, если это тот же человек (хотя проверка выше role != Owner уже отсекла это, но на всякий случай)
|
|
||||||
if u.UserID == userDB.ID {
|
if u.UserID == userDB.ID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
name := newUser.FirstName
|
name := newUser.FirstName
|
||||||
if newUser.LastName != "" {
|
if newUser.LastName != "" {
|
||||||
name += " " + newUser.LastName
|
name += " " + newUser.LastName
|
||||||
@@ -230,9 +200,7 @@ func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
|
|||||||
if newUser.Username != "" {
|
if newUser.Username != "" {
|
||||||
name += fmt.Sprintf(" (@%s)", newUser.Username)
|
name += fmt.Sprintf(" (@%s)", newUser.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf("🔔 <b>Обновление команды</b>\n\nПользователь <b>%s</b> активировал приглашение на сервер «%s» (Роль: %s).", name, activeServer.Name, role)
|
msg := fmt.Sprintf("🔔 <b>Обновление команды</b>\n\nПользователь <b>%s</b> активировал приглашение на сервер «%s» (Роль: %s).", name, activeServer.Name, role)
|
||||||
|
|
||||||
bot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML)
|
bot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -244,7 +212,6 @@ func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
|
|||||||
return bot.renderMainMenu(c)
|
return bot.renderMainMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Реализация интерфейса handlers.Notifier
|
|
||||||
func (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) {
|
func (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) {
|
||||||
msg := fmt.Sprintf("ℹ️ <b>Изменение прав доступа</b>\n\nСервер: <b>%s</b>\nВаша новая роль: <b>%s</b>", serverName, newRole)
|
msg := fmt.Sprintf("ℹ️ <b>Изменение прав доступа</b>\n\nСервер: <b>%s</b>\nВаша новая роль: <b>%s</b>", serverName, newRole)
|
||||||
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
|
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
|
||||||
@@ -255,17 +222,14 @@ func (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) {
|
|||||||
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
|
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminCommand - точка входа в админку
|
|
||||||
func (bot *Bot) handleAdminCommand(c tele.Context) error {
|
func (bot *Bot) handleAdminCommand(c tele.Context) error {
|
||||||
userID := c.Sender().ID
|
userID := c.Sender().ID
|
||||||
if _, isAdmin := bot.adminIDs[userID]; !isAdmin {
|
if _, isAdmin := bot.adminIDs[userID]; !isAdmin {
|
||||||
return nil // Игнорируем не админов
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
btnServers := menu.Data("🏢 Список серверов", "adm_list_servers")
|
btnServers := menu.Data("🏢 Список серверов", "adm_list_servers")
|
||||||
menu.Inline(menu.Row(btnServers))
|
menu.Inline(menu.Row(btnServers))
|
||||||
|
|
||||||
return c.Send("🕵️♂️ <b>Super Admin Panel</b>\n\nВыберите действие:", menu, tele.ModeHTML)
|
return c.Send("🕵️♂️ <b>Super Admin Panel</b>\n\nВыберите действие:", menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,30 +238,22 @@ func (bot *Bot) adminListServers(c tele.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Error: " + err.Error())
|
return c.Send("Error: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
var rows []tele.Row
|
var rows []tele.Row
|
||||||
|
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
// adm_srv_<UUID>
|
|
||||||
btn := menu.Data(fmt.Sprintf("🖥 %s", s.Name), "adm_srv_"+s.ID.String())
|
btn := menu.Data(fmt.Sprintf("🖥 %s", s.Name), "adm_srv_"+s.ID.String())
|
||||||
rows = append(rows, menu.Row(btn))
|
rows = append(rows, menu.Row(btn))
|
||||||
}
|
}
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
|
|
||||||
return c.EditOrSend("<b>Все серверы системы:</b>", menu, tele.ModeHTML)
|
return c.EditOrSend("<b>Все серверы системы:</b>", menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RENDERERS (View Layer) ---
|
|
||||||
|
|
||||||
// renderMainMenu строит меню динамически в зависимости от роли
|
|
||||||
func (bot *Bot) renderMainMenu(c tele.Context) error {
|
func (bot *Bot) renderMainMenu(c tele.Context) error {
|
||||||
bot.fsm.Reset(c.Sender().ID)
|
bot.fsm.Reset(c.Sender().ID)
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
|
|
||||||
btnServers := menu.Data("🖥 Серверы", "nav_servers")
|
btnServers := menu.Data("🖥 Серверы", "nav_servers")
|
||||||
btnDicts := menu.Data("🔄 Справочники", "nav_dicts")
|
btnDicts := menu.Data("🔄 Справочники", "nav_dicts")
|
||||||
btnBalance := menu.Data("💰 Баланс", "nav_balance")
|
btnBalance := menu.Data("💰 Баланс", "nav_balance")
|
||||||
@@ -306,7 +262,6 @@ func (bot *Bot) renderMainMenu(c tele.Context) error {
|
|||||||
rows = append(rows, menu.Row(btnServers, btnDicts))
|
rows = append(rows, menu.Row(btnServers, btnDicts))
|
||||||
rows = append(rows, menu.Row(btnBalance))
|
rows = append(rows, menu.Row(btnBalance))
|
||||||
|
|
||||||
// Проверяем роль для отображения кнопки App
|
|
||||||
showApp := false
|
showApp := false
|
||||||
if activeServer != nil {
|
if activeServer != nil {
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)
|
||||||
@@ -318,13 +273,9 @@ func (bot *Bot) renderMainMenu(c tele.Context) error {
|
|||||||
if showApp {
|
if showApp {
|
||||||
btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
|
btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
|
||||||
rows = append(rows, menu.Row(btnApp))
|
rows = append(rows, menu.Row(btnApp))
|
||||||
} else {
|
|
||||||
// Если оператор или нет сервера, можно добавить подсказку или просто ничего
|
|
||||||
// Для оператора это нормально. Для нового юзера - он пойдет в "Серверы"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
|
|
||||||
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
|
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
|
||||||
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
|
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
|
||||||
|
|
||||||
@@ -346,17 +297,13 @@ func (bot *Bot) renderServersMenu(c tele.Context) error {
|
|||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
var rows []tele.Row
|
var rows []tele.Row
|
||||||
|
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
icon := "🔴"
|
icon := "🔴"
|
||||||
if activeServer != nil && activeServer.ID == s.ID {
|
if activeServer != nil && activeServer.ID == s.ID {
|
||||||
icon = "🟢"
|
icon = "🟢"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Определяем роль для отображения
|
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
||||||
label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role)
|
label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role)
|
||||||
|
|
||||||
btn := menu.Data(label, "set_server_"+s.ID.String())
|
btn := menu.Data(label, "set_server_"+s.ID.String())
|
||||||
rows = append(rows, menu.Row(btn))
|
rows = append(rows, menu.Row(btn))
|
||||||
}
|
}
|
||||||
@@ -367,7 +314,6 @@ func (bot *Bot) renderServersMenu(c tele.Context) error {
|
|||||||
|
|
||||||
rows = append(rows, menu.Row(btnAdd, btnDel))
|
rows = append(rows, menu.Row(btnAdd, btnDel))
|
||||||
rows = append(rows, menu.Row(btnBack))
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
|
|
||||||
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер, чтобы сделать его активным.", len(servers))
|
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер, чтобы сделать его активным.", len(servers))
|
||||||
@@ -376,9 +322,7 @@ func (bot *Bot) renderServersMenu(c tele.Context) error {
|
|||||||
|
|
||||||
func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
stats, err := bot.syncService.GetSyncStats(userDB.ID)
|
stats, err := bot.syncService.GetSyncStats(userDB.ID)
|
||||||
|
|
||||||
var txt string
|
var txt string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка или нет активного сервера (%v)", err)
|
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка или нет активного сервера (%v)", err)
|
||||||
@@ -387,7 +331,6 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
|||||||
if stats.LastInvoice != nil {
|
if stats.LastInvoice != nil {
|
||||||
lastUpdate = stats.LastInvoice.Format("02.01.2006")
|
lastUpdate = stats.LastInvoice.Format("02.01.2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
txt = fmt.Sprintf("<b>🔄 Состояние справочников</b>\n\n"+
|
txt = fmt.Sprintf("<b>🔄 Состояние справочников</b>\n\n"+
|
||||||
"🏢 <b>Сервер:</b> %s\n"+
|
"🏢 <b>Сервер:</b> %s\n"+
|
||||||
"📦 <b>Товары:</b> %d\n"+
|
"📦 <b>Товары:</b> %d\n"+
|
||||||
@@ -396,27 +339,63 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
|||||||
"📄 <b>Накладные (30дн):</b> %d\n"+
|
"📄 <b>Накладные (30дн):</b> %d\n"+
|
||||||
"📅 <b>Посл. документ:</b> %s\n\n"+
|
"📅 <b>Посл. документ:</b> %s\n\n"+
|
||||||
"Нажмите «Обновить», чтобы синхронизировать данные.",
|
"Нажмите «Обновить», чтобы синхронизировать данные.",
|
||||||
stats.ServerName,
|
stats.ServerName, stats.ProductsCount, stats.SuppliersCount, stats.StoresCount, stats.InvoicesLast30, lastUpdate)
|
||||||
stats.ProductsCount,
|
|
||||||
stats.SuppliersCount,
|
|
||||||
stats.StoresCount,
|
|
||||||
stats.InvoicesLast30,
|
|
||||||
lastUpdate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML)
|
return c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
||||||
txt := "<b>💰 Ваш баланс</b>\n\n" +
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
"💵 Текущий счет: <b>0.00 ₽</b>\n" +
|
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
"💎 Тариф: <b>Free</b>\n\n" +
|
|
||||||
"Пока сервис работает в бета-режиме, использование бесплатно."
|
|
||||||
|
|
||||||
return c.EditOrSend(txt, bot.menuBalance, tele.ModeHTML)
|
txt := "<b>💰 Баланс и Тарифы</b>\n\n"
|
||||||
|
if activeServer == nil {
|
||||||
|
txt += "❌ У вас нет активного сервера. Сначала подключите сервер в меню «Серверы»."
|
||||||
|
} else {
|
||||||
|
paidUntil := "не активно"
|
||||||
|
if activeServer.PaidUntil != nil {
|
||||||
|
paidUntil = activeServer.PaidUntil.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
txt += fmt.Sprintf("🏢 Сервер: <b>%s</b>\n", activeServer.Name)
|
||||||
|
txt += fmt.Sprintf("📄 Остаток накладных: <b>%d шт.</b>\n", activeServer.Balance)
|
||||||
|
txt += fmt.Sprintf("📅 Доступен до: <b>%s</b>\n\n", paidUntil)
|
||||||
|
txt += "Выберите способ пополнения:"
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
var rows []tele.Row
|
||||||
|
if activeServer != nil {
|
||||||
|
btnTopUp := menu.Data("💳 Пополнить баланс", "bill_topup")
|
||||||
|
btnGift := menu.Data("🎁 Подарок другу", "bill_gift")
|
||||||
|
rows = append(rows, menu.Row(btnTopUp, btnGift))
|
||||||
|
}
|
||||||
|
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||||
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
menu.Inline(rows...)
|
||||||
|
|
||||||
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LOGIC HANDLERS ---
|
func (bot *Bot) renderTariffShowcase(c tele.Context, targetURL string) error {
|
||||||
|
tariffs := bot.billingService.GetTariffs()
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
txt := "<b>🛒 Выберите тарифный план</b>\n"
|
||||||
|
if targetURL != "" {
|
||||||
|
txt += fmt.Sprintf("🎁 Оформление подарка для сервера: <code>%s</code>\n", targetURL)
|
||||||
|
}
|
||||||
|
txt += "\n<b>Пакеты (разово):</b>"
|
||||||
|
|
||||||
|
var rows []tele.Row
|
||||||
|
for _, t := range tariffs {
|
||||||
|
label := fmt.Sprintf("%s — %.0f₽ (%d шт)", t.Name, t.Price, t.InvoicesCount)
|
||||||
|
btn := menu.Data(label, "buy_id_"+t.ID)
|
||||||
|
rows = append(rows, menu.Row(btn))
|
||||||
|
}
|
||||||
|
btnBack := menu.Data("🔙 Отмена", "nav_balance")
|
||||||
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
menu.Inline(rows...)
|
||||||
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
func (bot *Bot) handleCallback(c tele.Context) error {
|
func (bot *Bot) handleCallback(c tele.Context) error {
|
||||||
data := c.Callback().Data
|
data := c.Callback().Data
|
||||||
@@ -426,7 +405,11 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
|
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
// --- SELECT SERVER ---
|
// --- INTEGRATION: Billing Callbacks ---
|
||||||
|
if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") {
|
||||||
|
return bot.handleBillingCallbacks(c, data, userDB)
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(data, "set_server_") {
|
if strings.HasPrefix(data, "set_server_") {
|
||||||
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
||||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
@@ -434,22 +417,15 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
serverIDStr = serverIDStr[:idx]
|
serverIDStr = serverIDStr[:idx]
|
||||||
}
|
}
|
||||||
targetID := parseUUID(serverIDStr)
|
targetID := parseUUID(serverIDStr)
|
||||||
|
|
||||||
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
|
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
|
||||||
logger.Log.Error("Failed to set active server", zap.Error(err))
|
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: доступ запрещен"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: доступ запрещен"})
|
||||||
}
|
}
|
||||||
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
bot.rmsFactory.ClearCacheForUser(userDB.ID) // Сброс кэша
|
|
||||||
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
||||||
|
|
||||||
// Важно: перерисовываем главное меню, чтобы обновилась кнопка App (появилась/пропала)
|
|
||||||
// Но мы находимся в подменю. Логичнее остаться в ServersMenu, но кнопка App в MainMenu.
|
|
||||||
// Пользователь нажмет "Назад" и попадет в MainMenu, где сработает renderMainMenu с новой логикой.
|
|
||||||
return bot.renderServersMenu(c)
|
return bot.renderServersMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DELETE / LEAVE SERVER ---
|
|
||||||
if strings.HasPrefix(data, "do_del_server_") {
|
if strings.HasPrefix(data, "do_del_server_") {
|
||||||
serverIDStr := strings.TrimPrefix(data, "do_del_server_")
|
serverIDStr := strings.TrimPrefix(data, "do_del_server_")
|
||||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
@@ -457,12 +433,10 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
serverIDStr = serverIDStr[:idx]
|
serverIDStr = serverIDStr[:idx]
|
||||||
}
|
}
|
||||||
targetID := parseUUID(serverIDStr)
|
targetID := parseUUID(serverIDStr)
|
||||||
|
|
||||||
role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)
|
role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if role == account.RoleOwner {
|
if role == account.RoleOwner {
|
||||||
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||||
@@ -476,7 +450,6 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"})
|
c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"})
|
||||||
}
|
}
|
||||||
|
|
||||||
active, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
active, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
if active == nil {
|
if active == nil {
|
||||||
all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)
|
all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)
|
||||||
@@ -484,11 +457,9 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bot.renderDeleteServerMenu(c)
|
return bot.renderDeleteServerMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- INVITE LINK GENERATION ---
|
|
||||||
if strings.HasPrefix(data, "gen_invite_") {
|
if strings.HasPrefix(data, "gen_invite_") {
|
||||||
serverIDStr := strings.TrimPrefix(data, "gen_invite_")
|
serverIDStr := strings.TrimPrefix(data, "gen_invite_")
|
||||||
link := fmt.Sprintf("https://t.me/%s?start=invite_%s", bot.b.Me.Username, serverIDStr)
|
link := fmt.Sprintf("https://t.me/%s?start=invite_%s", bot.b.Me.Username, serverIDStr)
|
||||||
@@ -496,85 +467,138 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
return c.Send(fmt.Sprintf("🔗 <b>Ссылка для приглашения:</b>\n\n<code>%s</code>\n\nОтправьте её сотруднику.", link), tele.ModeHTML)
|
return c.Send(fmt.Sprintf("🔗 <b>Ссылка для приглашения:</b>\n\n<code>%s</code>\n\nОтправьте её сотруднику.", link), tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADMIN: SELECT SERVER -> SHOW USERS ---
|
|
||||||
if strings.HasPrefix(data, "adm_srv_") {
|
if strings.HasPrefix(data, "adm_srv_") {
|
||||||
serverIDStr := strings.TrimPrefix(data, "adm_srv_")
|
serverIDStr := strings.TrimPrefix(data, "adm_srv_")
|
||||||
serverID := parseUUID(serverIDStr)
|
serverID := parseUUID(serverIDStr)
|
||||||
return bot.renderServerUsers(c, serverID) // <--- ВЫЗОВ НОВОГО МЕТОДА
|
return bot.renderServerUsers(c, serverID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADMIN: SELECT USER -> CONFIRM OWNERSHIP ---
|
|
||||||
if strings.HasPrefix(data, "adm_usr_") {
|
if strings.HasPrefix(data, "adm_usr_") {
|
||||||
// Получаем ID связи
|
|
||||||
connIDStr := strings.TrimPrefix(data, "adm_usr_")
|
connIDStr := strings.TrimPrefix(data, "adm_usr_")
|
||||||
connID := parseUUID(connIDStr)
|
connID := parseUUID(connIDStr)
|
||||||
|
|
||||||
// Загружаем детали связи через новый метод
|
|
||||||
link, err := bot.accountRepo.GetConnectionByID(connID)
|
link, err := bot.accountRepo.GetConnectionByID(connID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем роль
|
|
||||||
if link.Role == account.RoleOwner {
|
if link.Role == account.RoleOwner {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Этот пользователь уже Владелец"})
|
return c.Respond(&tele.CallbackResponse{Text: "Этот пользователь уже Владелец"})
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
|
|
||||||
// ИСПРАВЛЕНИЕ: Для подтверждения тоже передаем ID связи
|
|
||||||
// adm_own_yes_ + UUID = 12 + 36 = 48 байт (OK)
|
|
||||||
btnYes := menu.Data("✅ Сделать Владельцем", fmt.Sprintf("adm_own_yes_%s", link.ID.String()))
|
btnYes := menu.Data("✅ Сделать Владельцем", fmt.Sprintf("adm_own_yes_%s", link.ID.String()))
|
||||||
btnNo := menu.Data("Отмена", "adm_srv_"+link.ServerID.String())
|
btnNo := menu.Data("Отмена", "adm_srv_"+link.ServerID.String())
|
||||||
|
|
||||||
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||||
|
|
||||||
txt := fmt.Sprintf("⚠️ <b>Внимание!</b>\n\nВы собираетесь передать права Владельца сервера <b>%s</b> пользователю <b>%s</b>.\n\nТекущий владелец станет Администратором.",
|
txt := fmt.Sprintf("⚠️ <b>Внимание!</b>\n\nВы собираетесь передать права Владельца сервера <b>%s</b> пользователю <b>%s</b>.\n\nТекущий владелец станет Администратором.",
|
||||||
link.Server.Name, link.User.FirstName)
|
link.Server.Name, link.User.FirstName)
|
||||||
|
|
||||||
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADMIN: EXECUTE TRANSFER ---
|
|
||||||
if strings.HasPrefix(data, "adm_own_yes_") {
|
if strings.HasPrefix(data, "adm_own_yes_") {
|
||||||
connIDStr := strings.TrimPrefix(data, "adm_own_yes_")
|
connIDStr := strings.TrimPrefix(data, "adm_own_yes_")
|
||||||
connID := parseUUID(connIDStr)
|
connID := parseUUID(connIDStr)
|
||||||
|
|
||||||
link, err := bot.accountRepo.GetConnectionByID(connID)
|
link, err := bot.accountRepo.GetConnectionByID(connID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil {
|
if err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil {
|
||||||
logger.Log.Error("Ownership transfer failed", zap.Error(err))
|
logger.Log.Error("Ownership transfer failed", zap.Error(err))
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Уведомляем нового владельца
|
|
||||||
go func() {
|
go func() {
|
||||||
msg := fmt.Sprintf("👑 <b>Поздравляем!</b>\n\nВам переданы права Владельца (OWNER) сервера <b>%s</b>.", link.Server.Name)
|
msg := fmt.Sprintf("👑 <b>Поздравляем!</b>\n\nВам переданы права Владельца (OWNER) сервера <b>%s</b>.", link.Server.Name)
|
||||||
bot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML)
|
bot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.Respond(&tele.CallbackResponse{Text: "Успешно!"})
|
c.Respond(&tele.CallbackResponse{Text: "Успешно!"})
|
||||||
|
|
||||||
// Возвращаемся к списку
|
|
||||||
return bot.renderServerUsers(c, link.ServerID)
|
return bot.renderServerUsers(c, link.ServerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Вспомогательный метод рендера списка пользователей ---
|
// Реализация метода интерфейса PaymentNotifier
|
||||||
|
func (bot *Bot) NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string) {
|
||||||
|
|
||||||
|
user, err := bot.accountRepo.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Failed to find user for payment notification", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"✅ <b>Оплата получена!</b>\n\n"+
|
||||||
|
"Сумма: <b>%.2f ₽</b>\n"+
|
||||||
|
"Сервер: <b>%s</b>\n"+
|
||||||
|
"Текущий баланс: <b>%d накладных</b>\n\n"+
|
||||||
|
"Спасибо за использование RMSer!",
|
||||||
|
amount, serverName, newBalance,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error {
|
||||||
|
if data == "bill_topup" {
|
||||||
|
return bot.renderTariffShowcase(c, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == "bill_gift" {
|
||||||
|
bot.fsm.SetState(c.Sender().ID, StateBillingGiftURL)
|
||||||
|
return c.EditOrSend("🎁 <b>Режим подарка</b>\n\nВведите URL сервера, который хотите пополнить (например: <code>https://myresto.iiko.it</code>).\n\n<i>Сервер должен быть уже зарегистрирован в нашей системе.</i>", tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "pay_confirm_") {
|
||||||
|
orderIDStr := strings.TrimPrefix(data, "pay_confirm_")
|
||||||
|
orderID, _ := uuid.Parse(orderIDStr)
|
||||||
|
if err := bot.billingService.ConfirmOrder(orderID); err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
||||||
|
}
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "✨ Оплата успешно имитирована!"})
|
||||||
|
bot.fsm.Reset(c.Sender().ID)
|
||||||
|
return c.EditOrSend("✅ <b>Оплата прошла успешно!</b>\n\nУслуги начислены на баланс сервера. Теперь вы можете продолжить работу с накладными.", bot.menuBalance, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "buy_id_") {
|
||||||
|
tariffID := strings.TrimPrefix(data, "buy_id_")
|
||||||
|
ctxFSM := bot.fsm.GetContext(c.Sender().ID)
|
||||||
|
|
||||||
|
// 1. Формируем URL возврата (ссылка на бота)
|
||||||
|
returnURL := fmt.Sprintf("https://t.me/%s", bot.b.Me.Username)
|
||||||
|
|
||||||
|
// 2. Вызываем обновленный метод (теперь возвращает 3 значения)
|
||||||
|
order, payURL, err := bot.billingService.CreateOrder(
|
||||||
|
context.Background(),
|
||||||
|
userDB.ID,
|
||||||
|
tariffID,
|
||||||
|
ctxFSM.BillingTargetURL,
|
||||||
|
returnURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка при формировании счета: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Создаем кнопку с URL на ЮКассу
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnPay := menu.URL("💳 Оплатить", payURL) // payURL теперь string
|
||||||
|
btnBack := menu.Data("🔙 Отмена", "nav_balance")
|
||||||
|
menu.Inline(menu.Row(btnPay), menu.Row(btnBack))
|
||||||
|
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"📦 <b>Заказ №%s</b>\n\nСумма к оплате: <b>%.2f ₽</b>\n\nНажмите кнопку ниже для перехода к оплате. Баланс будет пополнен автоматически сразу после подтверждения платежа.",
|
||||||
|
order.ID.String()[:8], // Показываем короткий ID для красоты
|
||||||
|
order.Amount,
|
||||||
|
)
|
||||||
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error {
|
func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error {
|
||||||
users, err := bot.accountRepo.GetServerUsers(serverID)
|
users, err := bot.accountRepo.GetServerUsers(serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка загрузки юзеров"})
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка загрузки юзеров"})
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
var rows []tele.Row
|
var rows []tele.Row
|
||||||
|
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
roleIcon := "👤"
|
roleIcon := "👤"
|
||||||
if u.Role == account.RoleOwner {
|
if u.Role == account.RoleOwner {
|
||||||
@@ -583,45 +607,32 @@ func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error {
|
|||||||
if u.Role == account.RoleAdmin {
|
if u.Role == account.RoleAdmin {
|
||||||
roleIcon = "⭐️"
|
roleIcon = "⭐️"
|
||||||
}
|
}
|
||||||
|
|
||||||
label := fmt.Sprintf("%s %s %s", roleIcon, u.User.FirstName, u.User.LastName)
|
label := fmt.Sprintf("%s %s %s", roleIcon, u.User.FirstName, u.User.LastName)
|
||||||
// Используем ID связи
|
|
||||||
payload := fmt.Sprintf("adm_usr_%s", u.ID.String())
|
payload := fmt.Sprintf("adm_usr_%s", u.ID.String())
|
||||||
|
|
||||||
btn := menu.Data(label, payload)
|
btn := menu.Data(label, payload)
|
||||||
rows = append(rows, menu.Row(btn))
|
rows = append(rows, menu.Row(btn))
|
||||||
}
|
}
|
||||||
|
|
||||||
btnBack := menu.Data("🔙 К серверам", "adm_list_servers")
|
btnBack := menu.Data("🔙 К серверам", "adm_list_servers")
|
||||||
rows = append(rows, menu.Row(btnBack))
|
rows = append(rows, menu.Row(btnBack))
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
|
|
||||||
// Для заголовка нам нужно имя сервера, но в users[0].Server оно есть (Preload),
|
|
||||||
// либо если юзеров нет (пустой сервер?), то имя не узнаем без доп запроса.
|
|
||||||
// Но пустой сервер вряд ли будет, там как минимум Owner.
|
|
||||||
serverName := "Unknown"
|
serverName := "Unknown"
|
||||||
if len(users) > 0 {
|
if len(users) > 0 {
|
||||||
serverName = users[0].Server.Name
|
serverName = users[0].Server.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.EditOrSend(fmt.Sprintf("👥 Пользователи сервера <b>%s</b>:", serverName), menu, tele.ModeHTML)
|
return c.EditOrSend(fmt.Sprintf("👥 Пользователи сервера <b>%s</b>:", serverName), menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) triggerSync(c tele.Context) error {
|
func (bot *Bot) triggerSync(c tele.Context) error {
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
server, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
server, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
if err != nil || server == nil {
|
if err != nil || server == nil {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"})
|
return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"})
|
||||||
}
|
}
|
||||||
|
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||||
if role == account.RoleOperator {
|
if role == account.RoleOperator {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "⚠️ Синхронизация доступна только Админам", ShowAlert: true})
|
return c.Respond(&tele.CallbackResponse{Text: "⚠️ Синхронизация доступна только Админам", ShowAlert: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
|
c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := bot.syncService.SyncAllData(userDB.ID); err != nil {
|
if err := bot.syncService.SyncAllData(userDB.ID); err != nil {
|
||||||
logger.Log.Error("Manual sync failed", zap.Error(err))
|
logger.Log.Error("Manual sync failed", zap.Error(err))
|
||||||
@@ -630,11 +641,9 @@ func (bot *Bot) triggerSync(c tele.Context) error {
|
|||||||
bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!")
|
bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FSM: ADD SERVER FLOW ---
|
|
||||||
func (bot *Bot) startAddServerFlow(c tele.Context) error {
|
func (bot *Bot) startAddServerFlow(c tele.Context) error {
|
||||||
bot.fsm.SetState(c.Sender().ID, StateAddServerURL)
|
bot.fsm.SetState(c.Sender().ID, StateAddServerURL)
|
||||||
return c.EditOrSend("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://resto.iiko.it</code>\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
return c.EditOrSend("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://resto.iiko.it</code>\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
||||||
@@ -676,26 +685,21 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
password := text
|
password := text
|
||||||
ctx := bot.fsm.GetContext(userID)
|
ctx := bot.fsm.GetContext(userID)
|
||||||
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||||
|
|
||||||
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
||||||
if err := tempClient.Auth(); err != nil {
|
if err := tempClient.Auth(); err != nil {
|
||||||
bot.b.Delete(msg)
|
bot.b.Delete(msg)
|
||||||
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
|
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
var detectedName string
|
var detectedName string
|
||||||
info, err := rms.GetServerInfo(ctx.TempURL)
|
info, err := rms.GetServerInfo(ctx.TempURL)
|
||||||
if err == nil && info.ServerName != "" {
|
if err == nil && info.ServerName != "" {
|
||||||
detectedName = info.ServerName
|
detectedName = info.ServerName
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.b.Delete(msg)
|
bot.b.Delete(msg)
|
||||||
|
|
||||||
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
|
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
|
||||||
uCtx.TempPassword = password
|
uCtx.TempPassword = password
|
||||||
uCtx.TempServerName = detectedName
|
uCtx.TempServerName = detectedName
|
||||||
})
|
})
|
||||||
|
|
||||||
if detectedName != "" {
|
if detectedName != "" {
|
||||||
bot.fsm.SetState(userID, StateAddServerConfirmName)
|
bot.fsm.SetState(userID, StateAddServerConfirmName)
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
@@ -704,7 +708,6 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||||
return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
|
return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.fsm.SetState(userID, StateAddServerInputName)
|
bot.fsm.SetState(userID, StateAddServerInputName)
|
||||||
return c.Send("🏷 Введите <b>название</b> для этого сервера:")
|
return c.Send("🏷 Введите <b>название</b> для этого сервера:")
|
||||||
|
|
||||||
@@ -714,6 +717,19 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
return c.Send("⚠️ Название слишком короткое.")
|
return c.Send("⚠️ Название слишком короткое.")
|
||||||
}
|
}
|
||||||
return bot.saveServerFinal(c, userID, name)
|
return bot.saveServerFinal(c, userID, name)
|
||||||
|
|
||||||
|
case StateBillingGiftURL:
|
||||||
|
if !strings.HasPrefix(text, "http") {
|
||||||
|
return c.Send("❌ Некорректный URL. Он должен начинаться с http:// или https://")
|
||||||
|
}
|
||||||
|
_, err := bot.accountRepo.GetServerByURL(text)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("🔍 Сервер с таким URL не найден в системе. Попросите владельца сначала подключить его к боту.")
|
||||||
|
}
|
||||||
|
bot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {
|
||||||
|
ctx.BillingTargetURL = text
|
||||||
|
})
|
||||||
|
return bot.renderTariffShowcase(c, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -724,60 +740,48 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка базы данных пользователей")
|
return c.Send("Ошибка базы данных пользователей")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
|
return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
|
||||||
}
|
}
|
||||||
|
|
||||||
photo := c.Message().Photo
|
photo := c.Message().Photo
|
||||||
file, err := bot.b.FileByID(photo.FileID)
|
file, err := bot.b.FileByID(photo.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка доступа к файлу.")
|
return c.Send("Ошибка доступа к файлу.")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
||||||
resp, err := http.Get(fileURL)
|
resp, err := http.Get(fileURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка скачивания файла.")
|
return c.Send("Ошибка скачивания файла.")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
imgData, err := io.ReadAll(resp.Body)
|
imgData, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка чтения файла.")
|
return c.Send("Ошибка чтения файла.")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
|
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
matchedCount := 0
|
matchedCount := 0
|
||||||
for _, item := range draft.Items {
|
for _, item := range draft.Items {
|
||||||
if item.IsMatched {
|
if item.IsMatched {
|
||||||
matchedCount++
|
matchedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||||
|
|
||||||
var msgText string
|
var msgText string
|
||||||
if matchedCount == len(draft.Items) {
|
if matchedCount == len(draft.Items) {
|
||||||
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
||||||
} else {
|
} else {
|
||||||
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items))
|
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
|
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||||
if role != account.RoleOperator {
|
if role != account.RoleOperator {
|
||||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
||||||
@@ -785,7 +789,6 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Send(msgText, menu, tele.ModeHTML)
|
return c.Send(msgText, menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,25 +810,15 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
|
|||||||
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
||||||
ctx := bot.fsm.GetContext(userID)
|
ctx := bot.fsm.GetContext(userID)
|
||||||
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||||
|
|
||||||
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
||||||
|
|
||||||
server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)
|
server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка подключения сервера: " + err.Error())
|
return c.Send("Ошибка подключения сервера: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.fsm.Reset(userID)
|
bot.fsm.Reset(userID)
|
||||||
|
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||||
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> подключен!\nВаша роль: <b>%s</b>", server.Name, role), tele.ModeHTML)
|
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> подключен!\nВаша роль: <b>%s</b>", server.Name, role), tele.ModeHTML)
|
||||||
|
go bot.syncService.SyncAllData(userDB.ID)
|
||||||
if role == account.RoleOwner {
|
|
||||||
go bot.syncService.SyncAllData(userDB.ID)
|
|
||||||
} else {
|
|
||||||
go bot.syncService.SyncAllData(userDB.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return bot.renderMainMenu(c)
|
return bot.renderMainMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,26 +828,20 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка БД: " + err.Error())
|
return c.Send("Ошибка БД: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(servers) == 0 {
|
if len(servers) == 0 {
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"})
|
return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"})
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
var rows []tele.Row
|
var rows []tele.Row
|
||||||
|
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
||||||
|
|
||||||
var label string
|
var label string
|
||||||
if role == account.RoleOwner {
|
if role == account.RoleOwner {
|
||||||
label = fmt.Sprintf("❌ Удалить %s (Owner)", s.Name)
|
label = fmt.Sprintf("❌ Удалить %s (Owner)", s.Name)
|
||||||
} else {
|
} else {
|
||||||
label = fmt.Sprintf("🚪 Покинуть %s", s.Name)
|
label = fmt.Sprintf("🚪 Покинуть %s", s.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
btnAction := menu.Data(label, "do_del_server_"+s.ID.String())
|
btnAction := menu.Data(label, "do_del_server_"+s.ID.String())
|
||||||
|
|
||||||
if role == account.RoleOwner || role == account.RoleAdmin {
|
if role == account.RoleOwner || role == account.RoleAdmin {
|
||||||
btnInvite := menu.Data(fmt.Sprintf("📩 Invite %s", s.Name), "gen_invite_"+s.ID.String())
|
btnInvite := menu.Data(fmt.Sprintf("📩 Invite %s", s.Name), "gen_invite_"+s.ID.String())
|
||||||
rows = append(rows, menu.Row(btnAction, btnInvite))
|
rows = append(rows, menu.Row(btnAction, btnInvite))
|
||||||
@@ -862,12 +849,9 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
|||||||
rows = append(rows, menu.Row(btnAction))
|
rows = append(rows, menu.Row(btnAction))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
|
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
|
||||||
rows = append(rows, menu.Row(btnBack))
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
|
|
||||||
return c.EditOrSend("⚙️ <b>Управление серверами</b>\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML)
|
return c.EditOrSend("⚙️ <b>Управление серверами</b>\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ const (
|
|||||||
StateAddServerPassword
|
StateAddServerPassword
|
||||||
StateAddServerConfirmName
|
StateAddServerConfirmName
|
||||||
StateAddServerInputName
|
StateAddServerInputName
|
||||||
|
StateBillingGiftURL
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserContext хранит временные данные в процессе диалога
|
// UserContext хранит временные данные в процессе диалога
|
||||||
type UserContext struct {
|
type UserContext struct {
|
||||||
State State
|
State State
|
||||||
TempURL string
|
TempURL string
|
||||||
TempLogin string
|
TempLogin string
|
||||||
TempPassword string
|
TempPassword string
|
||||||
TempServerName string
|
TempServerName string
|
||||||
|
BillingTargetURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateManager управляет состояниями
|
// StateManager управляет состояниями
|
||||||
|
|||||||
Reference in New Issue
Block a user