On branch main

modified:   internal/domain/appeal/dto.go
	new file:   internal/domain/appeal/handler.go
	modified:   internal/domain/appeal/router.go
	modified:   internal/domain/appeal/service.go
	modified:   internal/models/appeal.go
	modified:   internal/router/router.go
fix bag with no embeded the Base into appeal
This commit is contained in:
2026-05-21 05:04:34 +05:00
parent ba2e3b9545
commit 318075d686
6 changed files with 1300 additions and 10 deletions
@@ -1,4 +1,219 @@
package appeal
import ()
import (
"time"
"api_yal/internal/models"
)
// CreateAppealRequest DTO для создания обращения
type CreateAppealRequest struct {
Type string `json:"type" validate:"required,oneof=complaint suggestion wish question other"`
Title string `json:"title" validate:"required,min=3,max=255"`
Message string `json:"message" validate:"required,min=10"`
Priority string `json:"priority" validate:"omitempty,oneof=low medium high critical"`
ObjectID *uint `json:"object_id,omitempty"`
FeedbackID *uint `json:"feedback_id,omitempty"`
CommentID *uint `json:"comment_id,omitempty"`
ContactName string `json:"contact_name,omitempty"`
ContactEmail string `json:"contact_email,omitempty" validate:"omitempty,email"`
ContactPhone string `json:"contact_phone,omitempty"`
Attachments []string `json:"attachments,omitempty"`
Category string `json:"category,omitempty"`
Labels []string `json:"labels,omitempty"`
CustomData map[string]interface{} `json:"custom_data,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// UpdateAppealRequest DTO для обновления обращения
type UpdateAppealRequest struct {
Title *string `json:"title,omitempty" validate:"omitempty,min=3,max=255"`
Message *string `json:"message,omitempty" validate:"omitempty,min=10"`
Priority *string `json:"priority,omitempty" validate:"omitempty,oneof=low medium high critical"`
Category *string `json:"category,omitempty"`
Labels []string `json:"labels,omitempty"`
Attachments []string `json:"attachments,omitempty"`
CustomData map[string]interface{} `json:"custom_data,omitempty"`
}
// UpdateStatusRequest DTO для обновления статуса
type UpdateStatusRequest struct {
Status string `json:"status" validate:"required,oneof=new in_progress resolved rejected closed"`
Comment string `json:"comment,omitempty"`
}
// AssignRequest DTO для назначения ответственного
type AssignRequest struct {
AssignedToID *uint `json:"assigned_to_id"`
}
// ResolveRequest DTO для решения обращения
type ResolveRequest struct {
Resolution string `json:"resolution" validate:"required,min=5"`
}
// AppealResponse DTO для ответа
type AppealResponse struct {
ID uint `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Priority string `json:"priority"`
Title string `json:"title"`
Message string `json:"message"`
AuthorID *uint `json:"author_id,omitempty"`
Author *AuthorInfo `json:"author,omitempty"`
ObjectID *uint `json:"object_id,omitempty"`
Object *ObjectInfo `json:"object,omitempty"`
FeedbackID *uint `json:"feedback_id,omitempty"`
CommentID *uint `json:"comment_id,omitempty"`
ContactName string `json:"contact_name,omitempty"`
ContactEmail string `json:"contact_email,omitempty"`
ContactPhone string `json:"contact_phone,omitempty"`
Attachments []string `json:"attachments,omitempty"`
Category string `json:"category,omitempty"`
Labels []string `json:"labels,omitempty"`
AssignedToID *uint `json:"assigned_to_id,omitempty"`
AssignedTo *AuthorInfo `json:"assigned_to,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
ResolvedBy *uint `json:"resolved_by,omitempty"`
Resolution string `json:"resolution,omitempty"`
CustomData map[string]interface{} `json:"custom_data,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AuthorInfo информация об авторе
type AuthorInfo struct {
ID uint `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// ObjectInfo информация об объекте
type ObjectInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// ListAppealsResponse DTO для списка обращений
type ListAppealsResponse struct {
Data []AppealResponse `json:"data"`
Total int64 `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
// AppealHistoryResponse DTO для истории
type AppealHistoryResponse struct {
ID uint `json:"id"`
AppealID uint `json:"appeal_id"`
UserID *uint `json:"user_id,omitempty"`
User *AuthorInfo `json:"user,omitempty"`
OldStatus string `json:"old_status"`
NewStatus string `json:"new_status"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ListHistoryResponse DTO для списка истории
type ListHistoryResponse struct {
Data []AppealHistoryResponse `json:"data"`
Total int64 `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
// AppealStatisticsResponse DTO для статистики
type AppealStatisticsResponse struct {
Total int64 `json:"total"`
ByStatus map[string]int64 `json:"by_status"`
ByType map[string]int64 `json:"by_type"`
ByPriority map[string]int64 `json:"by_priority"`
ByCategory map[string]int64 `json:"by_category,omitempty"`
AvgResolveTime float64 `json:"avg_resolve_time_hours"`
}
// NewAppealResponse создает AppealResponse из модели
func NewAppealResponse(appeal *models.Appeal) AppealResponse {
resp := AppealResponse{
ID: appeal.ID,
Type: string(appeal.Type),
Status: string(appeal.Status),
Priority: string(appeal.Priority),
Title: appeal.Title,
Message: appeal.Message,
AuthorID: appeal.AuthorID,
ObjectID: appeal.ObjectID,
FeedbackID: appeal.FeedbackID,
CommentID: appeal.CommentID,
ContactName: appeal.ContactName,
ContactEmail: appeal.ContactEmail,
ContactPhone: appeal.ContactPhone,
Attachments: appeal.Attachments,
Category: appeal.Category,
Labels: appeal.Labels,
AssignedToID: appeal.AssignedToID,
ResolvedAt: appeal.ResolvedAt,
ResolvedBy: appeal.ResolvedBy,
Resolution: appeal.Resolution,
CustomData: appeal.CustomData,
CreatedAt: appeal.CreatedAt,
UpdatedAt: appeal.UpdatedAt,
}
if appeal.Author != nil {
resp.Author = &AuthorInfo{
ID: appeal.Author.ID,
Email: appeal.Author.Email,
FirstName: appeal.Author.FirstName,
LastName: appeal.Author.LastName,
}
}
if appeal.Object != nil {
resp.Object = &ObjectInfo{
ID: appeal.Object.ID,
Name: appeal.Object.ShortName,
Description: appeal.Object.Description,
}
}
if appeal.AssignedTo != nil {
resp.AssignedTo = &AuthorInfo{
ID: appeal.AssignedTo.ID,
Email: appeal.AssignedTo.Email,
FirstName: appeal.AssignedTo.FirstName,
LastName: appeal.AssignedTo.LastName,
}
}
return resp
}
// NewAppealHistoryResponse создает AppealHistoryResponse из модели
func NewAppealHistoryResponse(history *models.AppealHistory) AppealHistoryResponse {
resp := AppealHistoryResponse{
ID: history.ID,
AppealID: history.AppealID,
UserID: history.UserID,
OldStatus: string(history.OldStatus),
NewStatus: string(history.NewStatus),
Comment: history.Comment,
CreatedAt: history.CreatedAt,
}
if history.User != nil {
resp.User = &AuthorInfo{
ID: history.User.ID,
Email: history.User.Email,
FirstName: history.User.FirstName,
LastName: history.User.LastName,
}
}
return resp
}
@@ -0,0 +1,580 @@
package appeal
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"api_yal/internal/logger"
"api_yal/internal/middleware"
"api_yal/internal/models"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
// Handler HTTP обработчик для обращений
type Handler struct {
service AppealService
logger *zap.Logger
}
// NewHandler создает новый экземпляр обработчика
func NewHandler(service AppealService) *Handler {
return &Handler{
service: service,
logger: logger.Get(),
}
}
// CreateAppeal создает новое обращение
// @Summary Создать обращение
// @Description Создает новое обращение (жалобу, предложение и т.д.)
// @Tags appeals
// @Accept json
// @Produce json
// @Param request body CreateAppealRequest true "Данные обращения"
// @Success 201 {object} AppealResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /appeals [post]
func (h *Handler) CreateAppeal(w http.ResponseWriter, r *http.Request) {
var req CreateAppealRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode request", zap.Error(err))
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Получаем ID автора из контекста (если авторизован)
authorID, isAuth := middleware.GetUserID(r.Context())
var authorIDPtr *uint
if isAuth {
authorIDPtr = &authorID
}
// Получаем IP и User-Agent
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = strings.Split(forwarded, ",")[0]
}
userAgent := r.Header.Get("User-Agent")
appeal, err := h.service.Create(&req, authorIDPtr, ipAddress, userAgent)
if err != nil {
h.logger.Error("Failed to create appeal", zap.Error(err))
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
respondWithJSON(w, http.StatusCreated, NewAppealResponse(appeal))
}
// GetAppeal возвращает обращение по ID
// @Summary Получить обращение
// @Description Возвращает обращение по ID
// @Tags appeals
// @Produce json
// @Param id path int true "ID обращения"
// @Success 200 {object} AppealResponse
// @Failure 404 {object} map[string]string
// @Router /appeals/{id} [get]
func (h *Handler) GetAppeal(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
appeal, err := h.service.GetByID(id)
if err != nil {
h.logger.Error("Failed to get appeal", zap.Error(err), zap.Uint("id", id))
respondWithError(w, http.StatusNotFound, "Appeal not found")
return
}
// Проверка прав доступа
userID, _ := middleware.GetUserID(r.Context())
userRole, _ := middleware.GetUserRole(r.Context())
if !canAccessAppeal(appeal, userID, userRole) {
respondWithError(w, http.StatusForbidden, "Access denied")
return
}
respondWithJSON(w, http.StatusOK, NewAppealResponse(appeal))
}
// UpdateAppeal обновляет обращение
// @Summary Обновить обращение
// @Description Обновляет существующее обращение
// @Tags appeals
// @Accept json
// @Produce json
// @Param id path int true "ID обращения"
// @Param request body UpdateAppealRequest true "Данные для обновления"
// @Success 200 {object} AppealResponse
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /appeals/{id} [put]
func (h *Handler) UpdateAppeal(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
var req UpdateAppealRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
userID, _ := middleware.GetUserID(r.Context())
userRole, _ := middleware.GetUserRole(r.Context())
isAdmin := userRole == "admin"
appeal, err := h.service.Update(id, &req, userID, isAdmin)
if err != nil {
h.logger.Error("Failed to update appeal", zap.Error(err), zap.Uint("id", id))
if err.Error() == "permission denied" {
respondWithError(w, http.StatusForbidden, err.Error())
return
}
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
respondWithJSON(w, http.StatusOK, NewAppealResponse(appeal))
}
// DeleteAppeal удаляет обращение
// @Summary Удалить обращение
// @Description Мягкое удаление обращения
// @Tags appeals
// @Param id path int true "ID обращения"
// @Success 204 "No Content"
// @Failure 403 {object} map[string]string
// @Router /appeals/{id} [delete]
func (h *Handler) DeleteAppeal(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
userID, _ := middleware.GetUserID(r.Context())
userRole, _ := middleware.GetUserRole(r.Context())
isAdmin := userRole == "admin"
if err := h.service.Delete(id, userID, isAdmin); err != nil {
h.logger.Error("Failed to delete appeal", zap.Error(err), zap.Uint("id", id))
if err.Error() == "permission denied" {
respondWithError(w, http.StatusForbidden, err.Error())
return
}
respondWithError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListAppeals возвращает список обращений
// @Summary Список обращений
// @Description Возвращает список обращений с пагинацией и фильтрацией
// @Tags appeals
// @Produce json
// @Param offset query int false "Смещение" default(0)
// @Param limit query int false "Лимит" default(20)
// @Param status query string false "Статус" Enums(new, in_progress, resolved, rejected, closed)
// @Param type query string false "Тип" Enums(complaint, suggestion, wish, question, other)
// @Param priority query string false "Приоритет" Enums(low, medium, high, critical)
// @Param search query string false "Поиск по заголовку/сообщению"
// @Success 200 {object} ListAppealsResponse
// @Router /appeals [get]
func (h *Handler) ListAppeals(w http.ResponseWriter, r *http.Request) {
offset, limit := parsePagination(r)
// Собираем фильтры
filters := make(map[string]interface{})
if status := r.URL.Query().Get("status"); status != "" {
filters["status"] = status
}
if appealType := r.URL.Query().Get("type"); appealType != "" {
filters["type"] = appealType
}
if priority := r.URL.Query().Get("priority"); priority != "" {
filters["priority"] = priority
}
if search := r.URL.Query().Get("search"); search != "" {
filters["search"] = search
}
appeals, total, err := h.service.List(offset, limit, filters)
if err != nil {
h.logger.Error("Failed to list appeals", zap.Error(err))
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve appeals")
return
}
data := make([]AppealResponse, len(appeals))
for i, appeal := range appeals {
data[i] = NewAppealResponse(&appeal)
}
totalPages := int(total) / limit
if int(total)%limit > 0 {
totalPages++
}
respondWithJSON(w, http.StatusOK, ListAppealsResponse{
Data: data,
Total: total,
Offset: offset,
Limit: limit,
TotalPages: totalPages,
})
}
// UpdateAppealStatus обновляет статус обращения
// @Summary Обновить статус
// @Description Обновляет статус обращения (требует прав администратора или модератора)
// @Tags appeals
// @Accept json
// @Produce json
// @Param id path int true "ID обращения"
// @Param request body UpdateStatusRequest true "Новый статус"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /appeals/{id}/status [patch]
func (h *Handler) UpdateAppealStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
var req UpdateStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
userID, _ := middleware.GetUserID(r.Context())
userRole, _ := middleware.GetUserRole(r.Context())
// Только администраторы и модераторы могут менять статус
if userRole != "admin" && userRole != "moderator" {
respondWithError(w, http.StatusForbidden, "Permission denied")
return
}
if err := h.service.UpdateStatus(id, &req, userID); err != nil {
h.logger.Error("Failed to update status", zap.Error(err), zap.Uint("id", id))
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
respondWithJSON(w, http.StatusOK, map[string]string{"message": "Status updated successfully"})
}
// AssignAppeal назначает ответственного
// @Summary Назначить ответственного
// @Description Назначает ответственного за обработку обращения
// @Tags appeals
// @Accept json
// @Produce json
// @Param id path int true "ID обращения"
// @Param request body AssignRequest true "ID ответственного"
// @Success 200 {object} map[string]string
// @Router /appeals/{id}/assign [post]
func (h *Handler) AssignAppeal(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
var req AssignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
userID, _ := middleware.GetUserID(r.Context())
userRole, _ := middleware.GetUserRole(r.Context())
if userRole != "admin" && userRole != "moderator" {
respondWithError(w, http.StatusForbidden, "Permission denied")
return
}
if err := h.service.AssignTo(id, &req, userID); err != nil {
h.logger.Error("Failed to assign appeal", zap.Error(err), zap.Uint("id", id))
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
respondWithJSON(w, http.StatusOK, map[string]string{"message": "Assigned successfully"})
}
// ResolveAppeal решает обращение
// @Summary Решить обращение
// @Description Отмечает обращение как решенное с указанием решения
// @Tags appeals
// @Accept json
// @Produce json
// @Param id path int true "ID обращения"
// @Param request body ResolveRequest true "Решение"
// @Success 200 {object} map[string]string
// @Router /appeals/{id}/resolve [post]
func (h *Handler) ResolveAppeal(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
var req ResolveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
userID, _ := middleware.GetUserID(r.Context())
userRole, _ := middleware.GetUserRole(r.Context())
if userRole != "admin" && userRole != "moderator" {
respondWithError(w, http.StatusForbidden, "Permission denied")
return
}
if err := h.service.Resolve(id, &req, userID); err != nil {
h.logger.Error("Failed to resolve appeal", zap.Error(err), zap.Uint("id", id))
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
respondWithJSON(w, http.StatusOK, map[string]string{"message": "Appeal resolved successfully"})
}
// GetAppealHistory возвращает историю изменений обращения
// @Summary История изменений
// @Description Возвращает историю изменений статуса обращения
// @Tags appeals
// @Produce json
// @Param id path int true "ID обращения"
// @Param offset query int false "Смещение" default(0)
// @Param limit query int false "Лимит" default(20)
// @Success 200 {object} ListHistoryResponse
// @Router /appeals/{id}/history [get]
func (h *Handler) GetAppealHistory(w http.ResponseWriter, r *http.Request) {
id, err := parseIDParam(r, "id")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid appeal ID")
return
}
offset, limit := parsePagination(r)
histories, total, err := h.service.GetHistory(id, offset, limit)
if err != nil {
h.logger.Error("Failed to get history", zap.Error(err), zap.Uint("id", id))
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve history")
return
}
data := make([]AppealHistoryResponse, len(histories))
for i, history := range histories {
data[i] = NewAppealHistoryResponse(&history)
}
totalPages := int(total) / limit
if int(total)%limit > 0 {
totalPages++
}
respondWithJSON(w, http.StatusOK, ListHistoryResponse{
Data: data,
Total: total,
Offset: offset,
Limit: limit,
TotalPages: totalPages,
})
}
// GetAppealStatistics возвращает статистику по обращениям
// @Summary Статистика обращений
// @Description Возвращает статистику по обращениям (требует прав администратора)
// @Tags appeals
// @Produce json
// @Success 200 {object} AppealStatisticsResponse
// @Router /appeals/statistics [get]
func (h *Handler) GetAppealStatistics(w http.ResponseWriter, r *http.Request) {
userRole, _ := middleware.GetUserRole(r.Context())
if userRole != "admin" {
respondWithError(w, http.StatusForbidden, "Admin access required")
return
}
stats, err := h.service.GetStatistics()
if err != nil {
h.logger.Error("Failed to get statistics", zap.Error(err))
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve statistics")
return
}
respondWithJSON(w, http.StatusOK, stats)
}
// GetMyAppeals возвращает обращения текущего пользователя
// @Summary Мои обращения
// @Description Возвращает обращения текущего авторизованного пользователя
// @Tags appeals
// @Produce json
// @Param offset query int false "Смещение" default(0)
// @Param limit query int false "Лимит" default(20)
// @Success 200 {object} ListAppealsResponse
// @Router /appeals/me [get]
func (h *Handler) GetMyAppeals(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondWithError(w, http.StatusUnauthorized, "User not authenticated")
return
}
offset, limit := parsePagination(r)
appeals, total, err := h.service.GetMyAppeals(userID, offset, limit)
if err != nil {
h.logger.Error("Failed to get my appeals", zap.Error(err), zap.Uint("user_id", userID))
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve appeals")
return
}
data := make([]AppealResponse, len(appeals))
for i, appeal := range appeals {
data[i] = NewAppealResponse(&appeal)
}
totalPages := int(total) / limit
if int(total)%limit > 0 {
totalPages++
}
respondWithJSON(w, http.StatusOK, ListAppealsResponse{
Data: data,
Total: total,
Offset: offset,
Limit: limit,
TotalPages: totalPages,
})
}
// GetAppealsByAuthor возвращает обращения пользователя по ID
// @Summary Обращения пользователя
// @Description Возвращает обращения указанного пользователя (админ доступ)
// @Tags appeals
// @Produce json
// @Param userID path int true "ID пользователя"
// @Param offset query int false "Смещение" default(0)
// @Param limit query int false "Лимит" default(20)
// @Success 200 {object} ListAppealsResponse
// @Router /appeals/user/{userID} [get]
func (h *Handler) GetAppealsByAuthor(w http.ResponseWriter, r *http.Request) {
userRole, _ := middleware.GetUserRole(r.Context())
if userRole != "admin" {
respondWithError(w, http.StatusForbidden, "Admin access required")
return
}
authorID, err := parseIDParam(r, "userID")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid user ID")
return
}
offset, limit := parsePagination(r)
appeals, total, err := h.service.GetAppealsByAuthor(authorID, offset, limit)
if err != nil {
h.logger.Error("Failed to get appeals by author", zap.Error(err), zap.Uint("author_id", authorID))
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve appeals")
return
}
data := make([]AppealResponse, len(appeals))
for i, appeal := range appeals {
data[i] = NewAppealResponse(&appeal)
}
totalPages := int(total) / limit
if int(total)%limit > 0 {
totalPages++
}
respondWithJSON(w, http.StatusOK, ListAppealsResponse{
Data: data,
Total: total,
Offset: offset,
Limit: limit,
TotalPages: totalPages,
})
}
// Вспомогательные функции
func parseIDParam(r *http.Request, param string) (uint, error) {
idStr := chi.URLParam(r, param)
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return 0, err
}
return uint(id), nil
}
func parsePagination(r *http.Request) (offset, limit int) {
offsetStr := r.URL.Query().Get("offset")
limitStr := r.URL.Query().Get("limit")
offset, _ = strconv.Atoi(offsetStr)
limit, _ = strconv.Atoi(limitStr)
if offset < 0 {
offset = 0
}
if limit <= 0 || limit > 100 {
limit = 20
}
return
}
func respondWithJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
logger.Get().Error("Failed to encode JSON response", zap.Error(err))
}
}
func respondWithError(w http.ResponseWriter, status int, message string) {
respondWithJSON(w, status, map[string]string{"error": message})
}
func canAccessAppeal(appeal *models.Appeal, userID uint, userRole string) bool {
// Администратор может видеть все
if userRole == "admin" {
return true
}
// Модератор может видеть все
if userRole == "moderator" {
return true
}
// Обычный пользователь может видеть только свои обращения
if appeal.AuthorID != nil && *appeal.AuthorID == userID {
return true
}
// Анонимные обращения могут видеть только админы и модераторы
return false
}
@@ -1,3 +1,60 @@
package appeal
import ()
import (
"api_yal/internal/logger"
"api_yal/internal/middleware"
"api_yal/internal/repository"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
// RegisterRoutes регистрирует маршруты для работы с обращениями
func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
l := logger.Get()
l.Debug("Регистрация маршрутов обращений")
// Создаем репозиторий и сервис
appealRepo := repository.NewAppealRepository(db)
appealService := NewAppealService(appealRepo)
appealHandler := NewHandler(appealService)
// Публичные маршруты (не требуют аутентификации)
r.Post("/appeals", appealHandler.CreateAppeal)
r.Get("/appeals/{id}", appealHandler.GetAppeal)
// Защищенные маршруты (требуют аутентификации)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtSecret))
// Личные обращения пользователя
r.Get("/appeals/me", appealHandler.GetMyAppeals)
// Обновление и удаление своих обращений
r.Put("/appeals/{id}", appealHandler.UpdateAppeal)
r.Delete("/appeals/{id}", appealHandler.DeleteAppeal)
})
// Административные маршруты (требуют прав администратора или модератора)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtSecret))
r.Use(middleware.AdminOnlyMiddleware) // Или создайте ModeratorOnlyMiddleware
// Полный список обращений (с фильтрацией)
r.Get("/appeals", appealHandler.ListAppeals)
// Управление статусами
r.Patch("/appeals/{id}/status", appealHandler.UpdateAppealStatus)
r.Post("/appeals/{id}/assign", appealHandler.AssignAppeal)
r.Post("/appeals/{id}/resolve", appealHandler.ResolveAppeal)
// История и статистика
r.Get("/appeals/{id}/history", appealHandler.GetAppealHistory)
r.Get("/appeals/statistics", appealHandler.GetAppealStatistics)
// Просмотр обращений конкретного пользователя
r.Get("/appeals/user/{userID}", appealHandler.GetAppealsByAuthor)
})
l.Info("Маршруты обращений зарегистрированы")
}
@@ -1,17 +1,452 @@
package appeal
import "api_yal/internal/repository"
import (
"errors"
"time"
"api_yal/internal/models"
"api_yal/internal/repository"
)
// AppealService интерфейс сервиса обращений
type AppealService interface {
// Create создает новое обращение
Create(req *CreateAppealRequest, authorID *uint, ipAddress, userAgent string) (*models.Appeal, error)
// GetByID возвращает обращение по ID
GetByID(id uint) (*models.Appeal, error)
// Update обновляет обращение
Update(id uint, req *UpdateAppealRequest, userID uint, isAdmin bool) (*models.Appeal, error)
// Delete удаляет обращение (мягкое удаление)
Delete(id uint, userID uint, isAdmin bool) error
// List возвращает список обращений
List(offset, limit int, filters map[string]interface{}) ([]models.Appeal, int64, error)
// UpdateStatus обновляет статус обращения
UpdateStatus(id uint, req *UpdateStatusRequest, userID uint) error
// AssignTo назначает ответственного
AssignTo(id uint, req *AssignRequest, userID uint) error
// Resolve решает обращение
Resolve(id uint, req *ResolveRequest, userID uint) error
// GetHistory возвращает историю изменений
GetHistory(appealID uint, offset, limit int) ([]models.AppealHistory, int64, error)
// GetStatistics возвращает статистику по обращениям
GetStatistics() (*AppealStatisticsResponse, error)
// GetMyAppeals возвращает обращения текущего пользователя
GetMyAppeals(userID uint, offset, limit int) ([]models.Appeal, int64, error)
// GetAppealsByAuthor возвращает обращения автора
GetAppealsByAuthor(authorID uint, offset, limit int) ([]models.Appeal, int64, error)
}
type appealServiceImpl struct {
appelRepository repository.AppealRepository
appealRepo repository.AppealRepository
}
// NewAppealService создает новый экземпляр сервиса обращений
func NewAppealService(appealRepo repository.AppealRepository) AppealService {
return &appealServiceImpl{
appelRepository: appealRepo,
appealRepo: appealRepo,
}
}
// Create создает новое обращение
func (s *appealServiceImpl) Create(req *CreateAppealRequest, authorID *uint, ipAddress, userAgent string) (*models.Appeal, error) {
// Валидация
if req.Title == "" {
return nil, errors.New("title is required")
}
if req.Message == "" {
return nil, errors.New("message is required")
}
// Конвертация типов
appealType := models.AppealType(req.Type)
if !isValidAppealType(appealType) {
return nil, errors.New("invalid appeal type")
}
priority := models.AppealPriority(req.Priority)
if req.Priority == "" {
priority = models.AppealPriorityMedium
}
if !isValidAppealPriority(priority) {
return nil, errors.New("invalid appeal priority")
}
appeal := &models.Appeal{
Type: appealType,
Status: models.AppealStatusNew,
Priority: priority,
Title: req.Title,
Message: req.Message,
AuthorID: authorID,
ObjectID: req.ObjectID,
FeedbackID: req.FeedbackID,
CommentID: req.CommentID,
ContactName: req.ContactName,
ContactEmail: req.ContactEmail,
ContactPhone: req.ContactPhone,
Attachments: req.Attachments,
Category: req.Category,
Labels: req.Labels,
CustomData: req.CustomData,
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := s.appealRepo.Create(appeal); err != nil {
return nil, err
}
// Создаем запись в истории
history := &models.AppealHistory{
AppealID: appeal.ID,
UserID: authorID,
OldStatus: "",
NewStatus: models.AppealStatusNew,
Comment: "Обращение создано",
}
_ = s.appealRepo.CreateHistory(history)
return appeal, nil
}
// GetByID возвращает обращение по ID
func (s *appealServiceImpl) GetByID(id uint) (*models.Appeal, error) {
return s.appealRepo.GetByID(id)
}
// Update обновляет обращение
func (s *appealServiceImpl) Update(id uint, req *UpdateAppealRequest, userID uint, isAdmin bool) (*models.Appeal, error) {
appeal, err := s.appealRepo.GetByID(id)
if err != nil {
return nil, err
}
// Проверка прав: только автор или админ может редактировать
if !isAdmin && (appeal.AuthorID == nil || *appeal.AuthorID != userID) {
return nil, errors.New("permission denied")
}
// Только новые обращения можно редактировать
if !isAdmin && appeal.Status != models.AppealStatusNew {
return nil, errors.New("only new appeals can be edited")
}
if req.Title != nil {
appeal.Title = *req.Title
}
if req.Message != nil {
appeal.Message = *req.Message
}
if req.Priority != nil {
priority := models.AppealPriority(*req.Priority)
if isValidAppealPriority(priority) {
appeal.Priority = priority
}
}
if req.Category != nil {
appeal.Category = *req.Category
}
if req.Labels != nil {
appeal.Labels = req.Labels
}
if req.Attachments != nil {
appeal.Attachments = req.Attachments
}
if req.CustomData != nil {
appeal.CustomData = req.CustomData
}
if err := s.appealRepo.Update(appeal); err != nil {
return nil, err
}
return appeal, nil
}
// Delete удаляет обращение
func (s *appealServiceImpl) Delete(id uint, userID uint, isAdmin bool) error {
appeal, err := s.appealRepo.GetByID(id)
if err != nil {
return err
}
// Проверка прав: только автор или админ может удалить
if !isAdmin && (appeal.AuthorID == nil || *appeal.AuthorID != userID) {
return errors.New("permission denied")
}
return s.appealRepo.Delete(id)
}
// List возвращает список обращений
func (s *appealServiceImpl) List(offset, limit int, filters map[string]interface{}) ([]models.Appeal, int64, error) {
if offset < 0 {
offset = 0
}
if limit <= 0 || limit > 100 {
limit = 20
}
var appeals []models.Appeal
var total int64
var err error
// Применяем фильтры
if status, ok := filters["status"].(string); ok {
appeals, err = s.appealRepo.ListByStatus(models.AppealStatus(status), offset, limit)
if err == nil {
total, _ = s.appealRepo.Count()
}
} else if appealType, ok := filters["type"].(string); ok {
appeals, err = s.appealRepo.ListByType(models.AppealType(appealType), offset, limit)
if err == nil {
total, _ = s.appealRepo.Count()
}
} else if priority, ok := filters["priority"].(string); ok {
appeals, err = s.appealRepo.ListByPriority(models.AppealPriority(priority), offset, limit)
if err == nil {
total, _ = s.appealRepo.Count()
}
} else if query, ok := filters["search"].(string); ok && query != "" {
appeals, err = s.appealRepo.Search(query, offset, limit)
if err == nil {
total = int64(len(appeals))
}
} else {
appeals, err = s.appealRepo.List(offset, limit)
if err == nil {
total, _ = s.appealRepo.Count()
}
}
if err != nil {
return nil, 0, err
}
return appeals, total, nil
}
// UpdateStatus обновляет статус обращения
func (s *appealServiceImpl) UpdateStatus(id uint, req *UpdateStatusRequest, userID uint) error {
appeal, err := s.appealRepo.GetByID(id)
if err != nil {
return err
}
oldStatus := appeal.Status
newStatus := models.AppealStatus(req.Status)
if !isValidAppealStatus(newStatus) {
return errors.New("invalid appeal status")
}
if err := s.appealRepo.UpdateStatus(id, newStatus); err != nil {
return err
}
// Создаем запись в истории
history := &models.AppealHistory{
AppealID: id,
UserID: &userID,
OldStatus: oldStatus,
NewStatus: newStatus,
Comment: req.Comment,
}
if err := s.appealRepo.CreateHistory(history); err != nil {
return err
}
return nil
}
// AssignTo назначает ответственного
func (s *appealServiceImpl) AssignTo(id uint, req *AssignRequest, userID uint) error {
appeal, err := s.appealRepo.GetByID(id)
if err != nil {
return err
}
if err := s.appealRepo.AssignTo(id, req.AssignedToID); err != nil {
return err
}
// Создаем запись в истории
comment := "Назначен ответственный"
if req.AssignedToID == nil {
comment = "Ответственный снят"
}
history := &models.AppealHistory{
AppealID: id,
UserID: &userID,
OldStatus: appeal.Status,
NewStatus: appeal.Status,
Comment: comment,
}
_ = s.appealRepo.CreateHistory(history)
return nil
}
// Resolve решает обращение
func (s *appealServiceImpl) Resolve(id uint, req *ResolveRequest, userID uint) error {
appeal, err := s.appealRepo.GetByID(id)
if err != nil {
return err
}
now := time.Now()
appeal.Status = models.AppealStatusResolved
appeal.ResolvedAt = &now
appeal.ResolvedBy = &userID
appeal.Resolution = req.Resolution
if err := s.appealRepo.Update(appeal); err != nil {
return err
}
// Создаем запись в истории
history := &models.AppealHistory{
AppealID: id,
UserID: &userID,
OldStatus: appeal.Status,
NewStatus: models.AppealStatusResolved,
Comment: "Обращение решено: " + req.Resolution,
}
if err := s.appealRepo.CreateHistory(history); err != nil {
return err
}
return nil
}
// GetHistory возвращает историю изменений
func (s *appealServiceImpl) GetHistory(appealID uint, offset, limit int) ([]models.AppealHistory, int64, error) {
if offset < 0 {
offset = 0
}
if limit <= 0 || limit > 100 {
limit = 20
}
histories, err := s.appealRepo.ListHistory(appealID, offset, limit)
if err != nil {
return nil, 0, err
}
// Для подсчета общего количества нужно реализовать отдельный метод
total := int64(len(histories))
return histories, total, nil
}
// GetStatistics возвращает статистику по обращениям
func (s *appealServiceImpl) GetStatistics() (*AppealStatisticsResponse, error) {
total, err := s.appealRepo.Count()
if err != nil {
return nil, err
}
// Здесь нужно добавить агрегационные запросы для статистики
// Для простоты возвращаем базовую структуру
stats := &AppealStatisticsResponse{
Total: total,
ByStatus: make(map[string]int64),
ByType: make(map[string]int64),
ByPriority: make(map[string]int64),
AvgResolveTime: 0,
}
// TODO: Реализовать подсчет статистики через raw SQL запросы
return stats, nil
}
// GetMyAppeals возвращает обращения текущего пользователя
func (s *appealServiceImpl) GetMyAppeals(userID uint, offset, limit int) ([]models.Appeal, int64, error) {
// Получаем все обращения и фильтруем по автору
allAppeals, err := s.appealRepo.List(0, 1000)
if err != nil {
return nil, 0, err
}
var myAppeals []models.Appeal
for _, appeal := range allAppeals {
if appeal.AuthorID != nil && *appeal.AuthorID == userID {
myAppeals = append(myAppeals, appeal)
}
}
total := int64(len(myAppeals))
start := offset
end := offset + limit
if start > len(myAppeals) {
return []models.Appeal{}, total, nil
}
if end > len(myAppeals) {
end = len(myAppeals)
}
return myAppeals[start:end], total, nil
}
// GetAppealsByAuthor возвращает обращения автора
func (s *appealServiceImpl) GetAppealsByAuthor(authorID uint, offset, limit int) ([]models.Appeal, int64, error) {
allAppeals, err := s.appealRepo.List(0, 1000)
if err != nil {
return nil, 0, err
}
var authorAppeals []models.Appeal
for _, appeal := range allAppeals {
if appeal.AuthorID != nil && *appeal.AuthorID == authorID {
authorAppeals = append(authorAppeals, appeal)
}
}
total := int64(len(authorAppeals))
start := offset
end := offset + limit
if start > len(authorAppeals) {
return []models.Appeal{}, total, nil
}
if end > len(authorAppeals) {
end = len(authorAppeals)
}
return authorAppeals[start:end], total, nil
}
// Вспомогательные функции валидации
func isValidAppealType(t models.AppealType) bool {
switch t {
case models.AppealTypeComplaint, models.AppealTypeSuggestion, models.AppealTypeWish, models.AppealTypeQuestion, models.AppealTypeOther:
return true
}
return false
}
func isValidAppealStatus(s models.AppealStatus) bool {
switch s {
case models.AppealStatusNew, models.AppealStatusInProgress, models.AppealStatusResolved, models.AppealStatusRejected, models.AppealStatusClosed:
return true
}
return false
}
func isValidAppealPriority(p models.AppealPriority) bool {
switch p {
case models.AppealPriorityLow, models.AppealPriorityMedium, models.AppealPriorityHigh, models.AppealPriorityCritical:
return true
}
return false
}
@@ -106,7 +106,7 @@ type Appeal struct {
// AppealHistory записывает историю изменений статуса обращения
type AppealHistory struct {
Base Base `gorm:"embedded"`
Base `gorm:"embedded"`
AppealID uint `gorm:"not null;index" json:"appeal_id"`
Appeal Appeal `gorm:"foreignKey:AppealID;references:ID" json:"appeal,omitempty"`
@@ -3,6 +3,7 @@ package router
import (
"api_yal/internal/config"
"api_yal/internal/domain/account"
"api_yal/internal/domain/appeal"
"api_yal/internal/domain/auth"
"api_yal/internal/domain/comment"
"api_yal/internal/domain/feetback"
@@ -60,17 +61,19 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
// Регистрируем маршурты обьектов
object.RegisterRoutes(r, db, config.JWTSecret)
// Регистрируем маршруты отзывов
feetback.RegisterRoutes(r, db, config.JWTSecret)
// Регистрация маршрутов для комментариев
comment.RegisterRoutes(r, db, config.JWTSecret)
// Регистрация маршрутов для райтинга
rating.RegisterRoutes(r, db, config.JWTSecret)
// Регистрируем маршруты обращений
appeal.RegisterRoutes(r, db, config.JWTSecret)
})
zapLogger.Info("Настройка маршрутов завершена")