Yimaru-BackEnd/internal/web_server/handlers/maintenance_handler.go
2026-03-27 02:29:30 -07:00

156 lines
4.7 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"crypto/subtle"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/gofiber/fiber/v2"
)
type resetAndReseedReq struct {
Confirm string `json:"confirm"`
}
// ResetAndReseedDatabase godoc
// @Summary Reset and reseed database
// @Description Dangerous operation: truncates all public tables (except schema_migrations) and reseeds from db/data SQL files.
// @Tags internal
// @Accept json
// @Produce json
// @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN"
// @Param body body resetAndReseedReq true "Confirmation payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/internal/db/reset-reseed [post]
func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Operation is disabled",
Error: "DB_RESET_RESEED_ENABLED must be set to true",
})
}
var req resetAndReseedReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if strings.TrimSpace(req.Confirm) != "RESET_AND_RESEED" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Confirmation required",
Error: `set confirm to "RESET_AND_RESEED"`,
})
}
expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken)
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
if expectedToken == "" || subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Invalid reset token",
Error: "missing or invalid X-Seed-Reset-Token",
})
}
seedDir := strings.TrimSpace(h.Cfg.DBSeedDir)
if seedDir == "" {
seedDir = "db/data"
}
seedFiles, err := filepath.Glob(filepath.Join(seedDir, "*.sql"))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to resolve seed files",
Error: err.Error(),
})
}
if len(seedFiles) == 0 {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "No seed files found",
Error: fmt.Sprintf("no .sql files found in %s", seedDir),
})
}
sort.Strings(seedFiles)
var sqlBuilder strings.Builder
sqlBuilder.WriteString("BEGIN;\n")
sqlBuilder.WriteString(`
DO $$
DECLARE
truncate_stmt text;
BEGIN
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|| ' RESTART IDENTITY CASCADE'
INTO truncate_stmt
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> 'schema_migrations';
IF truncate_stmt IS NOT NULL THEN
EXECUTE truncate_stmt;
END IF;
END $$;
`)
for _, file := range seedFiles {
content, readErr := os.ReadFile(file)
if readErr != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read seed file",
Error: fmt.Sprintf("%s: %v", file, readErr),
})
}
sqlBuilder.WriteString("\n-- seed file: ")
sqlBuilder.WriteString(file)
sqlBuilder.WriteString("\n")
sqlBuilder.Write(content)
sqlBuilder.WriteString("\n")
}
sqlBuilder.WriteString("COMMIT;")
if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reset and reseed database",
Error: err.Error(),
})
}
// Ensure permission definitions and default assignments are in sync after reseed.
if err := h.rbacSvc.SeedPermissions(c.Context()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Database reseeded but RBAC permission sync failed",
Error: err.Error(),
})
}
if err := h.rbacSvc.SeedDefaultRolePermissions(c.Context()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Database reseeded but RBAC role default sync failed",
Error: err.Error(),
})
}
if err := h.rbacSvc.Reload(c.Context()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Database reseeded but RBAC cache reload failed",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Database reset and reseed completed successfully",
Data: map[string]interface{}{
"seed_dir": seedDir,
"seed_files": seedFiles,
"seeded_count": len(seedFiles),
},
Success: true,
StatusCode: fiber.StatusOK,
})
}