This commit is contained in:
2026-04-18 14:38:37 +03:00
parent 3ee88f9343
commit 6c871cd9eb
21 changed files with 1736 additions and 22 deletions

View File

@@ -177,6 +177,155 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users:
get:
tags:
- Social Rating
summary: List users with current social rating
operationId: listUsersWithRatings
security:
- bearerAuth: []
parameters:
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
'200':
description: Users with current rating values
content:
application/json:
schema:
$ref: '#/components/schemas/UsersResponse'
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{userId}:
get:
tags:
- Social Rating
summary: Get user with current social rating
operationId: getUserWithRating
security:
- bearerAuth: []
parameters:
- in: path
name: userId
required: true
schema:
type: integer
format: uint64
responses:
'200':
description: User with current rating value
content:
application/json:
schema:
$ref: '#/components/schemas/UserRatingResponse'
'400':
description: Invalid user id
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{userId}/social-rating/history:
get:
tags:
- Social Rating
summary: Get social rating history for user
operationId: getUserRatingHistory
security:
- bearerAuth: []
parameters:
- in: path
name: userId
required: true
schema:
type: integer
format: uint64
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
'200':
description: Rating history for user
content:
application/json:
schema:
$ref: '#/components/schemas/HistoryResponse'
'400':
description: Invalid user id
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/social-rating/operations:
get:
tags:
- Social Rating
summary: Get recent social rating operations
operationId: getRecentSocialRatingOperations
security:
- bearerAuth: []
parameters:
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
'200':
description: Recent rating operations across all users
content:
application/json:
schema:
$ref: '#/components/schemas/HistoryResponse'
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/social-rating/decrease:
post:
tags:
@@ -321,6 +470,34 @@ components:
required:
- user
UsersResponse:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/UserWithRating'
required:
- users
UserRatingResponse:
type: object
properties:
user:
$ref: '#/components/schemas/UserWithRating'
required:
- user
HistoryResponse:
type: object
properties:
operations:
type: array
items:
$ref: '#/components/schemas/SocialRatingOperation'
required:
- operations
User:
type: object
properties:
@@ -350,6 +527,42 @@ components:
- createdAt
- updatedAt
UserWithRating:
type: object
properties:
id:
type: integer
format: uint64
example: 2
email:
type: string
format: email
example: user@example.com
isAdmin:
type: boolean
example: false
score:
type: integer
example: -4
lastOperationId:
type: integer
format: uint64
nullable: true
example: 15
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- id
- email
- isAdmin
- score
- createdAt
- updatedAt
UserSocialRating:
type: object
properties:

View File

