Оптимизация wishlist: раздельные запросы и копирование
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -4055,6 +4056,7 @@ func main() {
|
||||
// Wishlist
|
||||
protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
|
||||
@@ -4062,6 +4064,7 @@ func main() {
|
||||
protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS")
|
||||
|
||||
// Admin operations
|
||||
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
||||
@@ -9527,7 +9530,7 @@ func (a *App) saveWishlistConditions(
|
||||
return nil
|
||||
}
|
||||
|
||||
// getWishlistHandler возвращает список желаний
|
||||
// getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых
|
||||
func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
@@ -9542,16 +9545,15 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
includeCompleted := r.URL.Query().Get("include_completed") == "true"
|
||||
|
||||
items, err := a.getWishlistItemsWithConditions(userID, includeCompleted)
|
||||
// Загружаем только незавершённые
|
||||
items, err := a.getWishlistItemsWithConditions(userID, false)
|
||||
if err != nil {
|
||||
log.Printf("Error getting wishlist items: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем количество завершённых отдельным запросом (т.к. основной запрос может их не включать)
|
||||
// Получаем количество завершённых
|
||||
var completedCount int
|
||||
err = a.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM wishlist_items
|
||||
@@ -9565,14 +9567,9 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Группируем и сортируем
|
||||
unlocked := make([]WishlistItem, 0)
|
||||
locked := make([]WishlistItem, 0)
|
||||
completed := make([]WishlistItem, 0)
|
||||
|
||||
for _, item := range items {
|
||||
if item.Completed {
|
||||
if includeCompleted {
|
||||
completed = append(completed, item)
|
||||
}
|
||||
} else if item.Unlocked {
|
||||
if item.Unlocked {
|
||||
unlocked = append(unlocked, item)
|
||||
} else {
|
||||
locked = append(locked, item)
|
||||
@@ -9604,6 +9601,48 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return priceI > priceJ
|
||||
})
|
||||
|
||||
response := WishlistResponse{
|
||||
Unlocked: unlocked,
|
||||
Locked: locked,
|
||||
CompletedCount: completedCount,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// getWishlistCompletedHandler возвращает список завершённых желаний
|
||||
func (a *App) getWishlistCompletedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Загружаем все желания включая завершённые
|
||||
items, err := a.getWishlistItemsWithConditions(userID, true)
|
||||
if err != nil {
|
||||
log.Printf("Error getting completed wishlist items: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting completed wishlist items: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Фильтруем только завершённые
|
||||
completed := make([]WishlistItem, 0)
|
||||
for _, item := range items {
|
||||
if item.Completed {
|
||||
completed = append(completed, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по цене (дорогие → дешёвые)
|
||||
sort.Slice(completed, func(i, j int) bool {
|
||||
priceI := 0.0
|
||||
priceJ := 0.0
|
||||
@@ -9616,15 +9655,8 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return priceI > priceJ
|
||||
})
|
||||
|
||||
response := WishlistResponse{
|
||||
Unlocked: unlocked,
|
||||
Locked: locked,
|
||||
Completed: completed,
|
||||
CompletedCount: completedCount,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(completed)
|
||||
}
|
||||
|
||||
// createWishlistHandler создаёт новое желание
|
||||
@@ -10183,6 +10215,246 @@ func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// copyWishlistHandler копирует желание
|
||||
func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
itemID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем оригинальное желание
|
||||
var name string
|
||||
var price sql.NullFloat64
|
||||
var link sql.NullString
|
||||
var imagePath sql.NullString
|
||||
var ownerID int
|
||||
err = a.DB.QueryRow(`
|
||||
SELECT user_id, name, price, link, image_path
|
||||
FROM wishlist_items
|
||||
WHERE id = $1 AND deleted = FALSE
|
||||
`, itemID).Scan(&ownerID, &name, &price, &link, &imagePath)
|
||||
|
||||
if err == sql.ErrNoRows || ownerID != userID {
|
||||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error getting wishlist item: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем условия оригинального желания
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT
|
||||
wc.display_order,
|
||||
wc.task_condition_id,
|
||||
wc.score_condition_id,
|
||||
tc.task_id,
|
||||
sc.project_id,
|
||||
sc.required_points,
|
||||
sc.start_date
|
||||
FROM wishlist_conditions wc
|
||||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||||
WHERE wc.wishlist_item_id = $1
|
||||
ORDER BY wc.display_order
|
||||
`, itemID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting wishlist conditions: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist conditions: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conditions []UnlockConditionRequest
|
||||
for rows.Next() {
|
||||
var displayOrder int
|
||||
var taskConditionID, scoreConditionID sql.NullInt64
|
||||
var taskID, projectID sql.NullInt64
|
||||
var requiredPoints sql.NullFloat64
|
||||
var startDate sql.NullString
|
||||
|
||||
err := rows.Scan(&displayOrder, &taskConditionID, &scoreConditionID, &taskID, &projectID, &requiredPoints, &startDate)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning condition row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
cond := UnlockConditionRequest{
|
||||
DisplayOrder: &displayOrder,
|
||||
}
|
||||
|
||||
if taskConditionID.Valid && taskID.Valid {
|
||||
cond.Type = "task_completion"
|
||||
tid := int(taskID.Int64)
|
||||
cond.TaskID = &tid
|
||||
} else if scoreConditionID.Valid && projectID.Valid {
|
||||
cond.Type = "project_points"
|
||||
pid := int(projectID.Int64)
|
||||
cond.ProjectID = &pid
|
||||
if requiredPoints.Valid {
|
||||
cond.RequiredPoints = &requiredPoints.Float64
|
||||
}
|
||||
if startDate.Valid {
|
||||
cond.StartDate = &startDate.String
|
||||
}
|
||||
}
|
||||
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
// Создаём копию в транзакции
|
||||
tx, err := a.DB.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Создаём копию желания
|
||||
var newWishlistID int
|
||||
var priceVal, linkVal interface{}
|
||||
if price.Valid {
|
||||
priceVal = price.Float64
|
||||
}
|
||||
if link.Valid {
|
||||
linkVal = link.String
|
||||
}
|
||||
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO wishlist_items (user_id, name, price, link, completed, deleted)
|
||||
VALUES ($1, $2, $3, $4, FALSE, FALSE)
|
||||
RETURNING id
|
||||
`, userID, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID)
|
||||
if err != nil {
|
||||
log.Printf("Error creating wishlist copy: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем условия
|
||||
if len(conditions) > 0 {
|
||||
err = a.saveWishlistConditions(tx, newWishlistID, conditions)
|
||||
if err != nil {
|
||||
log.Printf("Error saving wishlist conditions: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Копируем изображение, если есть
|
||||
if imagePath.Valid && imagePath.String != "" {
|
||||
// Получаем путь к оригинальному файлу
|
||||
uploadsDir := getEnv("UPLOADS_DIR", "/app/uploads")
|
||||
|
||||
// Очищаем путь от /uploads/ в начале и query параметров
|
||||
cleanPath := imagePath.String
|
||||
cleanPath = strings.TrimPrefix(cleanPath, "/uploads/")
|
||||
if idx := strings.Index(cleanPath, "?"); idx != -1 {
|
||||
cleanPath = cleanPath[:idx]
|
||||
}
|
||||
|
||||
originalPath := filepath.Join(uploadsDir, cleanPath)
|
||||
|
||||
log.Printf("Copying image: imagePath=%s, cleanPath=%s, originalPath=%s", imagePath.String, cleanPath, originalPath)
|
||||
|
||||
// Проверяем, существует ли файл
|
||||
if _, statErr := os.Stat(originalPath); statErr == nil {
|
||||
// Создаём директорию для нового желания
|
||||
newImageDir := filepath.Join(uploadsDir, "wishlist", strconv.Itoa(userID))
|
||||
if mkdirErr := os.MkdirAll(newImageDir, 0755); mkdirErr != nil {
|
||||
log.Printf("Error creating image dir: %v", mkdirErr)
|
||||
}
|
||||
|
||||
// Генерируем уникальное имя файла
|
||||
ext := filepath.Ext(cleanPath)
|
||||
randomBytes := make([]byte, 8)
|
||||
rand.Read(randomBytes)
|
||||
newFileName := fmt.Sprintf("%d_%s%s", newWishlistID, hex.EncodeToString(randomBytes), ext)
|
||||
newImagePath := filepath.Join(newImageDir, newFileName)
|
||||
|
||||
log.Printf("New image path: %s", newImagePath)
|
||||
|
||||
// Копируем файл
|
||||
srcFile, openErr := os.Open(originalPath)
|
||||
if openErr != nil {
|
||||
log.Printf("Error opening source file: %v", openErr)
|
||||
} else {
|
||||
defer srcFile.Close()
|
||||
dstFile, createErr := os.Create(newImagePath)
|
||||
if createErr != nil {
|
||||
log.Printf("Error creating dest file: %v", createErr)
|
||||
} else {
|
||||
defer dstFile.Close()
|
||||
_, copyErr := io.Copy(dstFile, srcFile)
|
||||
if copyErr != nil {
|
||||
log.Printf("Error copying file: %v", copyErr)
|
||||
} else {
|
||||
// Обновляем путь к изображению в БД (с /uploads/ в начале для совместимости)
|
||||
relativePath := "/uploads/" + filepath.Join("wishlist", strconv.Itoa(userID), newFileName)
|
||||
log.Printf("Updating image_path in DB to: %s", relativePath)
|
||||
_, updateErr := tx.Exec(`UPDATE wishlist_items SET image_path = $1 WHERE id = $2`, relativePath, newWishlistID)
|
||||
if updateErr != nil {
|
||||
log.Printf("Error updating image_path in DB: %v", updateErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Original image file not found: %s, error: %v", originalPath, statErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("No image to copy: imagePath.Valid=%v, imagePath.String=%s", imagePath.Valid, imagePath.String)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем созданное желание с условиями
|
||||
items, err := a.getWishlistItemsWithConditions(userID, false)
|
||||
if err != nil {
|
||||
log.Printf("Error getting created wishlist item: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var createdItem *WishlistItem
|
||||
for i := range items {
|
||||
if items[i].ID == newWishlistID {
|
||||
createdItem = &items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if createdItem == nil {
|
||||
sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(createdItem)
|
||||
}
|
||||
|
||||
// LinkMetadataResponse структура ответа с метаданными ссылки
|
||||
type LinkMetadataResponse struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user