3.28.2: Оптимизация загрузки деталей задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m44s

This commit is contained in:
poignatov
2026-01-25 15:28:37 +03:00
parent 47f47608bc
commit ef1d6fb59a
8 changed files with 227 additions and 35 deletions

View File

@@ -25,7 +25,7 @@ alwaysApply: true
### 3. Проанализируй git diff ### 3. Проанализируй git diff
Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений. Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений. В начале commit message должна быть указана версия на которую осуществился переход в формате "1.2.3: Коммит мессадж"
### 4. Закоммить изменения ### 4. Закоммить изменения

View File

@@ -1 +1 @@
3.28.0 3.28.2

View File

@@ -243,10 +243,17 @@ type Subtask struct {
Rewards []Reward `json:"rewards"` Rewards []Reward `json:"rewards"`
} }
type WishlistInfo struct {
ID int `json:"id"`
Name string `json:"name"`
Unlocked bool `json:"unlocked"`
}
type TaskDetail struct { type TaskDetail struct {
Task Task `json:"task"` Task Task `json:"task"`
Rewards []Reward `json:"rewards"` Rewards []Reward `json:"rewards"`
Subtasks []Subtask `json:"subtasks"` Subtasks []Subtask `json:"subtasks"`
WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"`
// Test-specific fields (only present if task has config_id) // Test-specific fields (only present if task has config_id)
WordsCount *int `json:"words_count,omitempty"` WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"` MaxCards *int `json:"max_cards,omitempty"`
@@ -2889,6 +2896,18 @@ func (a *App) initAuthDB() error {
// Не возвращаем ошибку, чтобы приложение могло запуститься // Не возвращаем ошибку, чтобы приложение могло запуститься
} }
// Apply migration 028: Optimize task queries with composite index
if err := a.applyMigration028(); err != nil {
log.Printf("Warning: Failed to apply migration 028: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Apply migration 029: Add covering indexes
if err := a.applyMigration029(); err != nil {
log.Printf("Warning: Failed to apply migration 029: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Clean up expired refresh tokens (only those with expiration date set) // Clean up expired refresh tokens (only those with expiration date set)
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
@@ -3336,6 +3355,96 @@ func (a *App) applyMigration025() error {
return nil return nil
} }
// applyMigration028 применяет миграцию 028_optimize_task_queries.sql
func (a *App) applyMigration028() error {
log.Printf("Applying migration 028: Optimize task queries with composite index")
// Проверяем, существует ли уже индекс
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS (
SELECT FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'idx_tasks_id_user_deleted'
)
`).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if index exists: %w", err)
}
if exists {
log.Printf("Migration 028 already applied (index idx_tasks_id_user_deleted exists), skipping")
return nil
}
// Читаем SQL файл миграции
migrationPath := "/migrations/028_optimize_task_queries.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
// Пробуем альтернативный путь (для локальной разработки)
migrationPath = "play-life-backend/migrations/028_optimize_task_queries.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
migrationPath = "migrations/028_optimize_task_queries.sql"
}
}
migrationSQL, err := os.ReadFile(migrationPath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
}
// Выполняем миграцию
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
return fmt.Errorf("failed to execute migration 028: %w", err)
}
log.Printf("Migration 028 applied successfully")
return nil
}
// applyMigration029 применяет миграцию 029_add_covering_indexes.sql
func (a *App) applyMigration029() error {
log.Printf("Applying migration 029: Add covering indexes")
// Проверяем, существует ли уже индекс для подзадач
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS (
SELECT FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'idx_tasks_parent_deleted_covering'
)
`).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if index exists: %w", err)
}
if exists {
log.Printf("Migration 029 already applied (covering indexes exist), skipping")
return nil
}
// Читаем SQL файл миграции
migrationPath := "/migrations/029_add_covering_indexes.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
// Пробуем альтернативный путь (для локальной разработки)
migrationPath = "play-life-backend/migrations/029_add_covering_indexes.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
migrationPath = "migrations/029_add_covering_indexes.sql"
}
}
migrationSQL, err := os.ReadFile(migrationPath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
}
// Выполняем миграцию
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
return fmt.Errorf("failed to execute migration 029: %w", err)
}
log.Printf("Migration 029 applied successfully")
return nil
}
func (a *App) initPlayLifeDB() error { func (a *App) initPlayLifeDB() error {
// Создаем таблицу projects // Создаем таблицу projects
createProjectsTable := ` createProjectsTable := `
@@ -7331,6 +7440,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Error querying subtasks: %v", err) log.Printf("Error querying subtasks: %v", err)
} else { } else {
defer subtaskRows.Close() defer subtaskRows.Close()
subtaskMap := make(map[int]*Subtask)
subtaskIDs := make([]int, 0)
for subtaskRows.Next() { for subtaskRows.Next() {
var subtaskTask Task var subtaskTask Task
var subtaskRewardMessage sql.NullString var subtaskRewardMessage sql.NullString
@@ -7356,33 +7468,56 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
} }
// Получаем награды подзадачи subtaskIDs = append(subtaskIDs, subtaskTask.ID)
subtaskRewards := make([]Reward, 0) subtask := Subtask{
subtaskRewardRows, err := a.DB.Query(` Task: subtaskTask,
SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression Rewards: make([]Reward, 0),
}
subtaskMap[subtaskTask.ID] = &subtask
}
// Загружаем все награды всех подзадач одним запросом
if len(subtaskIDs) > 0 {
placeholders := make([]string, len(subtaskIDs))
args := make([]interface{}, len(subtaskIDs))
for i, id := range subtaskIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := fmt.Sprintf(`
SELECT rc.task_id, rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
FROM reward_configs rc FROM reward_configs rc
JOIN projects p ON rc.project_id = p.id JOIN projects p ON rc.project_id = p.id
WHERE rc.task_id = $1 WHERE rc.task_id = ANY(ARRAY[%s])
ORDER BY rc.position ORDER BY rc.task_id, rc.position
`, subtaskTask.ID) `, strings.Join(placeholders, ","))
if err == nil { subtaskRewardRows, err := a.DB.Query(query, args...)
if err != nil {
log.Printf("Error querying subtask rewards: %v", err)
} else {
defer subtaskRewardRows.Close()
for subtaskRewardRows.Next() { for subtaskRewardRows.Next() {
var taskID int
var reward Reward var reward Reward
err := subtaskRewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) err := subtaskRewardRows.Scan(&taskID, &reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
if err != nil { if err != nil {
log.Printf("Error scanning subtask reward: %v", err) log.Printf("Error scanning subtask reward: %v", err)
continue continue
} }
subtaskRewards = append(subtaskRewards, reward) if subtask, exists := subtaskMap[taskID]; exists {
subtask.Rewards = append(subtask.Rewards, reward)
}
}
} }
subtaskRewardRows.Close()
} }
subtasks = append(subtasks, Subtask{ // Преобразуем map в slice, сохраняя порядок по ID
Task: subtaskTask, for _, id := range subtaskIDs {
Rewards: subtaskRewards, if subtask, exists := subtaskMap[id]; exists {
}) subtasks = append(subtasks, *subtask)
}
} }
} }
@@ -7392,6 +7527,32 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
Subtasks: subtasks, Subtasks: subtasks,
} }
// Если задача связана с wishlist, загружаем базовую информацию о wishlist
if wishlistID.Valid {
var wishlistName string
err := a.DB.QueryRow(`
SELECT name
FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, wishlistID.Int64).Scan(&wishlistName)
if err == nil {
unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID)
if err != nil {
log.Printf("Error checking wishlist unlock status: %v", err)
unlocked = false
}
response.WishlistInfo = &WishlistInfo{
ID: int(wishlistID.Int64),
Name: wishlistName,
Unlocked: unlocked,
}
} else if err != sql.ErrNoRows {
log.Printf("Error loading wishlist info for task %d: %v", taskID, err)
}
}
// Если задача - тест (есть config_id), загружаем данные конфигурации // Если задача - тест (есть config_id), загружаем данные конфигурации
if configID.Valid { if configID.Valid {
var wordsCount int var wordsCount int

View File

@@ -0,0 +1,14 @@
-- Migration: Optimize task queries with composite index
-- Date: 2026-01-24
--
-- This migration adds a composite index to optimize the task detail query:
-- WHERE id = $1 AND user_id = $2 AND deleted = FALSE
--
-- The index uses a partial index with WHERE deleted = FALSE to reduce index size
-- and improve query performance for active (non-deleted) tasks.
CREATE INDEX IF NOT EXISTS idx_tasks_id_user_deleted
ON tasks(id, user_id, deleted)
WHERE deleted = FALSE;
COMMENT ON INDEX idx_tasks_id_user_deleted IS 'Composite index for optimizing task detail queries with id, user_id, and deleted filter. Partial index for non-deleted tasks only.';

View File

@@ -0,0 +1,25 @@
-- Migration: Add covering indexes for task detail queries
-- Date: 2026-01-25
--
-- This migration adds covering indexes to optimize queries by including
-- all needed columns in the index, avoiding table lookups.
--
-- Covering indexes allow PostgreSQL to perform index-only scans,
-- getting all data directly from the index without accessing the table.
-- Covering index for subtasks query
-- Includes all columns needed for subtasks selection to avoid table lookups
CREATE INDEX IF NOT EXISTS idx_tasks_parent_deleted_covering
ON tasks(parent_task_id, deleted, id)
INCLUDE (name, completed, last_completed_at, reward_message, progression_base)
WHERE deleted = FALSE;
-- Covering index for wishlist name lookup
-- Includes name and deleted flag for quick lookup without table access
CREATE INDEX IF NOT EXISTS idx_wishlist_items_id_deleted_covering
ON wishlist_items(id, deleted)
INCLUDE (name)
WHERE deleted = FALSE;
COMMENT ON INDEX idx_tasks_parent_deleted_covering IS 'Covering index for subtasks query - includes all selected columns to avoid table lookups. Enables index-only scans for better performance.';
COMMENT ON INDEX idx_wishlist_items_id_deleted_covering IS 'Covering index for wishlist name lookup - includes name to avoid table lookup. Enables index-only scans for better performance.';

View File

@@ -1,12 +1,12 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.19.0", "version": "3.28.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "play-life-web", "name": "play-life-web",
"version": "3.19.0", "version": "3.28.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.28.0", "version": "3.28.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -395,21 +395,13 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
const data = await response.json() const data = await response.json()
setTaskDetail(data) setTaskDetail(data)
// Загружаем информацию о связанном желании, если есть // Используем информацию о wishlist из ответа API
if (data.task.wishlist_id) { if (data.wishlist_info) {
try {
const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`)
if (wishlistResponse.ok) {
const wishlistData = await wishlistResponse.json()
setWishlistInfo({ setWishlistInfo({
id: wishlistData.id, id: data.wishlist_info.id,
name: wishlistData.name, name: data.wishlist_info.name,
unlocked: wishlistData.unlocked || false unlocked: data.wishlist_info.unlocked || false
}) })
}
} catch (err) {
console.error('Error loading wishlist info:', err)
}
} else { } else {
setWishlistInfo(null) setWishlistInfo(null)
} }