@@ -2,6 +2,7 @@ package server
import (
"net/http"
"strings"
"time"
"github.com/gin-contrib/cors"
@@ -16,10 +17,21 @@ import (
func NewRouter(db *gorm.DB, cfg config.Config) *gin.Engine {
router := gin.Default()
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8081", "http://127.0.0.1:8081", "https://social-rating.nekiiinkognito.ru/"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowOriginFunc: func(origin string) bool {
switch {
case strings.HasPrefix(origin, "http://localhost:"):
return true
case strings.HasPrefix(origin, "http://127.0.0.1:"):
return true
case origin == "https://social-rating.nekiiinkognito.ru":
return true
default:
return false
}
},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
@@ -38,6 +50,10 @@ func NewRouter(db *gorm.DB, cfg config.Config) *gin.Engine {
protected := api.Group("/")
protected.Use(auth.Middleware(cfg.JWTSecret))
protected.GET("/auth/me", authHandler.Me)
protected.GET("/users", socialRatingHandler.ListUsers)
protected.GET("/users/:userId", socialRatingHandler.GetUser)
protected.GET("/users/:userId/social-rating/history", socialRatingHandler.GetUserHistory)
protected.GET("/social-rating/operations", socialRatingHandler.GetRecentOperations)
protected.POST("/social-rating/increase", socialRatingHandler.Increase)
protected.POST("/social-rating/decrease", socialRatingHandler.Decrease)

View File

@@ -3,6 +3,7 @@ package socialrating
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -26,10 +27,79 @@ type ChangeResponse struct {
CurrentRating models.UserSocialRating `json:"currentRating"`
}
type UsersResponse struct {
Users []UserWithRating `json:"users"`
}
type UserRatingResponse struct {
User UserWithRating `json:"user"`
}
type HistoryResponse struct {
Operations []models.SocialRatingOperation `json:"operations"`
}
func NewHandler(service Service) Handler {
return Handler{Service: service}
}
func (h Handler) ListUsers(ctx *gin.Context) {
limit := parsePositiveLimit(ctx.Query("limit"), 50)
users, err := h.Service.ListUsersWithRatings(limit)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
ctx.JSON(http.StatusOK, UsersResponse{Users: users})
}
func (h Handler) GetUser(ctx *gin.Context) {
userID, err := parseUintParam(ctx.Param("userId"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.Service.GetUserRating(userID)
if err != nil {
handleApplyChangeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, UserRatingResponse{User: user})
}
func (h Handler) GetUserHistory(ctx *gin.Context) {
userID, err := parseUintParam(ctx.Param("userId"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
limit := parsePositiveLimit(ctx.Query("limit"), 50)
operations, err := h.Service.GetUserHistory(userID, limit)
if err != nil {
handleApplyChangeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, HistoryResponse{Operations: operations})
}
func (h Handler) GetRecentOperations(ctx *gin.Context) {
limit := parsePositiveLimit(ctx.Query("limit"), 50)
operations, err := h.Service.GetRecentOperations(limit)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load operations"})
return
}
ctx.JSON(http.StatusOK, HistoryResponse{Operations: operations})
}
func (h Handler) Increase(ctx *gin.Context) {
h.applySignedChange(ctx, "increase", 1)
}
@@ -97,3 +167,12 @@ func handleApplyChangeError(ctx *gin.Context, err error) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
func parseUintParam(raw string) (uint, error) {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
return 0, errors.New("invalid user id")
}
return uint(value), nil
}

View File

@@ -2,7 +2,9 @@ package socialrating
import (
"errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -22,6 +24,16 @@ type ChangeInput struct {
Source string
}
type UserWithRating struct {
ID uint `json:"id"`
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Score int `json:"score"`
LastOperationID *uint `json:"lastOperationId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func NewService(db *gorm.DB) Service {
return Service{DB: db}
}
@@ -95,3 +107,131 @@ func ensureUserExists(tx *gorm.DB, userID uint) error {
var user models.User
return tx.First(&user, "id = ?", userID).Error
}
func (s Service) ListUsersWithRatings(limit int) ([]UserWithRating, error) {
if limit <= 0 {
limit = 50
}
var rows []struct {
ID uint
Email string
IsAdmin bool
Score int
LastOperationID *uint
CreatedAt time.Time
UpdatedAt time.Time
}
err := s.DB.
Table("users").
Select(
"users.id, users.email, users.is_admin, COALESCE(user_social_ratings.score, 0) AS score, user_social_ratings.last_operation_id, users.created_at, users.updated_at",
).
Joins("LEFT JOIN user_social_ratings ON user_social_ratings.user_id = users.id").
Order("score DESC, users.id ASC").
Limit(limit).
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make([]UserWithRating, 0, len(rows))
for _, row := range rows {
result = append(result, UserWithRating{
ID: row.ID,
Email: row.Email,
IsAdmin: row.IsAdmin,
Score: row.Score,
LastOperationID: row.LastOperationID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
return result, nil
}
func (s Service) GetUserRating(userID uint) (UserWithRating, error) {
var row struct {
ID uint
Email string
IsAdmin bool
Score int
LastOperationID *uint
CreatedAt time.Time
UpdatedAt time.Time
}
err := s.DB.
Table("users").
Select(
"users.id, users.email, users.is_admin, COALESCE(user_social_ratings.score, 0) AS score, user_social_ratings.last_operation_id, users.created_at, users.updated_at",
).
Joins("LEFT JOIN user_social_ratings ON user_social_ratings.user_id = users.id").
Where("users.id = ?", userID).
Take(&row).Error
if err != nil {
return UserWithRating{}, err
}
return UserWithRating{
ID: row.ID,
Email: row.Email,
IsAdmin: row.IsAdmin,
Score: row.Score,
LastOperationID: row.LastOperationID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (s Service) GetUserHistory(userID uint, limit int) ([]models.SocialRatingOperation, error) {
if limit <= 0 {
limit = 50
}
if err := ensureUserExists(s.DB, userID); err != nil {
return nil, err
}
var operations []models.SocialRatingOperation
err := s.DB.
Where("target_user_id = ?", userID).
Order("created_at DESC, id DESC").
Limit(limit).
Find(&operations).Error
return operations, err
}
func (s Service) GetRecentOperations(limit int) ([]models.SocialRatingOperation, error) {
if limit <= 0 {
limit = 50
}
var operations []models.SocialRatingOperation
err := s.DB.
Order("created_at DESC, id DESC").
Limit(limit).
Find(&operations).Error
return operations, err
}
func parsePositiveLimit(raw string, fallback int) int {
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return fallback
}
if value > 200 {
return 200
}
return value
}