Оптимизация wishlist: раздельные запросы и копирование
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s

This commit is contained in:
poignatov
2026-01-13 20:55:44 +03:00
parent db3b2640a8
commit ce7e0e584a
8 changed files with 943 additions and 185 deletions

View File

@@ -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"`