Initial backend import
This commit is contained in:
451
internal/handlers/public.go
Normal file
451
internal/handlers/public.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type CategoryDTO struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
IconKey string `json:"iconKey"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ResourceDTO struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Language string `json:"language"`
|
||||
CategoryID int `json:"categoryId"`
|
||||
CategorySlug string `json:"categorySlug"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CoverImage string `json:"coverImage,omitempty"`
|
||||
FileURL string `json:"fileUrl,omitempty"`
|
||||
PreviewURL string `json:"previewUrl,omitempty"`
|
||||
ExternalURL string `json:"externalUrl,omitempty"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BadgeLabel string `json:"badgeLabel,omitempty"`
|
||||
IsDownloadable bool `json:"isDownloadable"`
|
||||
IsRecommended bool `json:"isRecommended"`
|
||||
PublishedAt *string `json:"publishedAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
func pickLangName(r *http.Request, zhTW, zhCN, en string) string {
|
||||
lang := strings.TrimSpace(r.URL.Query().Get("lang"))
|
||||
if lang == "" {
|
||||
lang = r.Header.Get("Accept-Language")
|
||||
}
|
||||
lang = strings.ToLower(strings.TrimSpace(strings.Split(lang, ",")[0]))
|
||||
switch {
|
||||
case strings.HasPrefix(lang, "zh-cn") || lang == "zh-hans":
|
||||
if zhCN != "" {
|
||||
return zhCN
|
||||
}
|
||||
case strings.HasPrefix(lang, "en"):
|
||||
if en != "" {
|
||||
return en
|
||||
}
|
||||
}
|
||||
if zhTW != "" {
|
||||
return zhTW
|
||||
}
|
||||
if zhCN != "" {
|
||||
return zhCN
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
func ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
rows, err := pool.Query(r.Context(), `
|
||||
SELECT id, slug, name_zh_tw, name_zh_cn, name_en, description_zh_tw, icon_key, sort_order, updated_at
|
||||
FROM categories WHERE is_visible = TRUE ORDER BY sort_order ASC, id ASC`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]CategoryDTO, 0)
|
||||
for rows.Next() {
|
||||
var c CategoryDTO
|
||||
var zhTW, zhCN, en *string
|
||||
var desc *string
|
||||
var updated time.Time
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &zhTW, &zhCN, &en, &desc, &c.IconKey, &c.SortOrder, &updated); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Name = pickLangName(r, deref(zhTW), deref(zhCN), deref(en))
|
||||
c.Description = deref(desc)
|
||||
c.UpdatedAt = updated.UTC().Format(time.RFC3339)
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func ListResources(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
page := atoiDef(r.URL.Query().Get("page"), 1)
|
||||
limit := atoiDef(r.URL.Query().Get("limit"), 20)
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
typ := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
lang := strings.TrimSpace(r.URL.Query().Get("language"))
|
||||
catSlug := strings.TrimSpace(r.URL.Query().Get("category"))
|
||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||
if sort == "" {
|
||||
sort = "latest"
|
||||
}
|
||||
|
||||
order := "r.updated_at DESC"
|
||||
switch sort {
|
||||
case "published":
|
||||
order = "r.published_at DESC NULLS LAST, r.updated_at DESC"
|
||||
case "recommended":
|
||||
order = "r.is_recommended DESC, r.sort_order DESC, r.updated_at DESC"
|
||||
case "popular":
|
||||
order = "(r.download_count + r.favorite_count + r.share_count) DESC, r.updated_at DESC"
|
||||
}
|
||||
|
||||
base := `
|
||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||
WHERE r.status = 'published' AND r.is_public = TRUE`
|
||||
|
||||
args := []any{}
|
||||
cond := ""
|
||||
if catSlug != "" {
|
||||
args = append(args, catSlug)
|
||||
cond += " AND c.slug = $" + strconv.Itoa(len(args))
|
||||
}
|
||||
if typ != "" && typ != "all" {
|
||||
args = append(args, typ)
|
||||
cond += " AND r.type = $" + strconv.Itoa(len(args))
|
||||
}
|
||||
if lang != "" {
|
||||
args = append(args, lang)
|
||||
cond += " AND r.language = $" + strconv.Itoa(len(args))
|
||||
}
|
||||
if q != "" {
|
||||
pat := "%" + q + "%"
|
||||
start := len(args) + 1
|
||||
args = append(args, pat, pat, pat)
|
||||
cond += fmt.Sprintf(` AND (r.title ILIKE $%d OR r.description ILIKE $%d OR EXISTS (
|
||||
SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND t.name ILIKE $%d))`,
|
||||
start, start+1, start+2)
|
||||
}
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
if tag != "" {
|
||||
tagLower := strings.ToLower(tag)
|
||||
args = append(args, tagLower)
|
||||
i := strconv.Itoa(len(args))
|
||||
cond += ` AND EXISTS (
|
||||
SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id
|
||||
WHERE rt.resource_id = r.id AND (lower(t.slug) = $` + i + ` OR lower(t.name) = $` + i + `))`
|
||||
}
|
||||
|
||||
countSQL := `SELECT COUNT(*) FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.status = 'published' AND r.is_public = TRUE` + cond
|
||||
var total int
|
||||
if err := pool.QueryRow(r.Context(), countSQL, args...).Scan(&total); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sqlStr := base + cond + " ORDER BY " + order + " LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := pool.Query(r.Context(), sqlStr, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := readResourceRows(rows, r, pool)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"items": items, "page": page, "limit": limit, "total": total})
|
||||
}
|
||||
|
||||
func ListRecommended(w http.ResponseWriter, r *http.Request) {
|
||||
listFeatured(w, r, "r.is_recommended = TRUE", "r.sort_order DESC, r.updated_at DESC")
|
||||
}
|
||||
|
||||
func ListLatest(w http.ResponseWriter, r *http.Request) {
|
||||
listFeatured(w, r, "TRUE", "r.updated_at DESC")
|
||||
}
|
||||
|
||||
func ListPopular(w http.ResponseWriter, r *http.Request) {
|
||||
listFeatured(w, r, "TRUE", "(r.download_count + r.favorite_count + r.share_count) DESC, r.updated_at DESC")
|
||||
}
|
||||
|
||||
func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order string) {
|
||||
pool := poolFrom(r)
|
||||
limit := atoiDef(r.URL.Query().Get("limit"), 12)
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
sqlStr := `
|
||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||
WHERE r.status = 'published' AND r.is_public = TRUE AND (` + extra + `)
|
||||
ORDER BY ` + order + ` LIMIT $1`
|
||||
args := []any{limit}
|
||||
rows, err := pool.Query(r.Context(), sqlStr, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := readResourceRows(rows, r, pool)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func GetResource(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
row := pool.QueryRow(r.Context(), `
|
||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||
WHERE r.id = $1 AND r.status = 'published' AND r.is_public = TRUE`, id)
|
||||
dto, err := scanResourceRow(row, r, pool)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, dto)
|
||||
}
|
||||
|
||||
func RelatedResources(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var catID int
|
||||
if err := pool.QueryRow(r.Context(), `SELECT category_id FROM resources WHERE id = $1`, id).Scan(&catID); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rows, err := pool.Query(r.Context(), `
|
||||
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
|
||||
r.is_downloadable, r.is_recommended, r.published_at, r.updated_at
|
||||
FROM resources r JOIN categories c ON c.id = r.category_id
|
||||
WHERE r.status = 'published' AND r.is_public = TRUE AND r.category_id = $1 AND r.id <> $2
|
||||
ORDER BY r.updated_at DESC LIMIT 8`, catID, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := readResourceRows(rows, r, pool)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func LogSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := jsonDecode(r, &body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(body.Query)
|
||||
if q == "" {
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
pool := poolFrom(r)
|
||||
_, _ = pool.Exec(r.Context(), `INSERT INTO search_logs (query) VALUES ($1)`, q)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func IncrView(w http.ResponseWriter, r *http.Request) {
|
||||
incrMetric(w, r, "view_count")
|
||||
}
|
||||
|
||||
func IncrDownload(w http.ResponseWriter, r *http.Request) {
|
||||
incrMetric(w, r, "download_count")
|
||||
}
|
||||
|
||||
func IncrShare(w http.ResponseWriter, r *http.Request) {
|
||||
incrMetric(w, r, "share_count")
|
||||
}
|
||||
|
||||
func FavoriteDelta(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Add bool `json:"add"`
|
||||
}
|
||||
if err := jsonDecode(r, &body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pool := poolFrom(r)
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var q string
|
||||
if body.Add {
|
||||
q = `UPDATE resources SET favorite_count = favorite_count + 1 WHERE id = $1`
|
||||
} else {
|
||||
q = `UPDATE resources SET favorite_count = GREATEST(0, favorite_count - 1) WHERE id = $1`
|
||||
}
|
||||
if _, err := pool.Exec(r.Context(), q, id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func incrMetric(w http.ResponseWriter, r *http.Request, col string) {
|
||||
pool := poolFrom(r)
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// col is whitelisted
|
||||
switch col {
|
||||
case "view_count", "download_count", "share_count":
|
||||
default:
|
||||
http.Error(w, "bad metric", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err = pool.Exec(r.Context(), `UPDATE resources SET `+col+` = `+col+` + 1 WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func readResourceRows(rows pgx.Rows, r *http.Request, pool *pgxpool.Pool) ([]ResourceDTO, error) {
|
||||
list := make([]ResourceDTO, 0)
|
||||
for rows.Next() {
|
||||
dto, err := scanResourceRow(rows, r, pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, dto)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
func scanResourceRow(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}, r *http.Request, pool *pgxpool.Pool) (ResourceDTO, error) {
|
||||
var dto ResourceDTO
|
||||
var pubAt *time.Time
|
||||
var updated time.Time
|
||||
var zhTW, zhCN, en *string
|
||||
var id uuid.UUID
|
||||
err := scanner.Scan(
|
||||
&id, &dto.Title, &dto.Description, &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en,
|
||||
&dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BodyText, &dto.BadgeLabel,
|
||||
&dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated,
|
||||
)
|
||||
if err != nil {
|
||||
return dto, err
|
||||
}
|
||||
dto.ID = id.String()
|
||||
dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en))
|
||||
if pubAt != nil {
|
||||
s := pubAt.UTC().Format(time.RFC3339)
|
||||
dto.PublishedAt = &s
|
||||
}
|
||||
dto.UpdatedAt = updated.UTC().Format(time.RFC3339)
|
||||
if pool != nil {
|
||||
tags, err := loadTagNames(r.Context(), pool, id)
|
||||
if err == nil {
|
||||
dto.Tags = tags
|
||||
}
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func loadTagNames(ctx context.Context, pool *pgxpool.Pool, id uuid.UUID) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT t.name FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = $1 ORDER BY t.name`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user