mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
hot fix #1
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,375 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
eshop_controllers "fotbal-club/internal/controllers/eshop"
|
||||
"fotbal-club/internal/middleware"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/database"
|
||||
"fotbal-club/pkg/email"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load shared configuration
|
||||
config.LoadConfig()
|
||||
|
||||
// Logger level based on DEBUG flag
|
||||
if config.AppConfig != nil && config.AppConfig.Debug {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
log.Println("[eshop] Logger level set to DEBUG")
|
||||
} else {
|
||||
logger.SetLevel(logger.LevelInfo)
|
||||
}
|
||||
|
||||
// Gin mode
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// Initialize shared database connection
|
||||
dbInstance, err := database.InitDB()
|
||||
if err != nil {
|
||||
log.Fatalf("[eshop] Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Optional migrations for eshop-specific tables only (will be added later)
|
||||
runMigrations, _ := strconv.ParseBool(os.Getenv("RUN_MIGRATIONS"))
|
||||
if runMigrations {
|
||||
log.Println("[eshop] RUN_MIGRATIONS is true, but no eshop-specific migrations are defined yet")
|
||||
}
|
||||
|
||||
// Initialize Gin router with a similar hardened stack as the main backend
|
||||
reporter := services.NewErrorReporter(config.AppConfig)
|
||||
r := gin.New()
|
||||
r.Use(middleware.CustomRecoveryWithReporter(reporter))
|
||||
if err := r.SetTrustedProxies(nil); err != nil {
|
||||
log.Printf("[eshop] Trusted proxies setup error: %v", err)
|
||||
}
|
||||
|
||||
r.Use(middleware.RequestID())
|
||||
r.Use(middleware.RequestLogger())
|
||||
r.Use(middleware.ErrorStatusReporter(reporter))
|
||||
r.Use(middleware.SanitizeHeaders())
|
||||
r.Use(middleware.DBContext())
|
||||
r.Use(middleware.RequestSizeLimit(2 * 1024 * 1024))
|
||||
r.Use(middleware.ValidateContentType())
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
|
||||
// E-shop CORS – reuse AllowedOrigins from shared config
|
||||
r.Use(func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
allowed := false
|
||||
|
||||
// Explicit exact-origin allow list
|
||||
for _, ao := range config.AppConfig.AllowedOrigins {
|
||||
if ao == origin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard support
|
||||
if !allowed {
|
||||
for _, ao := range config.AppConfig.AllowedOrigins {
|
||||
if ao == "*" && origin != "" {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relaxed rule for non-production local dev
|
||||
if !allowed && origin != "" && config.AppConfig.AppEnv != "production" {
|
||||
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") ||
|
||||
strings.HasPrefix(origin, "https://localhost:") || strings.HasPrefix(origin, "https://127.0.0.1:") {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
if allowed && origin != "" {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Vary", "Origin")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With, X-Session-Token, X-Admin-Token, X-Dev-Admin, X-CSRF-Token")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Attach DB-backed email service for future order emails (not used yet)
|
||||
_ = email.NewEmailService(config.AppConfig, dbInstance)
|
||||
|
||||
// API group for E-shop
|
||||
api := r.Group("/api/v1/eshop")
|
||||
|
||||
// Healthcheck
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"service": "myclub-eshop-backend",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
api.HEAD("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"service": "myclub-eshop-backend",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// Admin Setup & Management Endpoints
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.JWTAuth(dbInstance))
|
||||
admin.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
// GET /api/v1/eshop/admin/settings
|
||||
admin.GET("/settings", func(c *gin.Context) {
|
||||
var settings models.EshopSettings
|
||||
if err := dbInstance.First(&settings).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Return defaults if not found
|
||||
c.JSON(http.StatusOK, models.EshopSettings{
|
||||
DefaultCurrency: "CZK",
|
||||
DefaultCountry: "CZ",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
|
||||
// PUT /api/v1/eshop/admin/settings
|
||||
admin.PUT("/settings", func(c *gin.Context) {
|
||||
var body models.EshopSettings
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var settings models.EshopSettings
|
||||
if err := dbInstance.First(&settings).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new
|
||||
settings = body
|
||||
// Ensure ID is 0 or handled by GORM for creation
|
||||
settings.ID = 0
|
||||
if err := dbInstance.Create(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update existing
|
||||
settings.DefaultCurrency = body.DefaultCurrency
|
||||
settings.SupportedCurrencies = body.SupportedCurrencies
|
||||
settings.DefaultCountry = body.DefaultCountry
|
||||
settings.ShippingOptionsJSON = body.ShippingOptionsJSON
|
||||
settings.TermsURL = body.TermsURL
|
||||
settings.ReturnsPolicyURL = body.ReturnsPolicyURL
|
||||
settings.SupportEmail = body.SupportEmail
|
||||
settings.SupportPhone = body.SupportPhone
|
||||
|
||||
if err := dbInstance.Save(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
|
||||
// GET /api/v1/eshop/admin/club-info
|
||||
// Returns main Club Settings to pre-fill E-shop Setup
|
||||
admin.GET("/club-info", func(c *gin.Context) {
|
||||
var clubSettings models.Settings
|
||||
if err := dbInstance.First(&clubSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load club settings"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"club_name": clubSettings.ClubName,
|
||||
"club_logo_url": clubSettings.ClubLogoURL,
|
||||
"contact_email": clubSettings.ContactEmail,
|
||||
"contact_phone": clubSettings.ContactPhone,
|
||||
"contact_country": clubSettings.ContactCountry,
|
||||
"primary_color": clubSettings.PrimaryColor,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Shipping controller & endpoints
|
||||
shippingCtrl := eshop_controllers.NewShippingController(config.AppConfig)
|
||||
|
||||
// Admin Shipping Actions
|
||||
admin.POST("/orders/:id/create-packet", shippingCtrl.CreatePacket)
|
||||
|
||||
// Public Shipping endpoints
|
||||
shipping := api.Group("/shipping")
|
||||
{
|
||||
shipping.GET("/packeta-widget-config", shippingCtrl.GetPacketaWidgetConfig)
|
||||
shipping.GET("/labels/:packet_id", shippingCtrl.DownloadLabel)
|
||||
}
|
||||
|
||||
// Support chat routes
|
||||
support := api.Group("/support")
|
||||
support.Use(middleware.JWTOptional(dbInstance), middleware.RateLimit(60, time.Minute))
|
||||
RegisterSupportRoutes(support, dbInstance)
|
||||
|
||||
checkoutCtrl := eshop_controllers.NewCheckoutController(dbInstance, config.AppConfig)
|
||||
api.POST("/checkout", middleware.JWTOptional(dbInstance), middleware.RateLimit(10, time.Minute), checkoutCtrl.Checkout)
|
||||
|
||||
// Payment webhooks (Revolut, Stripe)
|
||||
payments := api.Group("/payments")
|
||||
{
|
||||
payments.POST("/revolut/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.RevolutWebhook)
|
||||
payments.POST("/stripe/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.StripeWebhook)
|
||||
}
|
||||
|
||||
// Background jobs
|
||||
go func() {
|
||||
// Update packet statuses every hour
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if config.AppConfig.PacketaAPIPassword != "" {
|
||||
shippingCtrl.UpdatePacketStatuses(dbInstance)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Public catalog endpoints (auth optional)
|
||||
productsPub := api.Group("/products")
|
||||
productsPub.Use(middleware.JWTOptional(dbInstance))
|
||||
{
|
||||
// GET /api/v1/eshop/products
|
||||
productsPub.GET("", func(c *gin.Context) {
|
||||
var items []models.EshopProduct
|
||||
q := dbInstance.
|
||||
Preload("Category").
|
||||
Preload("Variants").
|
||||
Where("active = ?", true).
|
||||
Order("created_at DESC, id DESC")
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load products"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
})
|
||||
|
||||
// GET /api/v1/eshop/products/:slug
|
||||
productsPub.GET(":slug", func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
if strings.TrimSpace(slug) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing product slug"})
|
||||
return
|
||||
}
|
||||
var item models.EshopProduct
|
||||
if err := dbInstance.
|
||||
Preload("Category").
|
||||
Preload("Variants").
|
||||
Where("slug = ? AND active = ?", slug, true).
|
||||
First(&item).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
// Cart controller
|
||||
cartCtrl := eshop_controllers.NewCartController(dbInstance, config.AppConfig)
|
||||
|
||||
// Public cart endpoints (auth optional, tied to user or session)
|
||||
cart := api.Group("/cart")
|
||||
cart.Use(middleware.JWTOptional(dbInstance))
|
||||
{
|
||||
cart.GET("", cartCtrl.GetCart)
|
||||
cart.POST("/items", cartCtrl.AddItem)
|
||||
cart.PATCH("/items/:id", cartCtrl.UpdateItem)
|
||||
cart.DELETE("/items/:id", cartCtrl.RemoveItem)
|
||||
}
|
||||
|
||||
// GET /api/v1/eshop/orders/:id
|
||||
api.GET("/orders/:id", middleware.JWTOptional(dbInstance), checkoutCtrl.GetOrder)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if strings.TrimSpace(port) == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: r,
|
||||
ReadTimeout: config.AppConfig.ReadTimeout,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: config.AppConfig.WriteTimeout,
|
||||
IdleTimeout: config.AppConfig.IdleTimeout,
|
||||
}
|
||||
|
||||
// Graceful shutdown and DB close
|
||||
sqlDB, err := dbInstance.DB()
|
||||
if err != nil {
|
||||
log.Printf("[eshop] Error getting database connection: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[eshop] Server starting on port %s\n", port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[eshop] Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("[eshop] Shutdown signal received, shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("[eshop] Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
if sqlDB != nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
log.Printf("[eshop] Error closing database connection: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// systemPrompt is hardcoded here to ensure it is available in the binary without complex build steps
|
||||
const systemPrompt = `# DeepSeek – systémový prompt pro MyClub E‑shop podporu
|
||||
|
||||
Tento prompt je určen jako **system message** pro model ` + "`deepseek-chat`" + ` používaný v zákaznické podpoře MyClub e‑shopu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Role a identita asistenta
|
||||
|
||||
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový e‑shop **MyClub**.
|
||||
|
||||
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
|
||||
- E‑shop je úzce napojený na hlavní web klubu (MyClub CMS) a používá stejný uživatelský účet.
|
||||
- Zákazníci jsou převážně **čeští a slovenští fanoušci**, často méně technicky zdatní.
|
||||
|
||||
Mluv **především česky**, pokud není z kontextu zřejmé, že uživatel preferuje jiný jazyk (např. angličtinu). Pokud dotaz přijde slovensky, odpovídej slovensky, ale jednoduše a srozumitelně.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cíle a tón komunikace
|
||||
|
||||
- Buď **přátelský, věcný a stručný**.
|
||||
- Vysvětluj kroky **jednoduchým jazykem** (žádný technický žargon pro běžné uživatele).
|
||||
- Neslibuj nic, co z interního kontextu nevyplývá (termíny doručení, dostupnost, slevy apod.).
|
||||
- Pokud si nejsi jistý, napiš to otevřeně a navrhni kontakt na podporu (email/telefon), který dostaneš v kontextu.
|
||||
|
||||
Příklady vhodného tónu:
|
||||
- „Jasně, poradím. Teď prosím klikněte na…“
|
||||
- „Děkuju za trpělivost, podívám se na to.“
|
||||
- „Tomuhle bohužel přesně nerozumím, ale napíšu, jak to ověřit u podpory.“
|
||||
|
||||
---
|
||||
|
||||
## 3. Kontext, který dostaneš od backendu
|
||||
|
||||
Backend ti bude posílat strukturovaný kontext v JSONu v systémové nebo "tool" části zprávy. Nepředpokládej nic, co v kontextu chybí.
|
||||
|
||||
Příklady kontextu (může se lišit):
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"store": {
|
||||
"store_name": "{{STORE_NAME}}",
|
||||
"club_name": "{{CLUB_NAME}}",
|
||||
"primary_language": "cs",
|
||||
"supported_languages": ["cs", "sk", "en"],
|
||||
"support_email": "{{SUPPORT_EMAIL}}",
|
||||
"support_phone": "{{SUPPORT_PHONE}}",
|
||||
"returns_policy_url": "{{RETURNS_URL}}",
|
||||
"terms_url": "{{TERMS_URL}}"
|
||||
},
|
||||
"user": {
|
||||
"is_logged_in": true,
|
||||
"name": "{{USER_NAME}}",
|
||||
"email": "{{USER_EMAIL}}"
|
||||
},
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "{{ORDER_ID}}",
|
||||
"created_at": "{{ISO_DATETIME}}",
|
||||
"status": "paid | awaiting_payment | shipped | delivered | cancelled | refunded",
|
||||
"total_amount": 1290,
|
||||
"currency": "CZK",
|
||||
"shipping_method": "packeta | courier | pickup",
|
||||
"shipping_status": "label_created | handed_to_carrier | in_transit | ready_for_pickup | delivered",
|
||||
"tracking_url": "{{TRACKING_URL}}"
|
||||
}
|
||||
],
|
||||
"shipping": {
|
||||
"packeta_enabled": true,
|
||||
"estimated_delivery_days": "2-4",
|
||||
"regions": ["CZ", "SK"]
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
V odpovědích **vždy respektuj tento kontext**. Pokud nějaká informace v kontextu není, otevřeně přiznej, že ji neznáš.
|
||||
|
||||
---
|
||||
|
||||
## 4. Práce s objednávkami a dopravou
|
||||
|
||||
### 4.1 Stav objednávky
|
||||
|
||||
- Stav objednávky vysvětluj lidsky (česky), např.:
|
||||
- ` + "`awaiting_payment`" + ` → „Čekáme na dokončení platby.“
|
||||
- ` + "`paid`" + ` → „Objednávka je zaplacená a připravuje se k odeslání.“
|
||||
- ` + "`shipped` / `in_transit`" + ` → „Balík je na cestě k vám.“
|
||||
- ` + "`ready_for_pickup`" + ` → „Balík je připravený k vyzvednutí na výdejním místě.“
|
||||
- ` + "`delivered`" + ` → „Objednávka byla doručena.“
|
||||
- ` + "`cancelled` / `refunded`" + ` → vysvětlit, že objednávka byla zrušená / peníze vrácené.
|
||||
|
||||
Pokud máš v kontextu ` + "`tracking_url`" + `, nabídni uživateli, ať si stav ověří přímo tam.
|
||||
|
||||
### 4.2 Packeta (Zásilkovna)
|
||||
|
||||
- Vysvětluj princip jednoduše:
|
||||
- Zákazník si při objednávce vybral **výdejní místo** nebo **Z‑BOX**.
|
||||
- Po odeslání obdrží SMS/email od Packety s kódem pro vyzvednutí.
|
||||
- Nepopisuj vnitřní technické detaily (API, XML, Packeta.Widget…), pouze uživatelské kroky.
|
||||
- Pokud chybí informace o zásilce, řekni, že nemáš detailní data a ať zákazník použije tracking link nebo kontaktuje podporu.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platby (Stripe)
|
||||
|
||||
- Platební bránu popisuj jako **"bezpečnou online platbu kartou"**.
|
||||
- Nikdy netvrď, že máš přístup k číslům karet – nemáš a mít nebudeš.
|
||||
- Pokud je stav platby ` + "`awaiting_payment`" + ` nebo ` + "`payment_failed`" + `:
|
||||
- Navrhni zkontrolovat údaje karty, případně zkusit jinou kartu nebo prohlížeč.
|
||||
- Pokud problém přetrvá, doporuč kontaktovat podporu (email/telefon z kontextu).
|
||||
- Pokud je platba ` + "`paid`" + ` a objednávka ještě není odeslaná, uklidni uživatele, že je vše v pořádku a objednávka se připravuje.
|
||||
|
||||
---
|
||||
|
||||
## 6. Typické scénáře a jak odpovídat
|
||||
|
||||
### 6.1 „Kdy mi přijde balík?“
|
||||
|
||||
1. Podívej se, zda máš v kontextu poslední objednávku a její ` + "`status`" + ` + případně ` + "`shipping_status`" + ` a ` + "`estimated_delivery_days`" + `.
|
||||
2. Odpověz konkrétně:
|
||||
- „Vaše poslední objednávka **#1234** je aktuálně ve stavu **předáno dopravci**. Obvykle dorazí za **2–4 pracovní dny**.“
|
||||
3. Pokud nic z toho nemáš, napiš:
|
||||
- „Bohužel tady nevidím detailní informace k vaší objednávce. Prosím ověřte stav v e‑shopu po přihlášení, nebo napište na {{SUPPORT_EMAIL}}.“
|
||||
|
||||
### 6.2 „Chci změnit výdejní místo / adresu“
|
||||
|
||||
- Vysvětli, že změna po odeslání je omezená a může ji řešit jen lidská podpora.
|
||||
- Navrhni konkrétní postup:
|
||||
- „Přepište prosím číslo objednávky a novou adresu do emailu na {{SUPPORT_EMAIL}}. Kolegové zkusí změnu provést, pokud to dopravce ještě umožňuje.“
|
||||
|
||||
### 6.3 „Nepřišel mi potvrzovací email“
|
||||
|
||||
- Doporuč kroky:
|
||||
- Zkontrolovat spam/promo složky.
|
||||
- Ověřit, že adresa v účtu je správná.
|
||||
- Případně kontaktovat podporu s číslem objednávky.
|
||||
|
||||
---
|
||||
|
||||
## 7. Limity asistenta a eskalace
|
||||
|
||||
- Nikdy **nevymýšlej konkrétní čísla objednávek, částky nebo přesné termíny**, pokud je nemáš v kontextu.
|
||||
- Při nejistotě nebo chybějících datech **vždy nabídni kontakt na podporu**:
|
||||
- „Toto bohužel z chatu nevidím. Prosím napište na {{SUPPORT_EMAIL}} a přidejte číslo objednávky. Kolegové do toho mohou nahlédnout v systému.“
|
||||
|
||||
- Pokud se uživatel ptá na:
|
||||
- právní záležitosti (reklamace, odstoupení od smlouvy),
|
||||
- reklamaci zboží,
|
||||
- vrácení peněz,
|
||||
- nebo cokoliv, co vyžaduje manuální schválení,
|
||||
|
||||
vysvětli obecný princip (např. „máte 14 dní na vrácení při online nákupu“, pokud je to v kontextu nebo běžné v EU), ale nakonec přesměruj na oficiální podmínky (` + "`terms_url`" + `, ` + "`returns_policy_url`" + `) a na lidskou podporu.
|
||||
|
||||
---
|
||||
|
||||
## 8. Styl odpovědí
|
||||
|
||||
- Krátké odstavce, maximálně 2–3 věty, pokud uživatel nechce detail.
|
||||
- Používej **tučné zvýraznění** pro důležité informace (číslo objednávky, stav, důležitá akce).
|
||||
- Pokud vysvětluješ postup, napiš ho v očíslovaných krocích.
|
||||
|
||||
Příklad:
|
||||
|
||||
> "Abychom to vyřešili, prosím postupujte takto:\n\n1. Přihlaste se do e‑shopu.\n2. V menu zvolte **Moje objednávky**.\n3. Ověřte stav objednávky **#1234**.\n4. Pokud je tam stále uvedeno ` + "`čeká na platbu`" + `, zkuste prosím platbu znovu nebo nás kontaktujte na {{SUPPORT_EMAIL}}."
|
||||
|
||||
---
|
||||
|
||||
## 9. Co dělat při chybějícím nebo nekonzistentním kontextu
|
||||
|
||||
- Pokud backend pošle prázdný nebo neúplný kontext:
|
||||
- Nesnaž se hádat, jaký je stav objednávky nebo zásilky.
|
||||
- Vysvětli, že z chatu nemáš přímý přístup k internímu systému a uživatel musí použít svůj účet nebo kontaktovat podporu.
|
||||
|
||||
Příklad:
|
||||
|
||||
> "Bohužel tady v chatu nevidím detailní data o vaší objednávce. Prosím přihlaste se do e‑shopu a podívejte se do sekce **Moje objednávky**, nebo napište na {{SUPPORT_EMAIL}} s číslem objednávky."
|
||||
|
||||
---
|
||||
|
||||
## 10. Shrnutí
|
||||
|
||||
- Vždy se opírej o data z kontextu.
|
||||
- Odpovídej česky/slovensky, jednoduše a přátelsky.
|
||||
- Neimprovizuj v číslech, částkách ani termínech.
|
||||
- Pokud si nejsi jistý, uveď to a přesměruj uživatele na email/telefon podpory nebo sekci **Moje objednávky** v e‑shopu.
|
||||
`
|
||||
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message"`
|
||||
History []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"history"`
|
||||
}
|
||||
|
||||
type DeepSeekRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// DeepSeekStreamResponse represents the SSE chunk structure from DeepSeek API
|
||||
type DeepSeekStreamResponse struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// RegisterSupportRoutes adds support endpoints to the router
|
||||
func RegisterSupportRoutes(g *gin.RouterGroup, db *gorm.DB) {
|
||||
g.POST("/chat/stream", func(c *gin.Context) {
|
||||
SupportHandler(c, db)
|
||||
})
|
||||
}
|
||||
|
||||
func SupportHandler(c *gin.Context, db *gorm.DB) {
|
||||
// 1. Check if DeepSeek API key is configured
|
||||
if config.AppConfig.DeepSeekAPIKey == "" {
|
||||
log.Println("[eshop] DeepSeek API key not configured")
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Chat support unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Build Context
|
||||
ctxData := buildContext(c, db)
|
||||
ctxJSON, _ := json.Marshal(ctxData)
|
||||
|
||||
// 3. Prepare messages
|
||||
messages := []Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: systemPrompt + "\n\nCONTEXT:\n" + string(ctxJSON),
|
||||
},
|
||||
}
|
||||
|
||||
// Add history (limit to last 10 messages to save tokens)
|
||||
limit := 10
|
||||
if len(req.History) > limit {
|
||||
req.History = req.History[len(req.History)-limit:]
|
||||
}
|
||||
for _, h := range req.History {
|
||||
messages = append(messages, Message{
|
||||
Role: h.Role,
|
||||
Content: h.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
messages = append(messages, Message{
|
||||
Role: "user",
|
||||
Content: req.Message,
|
||||
})
|
||||
|
||||
// 4. Call DeepSeek API
|
||||
deepSeekReq := DeepSeekRequest{
|
||||
Model: "deepseek-chat",
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
reqBody, _ := json.Marshal(deepSeekReq)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second, // Long timeout for streaming
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(config.AppConfig.DeepSeekBaseURL, "/") + "/chat/completions"
|
||||
proxyReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
log.Printf("[eshop] Failed to create DeepSeek request: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
proxyReq.Header.Set("Content-Type", "application/json")
|
||||
proxyReq.Header.Set("Authorization", "Bearer "+config.AppConfig.DeepSeekAPIKey)
|
||||
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
log.Printf("[eshop] DeepSeek API call failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service unavailable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("[eshop] DeepSeek API error %d: %s", resp.StatusCode, string(body))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Stream response
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Transfer-Encoding", "chunked")
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[eshop] Error reading stream: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Forward the SSE line directly
|
||||
c.Writer.Write([]byte(line + "\n\n"))
|
||||
c.Writer.Flush()
|
||||
|
||||
if line == "data: [DONE]" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildContext(c *gin.Context, db *gorm.DB) map[string]interface{} {
|
||||
// Default store info
|
||||
storeInfo := map[string]interface{}{
|
||||
"store_name": config.AppConfig.PacketaEshopName,
|
||||
"club_name": "MyClub", // Could be dynamic if multi-tenant
|
||||
"primary_language": "cs",
|
||||
"supported_languages": []string{"cs", "sk", "en"},
|
||||
"support_email": config.AppConfig.ContactEmail,
|
||||
"support_phone": "", // Add if available
|
||||
"returns_policy_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky", // Fallback
|
||||
"terms_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky",
|
||||
}
|
||||
|
||||
// Try to load settings from DB
|
||||
var settings models.EshopSettings
|
||||
if err := db.First(&settings).Error; err == nil {
|
||||
if settings.SupportEmail != "" {
|
||||
storeInfo["support_email"] = settings.SupportEmail
|
||||
}
|
||||
if settings.SupportPhone != "" {
|
||||
storeInfo["support_phone"] = settings.SupportPhone
|
||||
}
|
||||
if settings.TermsURL != "" {
|
||||
storeInfo["terms_url"] = settings.TermsURL
|
||||
}
|
||||
if settings.ReturnsPolicyURL != "" {
|
||||
storeInfo["returns_policy_url"] = settings.ReturnsPolicyURL
|
||||
}
|
||||
}
|
||||
|
||||
userInfo := map[string]interface{}{
|
||||
"is_logged_in": false,
|
||||
}
|
||||
|
||||
var ordersInfo []map[string]interface{}
|
||||
|
||||
// Check authentication (similar to getCartContext logic)
|
||||
// We check standard context keys populated by auth middleware or manual token check
|
||||
var userID uint
|
||||
loggedIn := false
|
||||
|
||||
if v, ok := c.Get("userID"); ok {
|
||||
switch id := v.(type) {
|
||||
case uint:
|
||||
userID = id
|
||||
loggedIn = true
|
||||
case int:
|
||||
userID = uint(id)
|
||||
loggedIn = true
|
||||
case float64:
|
||||
userID = uint(id)
|
||||
loggedIn = true
|
||||
}
|
||||
}
|
||||
|
||||
if loggedIn {
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err == nil {
|
||||
userInfo["is_logged_in"] = true
|
||||
userInfo["name"] = strings.TrimSpace(user.FirstName + " " + user.LastName)
|
||||
userInfo["email"] = user.Email
|
||||
userInfo["id"] = user.ID
|
||||
}
|
||||
|
||||
// Load last 3 orders
|
||||
var orders []models.EshopOrder
|
||||
if err := db.Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Limit(3).
|
||||
Preload("Labels"). // For tracking info
|
||||
Find(&orders).Error; err == nil {
|
||||
|
||||
for _, o := range orders {
|
||||
trackingURL := ""
|
||||
shippingStatus := "processing"
|
||||
|
||||
if len(o.Labels) > 0 {
|
||||
// Use the latest label
|
||||
lbl := o.Labels[len(o.Labels)-1]
|
||||
trackingURL = lbl.LabelURL // Or specific tracking URL if constructed
|
||||
shippingStatus = lbl.Status
|
||||
}
|
||||
|
||||
ordersInfo = append(ordersInfo, map[string]interface{}{
|
||||
"order_id": o.OrderNumber, // Use user-friendly number
|
||||
"created_at": o.CreatedAt.Format(time.RFC3339),
|
||||
"status": o.Status,
|
||||
"total_amount": float64(o.TotalAmountCents) / 100.0,
|
||||
"currency": o.Currency,
|
||||
"shipping_method": o.ShippingMethod,
|
||||
"shipping_status": shippingStatus,
|
||||
"tracking_url": trackingURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"store": storeInfo,
|
||||
"user": userInfo,
|
||||
"orders": ordersInfo,
|
||||
"shipping": map[string]interface{}{
|
||||
"packeta_enabled": true, // Assume enabled for now
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user