feat(backend): leaderboard tiers, N+1 fix, contributors, certificate approval, physical disable

- leaderboard: batch-fetch users (GetUsersByIDs), deterministic tie-break by user_id
- leaderboard: tier system (champion/gold/silver/bronze) based on rank percentile
- store: HasCompletedCurriculum requires cert.Status == "issued"; pending does not satisfy
- certificate: issueCertificateIfEligible creates pending row; admin approves via handleAdminIssueCertificate
- certificate: handleRequestPhysicalCertificate returns 410 Gone (feature disabled)
- settings: contributors CRUD (loadContributors, validateContributors, handleAdminContributorAvatarUpload)
- community: expose contributors in /api/community response
- rate_limit: separate DevMode bypass from redis-nil check in allowWSHandshake
- router: remove /api/search route; fix missing tab indent on /api/meta line
- migration 0032: contributors app_setting with seed entry
This commit is contained in:
ahp
2026-10-20 17:51:22 +03:30
parent 617d8ff680
commit 161610049c
9 changed files with 232 additions and 69 deletions
+137
View File
@@ -3,9 +3,11 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"pargar/backend/internal/observability"
@@ -93,12 +95,138 @@ func validateSocialLinks(v string) string {
return ""
}
func (s *Server) loadContributors(ctx context.Context) []map[string]any {
raw, err := s.store.GetSetting(ctx, "contributors")
if err != nil || strings.TrimSpace(raw) == "" {
return []map[string]any{}
}
var list []map[string]any
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return []map[string]any{}
}
out := make([]map[string]any, 0, len(list))
for _, item := range list {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
continue
}
role, _ := item["role"].(string)
bio, _ := item["bio"].(string)
url, _ := item["url"].(string)
avatar, _ := item["avatar"].(string)
contributions, _ := item["contributions"].([]any)
cats := make([]string, 0, len(contributions))
for _, c := range contributions {
if s, ok := c.(string); ok && s != "" {
cats = append(cats, s)
}
}
out = append(out, map[string]any{
"name": strings.TrimSpace(name),
"role": strings.TrimSpace(role),
"bio": strings.TrimSpace(bio),
"url": strings.TrimSpace(url),
"avatar": strings.TrimSpace(avatar),
"contributions": cats,
})
}
return out
}
func validateContributors(v string) string {
if v == "" || v == "[]" {
return ""
}
var tmp []map[string]any
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
return "فرمت مشارکت‌کنندگان نامعتبر است"
}
if len(tmp) > 50 {
return "حداکثر ۵۰ مشارکت‌کننده مجاز است"
}
for _, item := range tmp {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return "نام مشارکت‌کننده الزامی است"
}
if utf8.RuneCountInString(strings.TrimSpace(name)) > 100 {
return "نام مشارکت‌کننده خیلی طولانی است"
}
if role, _ := item["role"].(string); utf8.RuneCountInString(strings.TrimSpace(role)) > 100 {
return "نقش مشارکت‌کننده خیلی طولانی است"
}
if bio, _ := item["bio"].(string); utf8.RuneCountInString(strings.TrimSpace(bio)) > 300 {
return "بیوگرافی خیلی طولانی است"
}
if url, _ := item["url"].(string); url != "" {
u := strings.TrimSpace(url)
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
return "لینک پروفایل باید با http شروع شود"
}
if utf8.RuneCountInString(u) > 500 {
return "لینک پروفایل خیلی طولانی است"
}
}
if contributions, ok := item["contributions"].([]any); ok {
if len(contributions) > 20 {
return "حداکثر ۲۰ دسته مشارکت برای هر نفر مجاز است"
}
for _, c := range contributions {
s, ok := c.(string)
if !ok {
return "دسته مشارکت باید رشته باشد"
}
s = strings.TrimSpace(s)
if s == "" {
return "دسته مشارکت نمی‌تواند خالی باشد"
}
if utf8.RuneCountInString(s) > 50 {
return "دسته مشارکت خیلی طولانی است"
}
}
}
}
return ""
}
func (s *Server) handleAdminContributorAvatarUpload(w http.ResponseWriter, r *http.Request) {
const maxSize = 6 << 20
r.Body = http.MaxBytesReader(w, r.Body, maxSize+1<<20)
if err := r.ParseMultipartForm(maxSize + 1<<20); err != nil {
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است")
return
}
file, h, err := r.FormFile("file")
if err != nil {
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
return
}
defer file.Close()
if h.Size > maxSize {
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد")
return
}
accepted, err := acceptAvatarImage(h, file)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
key := fmt.Sprintf("contributor-avatars/%d%s", time.Now().UnixNano(), accepted.Ext)
rawURL, err := s.storage.Save(key, file, h.Size)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)})
}
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
keys := []string{
"physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days",
"sponsors",
"social_links",
"contributors",
"hearts_max",
"hearts_regen_minutes",
"hearts_lock_on_empty",
@@ -163,6 +291,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
"registration_enabled": true,
"registration_require_whitelist": true,
"social_links": true,
"contributors": true,
"hearts_max": true,
"hearts_regen_minutes": true,
"hearts_lock_on_empty": true,
@@ -226,6 +355,14 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
writeErr(w, http.StatusBadRequest, msg)
return
}
case "contributors":
if v == "" {
v = "[]"
}
if msg := validateContributors(v); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
}
case "hearts_max":
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > store.MaxHearts {