@@ -42,6 +42,12 @@ func main() {
|
||||
if err := handlers.EnsureResourceI18nColumns(ctx, pool); err != nil {
|
||||
log.Fatalf("resources i18n columns: %v", err)
|
||||
}
|
||||
if err := handlers.EnsureCategoryI18nColumns(ctx, pool); err != nil {
|
||||
log.Fatalf("categories i18n columns: %v", err)
|
||||
}
|
||||
if err := handlers.EnsurePostsSchema(ctx, pool); err != nil {
|
||||
log.Fatalf("posts schema: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -113,6 +119,12 @@ func main() {
|
||||
})
|
||||
|
||||
r.Get("/categories", handlers.ListCategories)
|
||||
r.Get("/posts", handlers.ListPosts)
|
||||
r.Get("/posts/recommended", handlers.ListPostsRecommended)
|
||||
r.Get("/posts/latest", handlers.ListPostsLatest)
|
||||
r.Get("/posts/search", handlers.SearchPosts)
|
||||
r.Get("/posts/{id}", handlers.GetPost)
|
||||
r.Post("/posts/{id}/attachments/{aid}/download", handlers.PostAttachmentDownload)
|
||||
r.Get("/resources", handlers.ListResources)
|
||||
r.Get("/resources/recommended", handlers.ListRecommended)
|
||||
r.Get("/resources/latest", handlers.ListLatest)
|
||||
|
||||
13
go.mod
13
go.mod
@@ -1,8 +1,11 @@
|
||||
module github.com/arkie/ark-database
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||
github.com/ethereum/go-ethereum v1.17.2
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
@@ -10,13 +13,12 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/image v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
@@ -26,7 +28,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
@@ -42,7 +43,7 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
10
go.sum
10
go.sum
@@ -110,12 +110,14 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
35
internal/handlers/category_i18n.go
Normal file
35
internal/handlers/category_i18n.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// EnsureCategoryI18nColumns adds per-locale category name/description columns.
|
||||
func EnsureCategoryI18nColumns(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_zh TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_en TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ja TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ko TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_vi TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_id TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ms TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_zh TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ja TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ko TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_vi TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_id TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ms TEXT`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
UPDATE categories SET name_zh = COALESCE(NULLIF(name_zh, ''), NULLIF(name_zh_cn, ''), name_zh_tw)
|
||||
WHERE COALESCE(name_zh, '') = '';
|
||||
UPDATE categories SET description_zh = COALESCE(description_zh, description_zh_tw)
|
||||
WHERE description_zh IS NULL`)
|
||||
return err
|
||||
}
|
||||
36
internal/handlers/category_text.go
Normal file
36
internal/handlers/category_text.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
const categoryNameColsSQL = `
|
||||
COALESCE(c.name_zh,''), COALESCE(c.name_en,''), COALESCE(c.name_ja,''),
|
||||
COALESCE(c.name_ko,''), COALESCE(c.name_vi,''), COALESCE(c.name_id,''), COALESCE(c.name_ms,'')`
|
||||
|
||||
const categoryDescColsSQL = `
|
||||
COALESCE(c.description_zh,''), COALESCE(c.description_en,''), COALESCE(c.description_ja,''),
|
||||
COALESCE(c.description_ko,''), COALESCE(c.description_vi,''), COALESCE(c.description_id,''), COALESCE(c.description_ms,'')`
|
||||
|
||||
const categoryI18nColsSQL = categoryNameColsSQL + `, ` + categoryDescColsSQL
|
||||
|
||||
type categoryTextI18n struct {
|
||||
NameZh, NameEn, NameJa, NameKo, NameVi, NameId, NameMs string
|
||||
DescZh, DescEn, DescJa, DescKo, DescVi, DescId, DescMs string
|
||||
}
|
||||
|
||||
func (t categoryTextI18n) pickName(r *http.Request) string {
|
||||
return pickLangField(r, t.NameZh, t.NameEn, t.NameJa, t.NameKo, t.NameVi, t.NameId, t.NameMs)
|
||||
}
|
||||
|
||||
func (t categoryTextI18n) pickDesc(r *http.Request) string {
|
||||
return pickLangField(r, t.DescZh, t.DescEn, t.DescJa, t.DescKo, t.DescVi, t.DescId, t.DescMs)
|
||||
}
|
||||
|
||||
func scanCategoryTextI18n(
|
||||
nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs string,
|
||||
descZh, descEn, descJa, descKo, descVi, descId, descMs string,
|
||||
) categoryTextI18n {
|
||||
return categoryTextI18n{
|
||||
NameZh: nameZh, NameEn: nameEn, NameJa: nameJa, NameKo: nameKo, NameVi: nameVi, NameId: nameId, NameMs: nameMs,
|
||||
DescZh: descZh, DescEn: descEn, DescJa: descJa, DescKo: descKo, DescVi: descVi, DescId: descId, DescMs: descMs,
|
||||
}
|
||||
}
|
||||
61
internal/handlers/post_schema.go
Normal file
61
internal/handlers/post_schema.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// EnsurePostsSchema creates posts, post_attachments, and post_tags tables.
|
||||
func EnsurePostsSchema(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id INT NOT NULL REFERENCES categories(id),
|
||||
language TEXT NOT NULL DEFAULT 'zh',
|
||||
text_zh TEXT,
|
||||
text_en TEXT,
|
||||
text_ja TEXT,
|
||||
text_ko TEXT,
|
||||
text_vi TEXT,
|
||||
text_id TEXT,
|
||||
text_ms TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_recommended BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
view_count INT NOT NULL DEFAULT 0,
|
||||
download_count INT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status_public ON posts(status, is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC NULLS LAST, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_recommended ON posts(is_recommended) WHERE is_recommended = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
width INT,
|
||||
height INT,
|
||||
duration_sec INT,
|
||||
poster_url TEXT,
|
||||
thumbnail_url TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_attachments_post ON post_attachments(post_id, sort_order ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
74
internal/handlers/post_text.go
Normal file
74
internal/handlers/post_text.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const postI18nColsSQL = `
|
||||
COALESCE(p.text_zh,''), COALESCE(p.text_en,''), COALESCE(p.text_ja,''),
|
||||
COALESCE(p.text_ko,''), COALESCE(p.text_vi,''), COALESCE(p.text_id,''), COALESCE(p.text_ms,'')`
|
||||
|
||||
const maxPostTextLen = 32768
|
||||
const maxPostAttachments = 20
|
||||
|
||||
var postLinkRe = regexp.MustCompile(`https?://`)
|
||||
|
||||
type postTextI18n struct {
|
||||
TextZh, TextEn, TextJa, TextKo, TextVi, TextId, TextMs string
|
||||
}
|
||||
|
||||
type postLocalePayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (t postTextI18n) pick(r *http.Request) string {
|
||||
return pickLangField(r, t.TextZh, t.TextEn, t.TextJa, t.TextKo, t.TextVi, t.TextId, t.TextMs)
|
||||
}
|
||||
|
||||
func (t postTextI18n) anyNonEmpty() bool {
|
||||
return strings.TrimSpace(t.TextZh) != "" ||
|
||||
strings.TrimSpace(t.TextEn) != "" ||
|
||||
strings.TrimSpace(t.TextJa) != "" ||
|
||||
strings.TrimSpace(t.TextKo) != "" ||
|
||||
strings.TrimSpace(t.TextVi) != "" ||
|
||||
strings.TrimSpace(t.TextId) != "" ||
|
||||
strings.TrimSpace(t.TextMs) != ""
|
||||
}
|
||||
|
||||
func (t postTextI18n) legacyPrimary() string {
|
||||
if strings.TrimSpace(t.TextZh) != "" {
|
||||
return strings.TrimSpace(t.TextZh)
|
||||
}
|
||||
if strings.TrimSpace(t.TextEn) != "" {
|
||||
return strings.TrimSpace(t.TextEn)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scanPostTextI18n(zh, en, ja, ko, vi, id, ms string) postTextI18n {
|
||||
return postTextI18n{TextZh: zh, TextEn: en, TextJa: ja, TextKo: ko, TextVi: vi, TextId: id, TextMs: ms}
|
||||
}
|
||||
|
||||
func postTextHasLink(text string) bool {
|
||||
return postLinkRe.MatchString(text)
|
||||
}
|
||||
|
||||
func truncatePostTexts(t postTextI18n) postTextI18n {
|
||||
tr := func(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > maxPostTextLen {
|
||||
return s[:maxPostTextLen]
|
||||
}
|
||||
return s
|
||||
}
|
||||
t.TextZh = tr(t.TextZh)
|
||||
t.TextEn = tr(t.TextEn)
|
||||
t.TextJa = tr(t.TextJa)
|
||||
t.TextKo = tr(t.TextKo)
|
||||
t.TextVi = tr(t.TextVi)
|
||||
t.TextId = tr(t.TextId)
|
||||
t.TextMs = tr(t.TextMs)
|
||||
return t
|
||||
}
|
||||
418
internal/handlers/posts_common.go
Normal file
418
internal/handlers/posts_common.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type AttachmentDTO struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
DurationSec *int `json:"durationSec,omitempty"`
|
||||
PosterURL string `json:"posterUrl,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
}
|
||||
|
||||
type PostDTO struct {
|
||||
ID string `json:"id"`
|
||||
CategoryID int `json:"categoryId"`
|
||||
CategorySlug string `json:"categorySlug"`
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Attachments []AttachmentDTO `json:"attachments"`
|
||||
IsRecommended bool `json:"isRecommended"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type PostListResponse struct {
|
||||
Items []PostDTO `json:"items"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type attachmentInput struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Width *int `json:"width"`
|
||||
Height *int `json:"height"`
|
||||
DurationSec *int `json:"durationSec"`
|
||||
PosterURL string `json:"posterUrl"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type adminPost struct {
|
||||
ID string `json:"id"`
|
||||
CategoryID int `json:"categoryId"`
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text"`
|
||||
Attachments []attachmentInput `json:"attachments"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
IsRecommended bool `json:"isRecommended"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *string `json:"publishedAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Localizations map[string]postLocalePayload `json:"localizations,omitempty"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
DownloadCount int `json:"downloadCount,omitempty"`
|
||||
}
|
||||
|
||||
const publicPostWhere = `p.status = 'published' AND p.is_public = TRUE AND (p.published_at IS NULL OR p.published_at <= NOW())`
|
||||
|
||||
func parsePublishedAtPtr(s *string) (*time.Time, error) {
|
||||
if s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*s))
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(*s))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
utc := t.UTC()
|
||||
return &utc, nil
|
||||
}
|
||||
|
||||
func resolvePostPublishedAt(pubIn *string, status string, existing *time.Time, isCreate bool) (*time.Time, error) {
|
||||
if pubIn != nil && strings.TrimSpace(*pubIn) != "" {
|
||||
return parsePublishedAtPtr(pubIn)
|
||||
}
|
||||
if status == "published" {
|
||||
if isCreate || existing == nil {
|
||||
t := time.Now().UTC()
|
||||
return &t, nil
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func formatTimePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func encodePostCursor(pub time.Time, id uuid.UUID) string {
|
||||
return pub.UTC().Format(time.RFC3339Nano) + "|" + id.String()
|
||||
}
|
||||
|
||||
func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) {
|
||||
parts := strings.SplitN(cursor, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, uuid.Nil, fmt.Errorf("bad cursor")
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, parts[0])
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, parts[0])
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, uuid.Nil, err
|
||||
}
|
||||
id, err := uuid.Parse(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, uuid.Nil, err
|
||||
}
|
||||
return t.UTC(), id, nil
|
||||
}
|
||||
|
||||
func postTypeFilterSQL(typ string, args *[]any) string {
|
||||
typ = strings.ToLower(strings.TrimSpace(typ))
|
||||
if typ == "" || typ == "all" {
|
||||
return ""
|
||||
}
|
||||
switch typ {
|
||||
case "image":
|
||||
*args = append(*args, "image")
|
||||
n := len(*args)
|
||||
return fmt.Sprintf(` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = $%d)`, n)
|
||||
case "video":
|
||||
return ` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%'))`
|
||||
case "pdf":
|
||||
return ` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%pdf%' OR pa.filename ILIKE '%.pdf'))`
|
||||
case "ppt":
|
||||
return ` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%presentation%' OR pa.filename ILIKE '%.ppt%' OR pa.filename ILIKE '%.pptx%' OR pa.filename ILIKE '%.key%'))`
|
||||
case "archive":
|
||||
return ` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.filename ILIKE '%.zip%' OR pa.filename ILIKE '%.rar%' OR pa.filename ILIKE '%.7z%'))`
|
||||
case "text":
|
||||
return ` AND NOT EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id)
|
||||
AND (COALESCE(p.text_zh,'') <> '' OR COALESCE(p.text_en,'') <> '' OR COALESCE(p.text_ja,'') <> '' OR COALESCE(p.text_ko,'') <> '' OR COALESCE(p.text_vi,'') <> '' OR COALESCE(p.text_id,'') <> '' OR COALESCE(p.text_ms,'') <> '')`
|
||||
case "link":
|
||||
return ` AND (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ja ~* 'https?://' OR p.text_ko ~* 'https?://' OR p.text_vi ~* 'https?://' OR p.text_id ~* 'https?://' OR p.text_ms ~* 'https?://')`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePostLangFilter(lang string) string {
|
||||
lang = strings.TrimSpace(lang)
|
||||
if lang == "" {
|
||||
return ""
|
||||
}
|
||||
return translateNormalizePostLang(lang)
|
||||
}
|
||||
|
||||
func translateNormalizePostLang(lang string) string {
|
||||
lang = strings.ToLower(lang)
|
||||
switch {
|
||||
case strings.HasPrefix(lang, "zh"):
|
||||
return "zh"
|
||||
case strings.HasPrefix(lang, "en"):
|
||||
return "en"
|
||||
case strings.HasPrefix(lang, "ja"):
|
||||
return "ja"
|
||||
case strings.HasPrefix(lang, "ko"):
|
||||
return "ko"
|
||||
case strings.HasPrefix(lang, "vi"):
|
||||
return "vi"
|
||||
case lang == "id", strings.HasPrefix(lang, "in"):
|
||||
return "id"
|
||||
case strings.HasPrefix(lang, "ms"):
|
||||
return "ms"
|
||||
default:
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
func validateAdminPost(ap *adminPost) error {
|
||||
if ap.CategoryID <= 0 {
|
||||
return fmt.Errorf("categoryId required")
|
||||
}
|
||||
if len(ap.Attachments) > maxPostAttachments {
|
||||
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
|
||||
}
|
||||
hasText := strings.TrimSpace(ap.Text) != ""
|
||||
hasAtt := len(ap.Attachments) > 0
|
||||
if !hasText && !hasAtt {
|
||||
return fmt.Errorf("post requires text and/or at least one attachment")
|
||||
}
|
||||
for i, a := range ap.Attachments {
|
||||
if strings.TrimSpace(a.URL) == "" {
|
||||
return fmt.Errorf("attachment %d: url required", i)
|
||||
}
|
||||
k := strings.TrimSpace(a.Kind)
|
||||
if k != "image" && k != "video" && k != "document" {
|
||||
return fmt.Errorf("attachment %d: invalid kind", i)
|
||||
}
|
||||
}
|
||||
if ap.Status == "" {
|
||||
ap.Status = "draft"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAttachmentsByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID) (map[uuid.UUID][]AttachmentDTO, error) {
|
||||
out := make(map[uuid.UUID][]AttachmentDTO)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT id, post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec,
|
||||
COALESCE(poster_url,''), COALESCE(thumbnail_url,''), sort_order
|
||||
FROM post_attachments WHERE post_id = ANY($1)
|
||||
ORDER BY post_id, sort_order ASC, id ASC`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var aid, pid uuid.UUID
|
||||
var a AttachmentDTO
|
||||
var kind, url, mime, filename string
|
||||
var size int64
|
||||
var w, h, dur *int
|
||||
var poster, thumb string
|
||||
var sort int
|
||||
if err := rows.Scan(&aid, &pid, &kind, &url, &mime, &filename, &size, &w, &h, &dur, &poster, &thumb, &sort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ID = aid.String()
|
||||
a.Kind = kind
|
||||
a.URL = url
|
||||
a.Mime = mime
|
||||
a.Filename = filename
|
||||
a.SizeBytes = size
|
||||
a.Width = w
|
||||
a.Height = h
|
||||
a.DurationSec = dur
|
||||
a.PosterURL = poster
|
||||
a.ThumbnailURL = thumb
|
||||
out[pid] = append(out[pid], a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT t.name FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = $1 ORDER BY t.name`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tags []string) error {
|
||||
_, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
var tid int
|
||||
slug := tagSlug(t)
|
||||
err := pool.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1, $2)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id`, t, slug).Scan(&tid)
|
||||
if err != nil {
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, tid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePostAttachments(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, inputs []attachmentInput) error {
|
||||
_, err := pool.Exec(ctx, `DELETE FROM post_attachments WHERE post_id = $1`, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, in := range inputs {
|
||||
kind := strings.TrimSpace(in.Kind)
|
||||
if kind == "" {
|
||||
kind = classifyAttachmentKind(in.Mime, in.Filename)
|
||||
}
|
||||
sort := in.SortOrder
|
||||
if sort == 0 {
|
||||
sort = i
|
||||
}
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO post_attachments (post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec, poster_url, thumbnail_url, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
postID, kind, strings.TrimSpace(in.URL), nullIfEmptyStr(in.Mime, "application/octet-stream"),
|
||||
nullIfEmptyStr(in.Filename, "file"), in.SizeBytes,
|
||||
in.Width, in.Height, in.DurationSec,
|
||||
nullIfEmpty(in.PosterURL), nullIfEmpty(in.ThumbnailURL), sort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyAttachmentKind(mime, filename string) string {
|
||||
m := strings.ToLower(mime)
|
||||
if strings.HasPrefix(m, "image/") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasPrefix(m, "video/") {
|
||||
return "video"
|
||||
}
|
||||
fn := strings.ToLower(filename)
|
||||
if strings.HasSuffix(fn, ".jpg") || strings.HasSuffix(fn, ".jpeg") || strings.HasSuffix(fn, ".png") || strings.HasSuffix(fn, ".gif") || strings.HasSuffix(fn, ".webp") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasSuffix(fn, ".mp4") || strings.HasSuffix(fn, ".mov") || strings.HasSuffix(fn, ".webm") {
|
||||
return "video"
|
||||
}
|
||||
return "document"
|
||||
}
|
||||
|
||||
func nullIfEmptyStr(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
|
||||
var dto PostDTO
|
||||
var texts postTextI18n
|
||||
var id uuid.UUID
|
||||
var catID int
|
||||
var slug string
|
||||
var lang string
|
||||
var pub, updated, created *time.Time
|
||||
err := row.Scan(
|
||||
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs,
|
||||
&lang, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
|
||||
)
|
||||
if err != nil {
|
||||
return dto, texts, err
|
||||
}
|
||||
dto.ID = id.String()
|
||||
dto.CategoryID = catID
|
||||
dto.CategorySlug = slug
|
||||
dto.Language = lang
|
||||
dto.Text = texts.pick(r)
|
||||
if pub != nil {
|
||||
dto.PublishedAt = pub.UTC().Format(time.RFC3339)
|
||||
} else if created != nil {
|
||||
dto.PublishedAt = created.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if updated != nil {
|
||||
dto.UpdatedAt = updated.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if created != nil {
|
||||
dto.CreatedAt = created.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return dto, texts, nil
|
||||
}
|
||||
|
||||
func postSelectBase() string {
|
||||
return `
|
||||
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), p.category_id, c.slug,
|
||||
p.is_recommended, p.published_at, p.updated_at, p.created_at
|
||||
FROM posts p JOIN categories c ON c.id = p.category_id`
|
||||
}
|
||||
|
||||
func postLimitDef(r *http.Request, def, max int) int {
|
||||
limit := atoiDef(r.URL.Query().Get("limit"), def)
|
||||
if limit > max {
|
||||
limit = max
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func postLanguageFilterSQL(lang string, args *[]any) string {
|
||||
norm := normalizePostLangFilter(lang)
|
||||
if norm == "" {
|
||||
return ""
|
||||
}
|
||||
*args = append(*args, norm)
|
||||
return fmt.Sprintf(` AND p.language = $%d`, len(*args))
|
||||
}
|
||||
215
internal/handlers/posts_public.go
Normal file
215
internal/handlers/posts_public.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func ListPosts(w http.ResponseWriter, r *http.Request) {
|
||||
listPostsQuery(w, r, false)
|
||||
}
|
||||
|
||||
func ListPostsRecommended(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
limit := postLimitDef(r, 12, 50)
|
||||
rows, err := pool.Query(r.Context(), postSelectBase()+`
|
||||
WHERE `+publicPostWhere+` AND p.is_recommended = TRUE
|
||||
ORDER BY p.sort_order ASC, p.published_at DESC NULLS LAST, p.id DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
items, err := collectPostRows(r, rows)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func ListPostsLatest(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
limit := postLimitDef(r, 8, 50)
|
||||
rows, err := pool.Query(r.Context(), postSelectBase()+`
|
||||
WHERE `+publicPostWhere+`
|
||||
ORDER BY p.published_at DESC NULLS LAST, p.id DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
items, err := collectPostRows(r, rows)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func SearchPosts(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeJSON(w, PostListResponse{Items: []PostDTO{}})
|
||||
return
|
||||
}
|
||||
listPostsQuery(w, r, true)
|
||||
}
|
||||
|
||||
func GetPost(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
row := pool.QueryRow(r.Context(), postSelectBase()+`
|
||||
WHERE p.id = $1 AND `+publicPostWhere, id)
|
||||
dto, _, err := scanPostRow(r, row)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
atts, _ := loadAttachmentsByPostIDs(r.Context(), pool, []uuid.UUID{id})
|
||||
dto.Attachments = atts[id]
|
||||
if dto.Attachments == nil {
|
||||
dto.Attachments = []AttachmentDTO{}
|
||||
}
|
||||
tags, _ := loadPostTagNames(r.Context(), pool, id)
|
||||
dto.Tags = tags
|
||||
writeJSON(w, dto)
|
||||
}
|
||||
|
||||
func PostAttachmentDownload(w http.ResponseWriter, r *http.Request) {
|
||||
pool := poolFrom(r)
|
||||
postID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
aid, err := uuid.Parse(chi.URLParam(r, "aid"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad attachment id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cmd, err := pool.Exec(r.Context(), `
|
||||
UPDATE posts SET download_count = download_count + 1, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'published' AND is_public = TRUE
|
||||
AND (published_at IS NULL OR published_at <= NOW())`, postID)
|
||||
if err != nil || cmd.RowsAffected() == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
_ = aid
|
||||
}
|
||||
|
||||
func listPostsQuery(w http.ResponseWriter, r *http.Request, searchMode bool) {
|
||||
pool := poolFrom(r)
|
||||
limit := postLimitDef(r, 20, 50)
|
||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
||||
catSlug := strings.TrimSpace(r.URL.Query().Get("category"))
|
||||
typ := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
if typ == "" {
|
||||
typ = "all"
|
||||
}
|
||||
langFilter := strings.TrimSpace(r.URL.Query().Get("language"))
|
||||
|
||||
base := postSelectBase() + ` WHERE ` + publicPostWhere
|
||||
args := []any{}
|
||||
cond := postTypeFilterSQL(typ, &args)
|
||||
cond += postLanguageFilterSQL(langFilter, &args)
|
||||
if catSlug != "" {
|
||||
args = append(args, catSlug)
|
||||
cond += fmt.Sprintf(` AND c.slug = $%d`, len(args))
|
||||
}
|
||||
if searchMode {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q != "" {
|
||||
pat := "%" + q + "%"
|
||||
args = append(args, pat)
|
||||
n := len(args)
|
||||
cond += fmt.Sprintf(` AND (
|
||||
p.text_zh ILIKE $%d OR p.text_en ILIKE $%d OR p.text_ja ILIKE $%d OR p.text_ko ILIKE $%d OR
|
||||
p.text_vi ILIKE $%d OR p.text_id ILIKE $%d OR p.text_ms ILIKE $%d OR
|
||||
EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.filename ILIKE $%d) OR
|
||||
EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = p.id AND t.name ILIKE $%d))`,
|
||||
n, n, n, n, n, n, n, n, n)
|
||||
}
|
||||
}
|
||||
order := ` ORDER BY p.published_at DESC NULLS LAST, p.id DESC`
|
||||
if cursor != "" {
|
||||
pubT, cid, err := decodePostCursor(cursor)
|
||||
if err != nil {
|
||||
http.Error(w, "bad cursor", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args = append(args, pubT, cid)
|
||||
n := len(args)
|
||||
cond += fmt.Sprintf(` AND (p.published_at, p.id) < ($%d::timestamptz, $%d::uuid)`, n-1, n)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
limitArg := len(args)
|
||||
q := base + cond + order + fmt.Sprintf(` LIMIT $%d`, limitArg)
|
||||
|
||||
rows, err := pool.Query(r.Context(), q, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
items, err := collectPostRows(r, rows)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var nextCursor string
|
||||
if len(items) > limit {
|
||||
last := items[limit]
|
||||
items = items[:limit]
|
||||
if t, err := time.Parse(time.RFC3339, last.PublishedAt); err == nil {
|
||||
uid, _ := uuid.Parse(last.ID)
|
||||
nextCursor = encodePostCursor(t, uid)
|
||||
}
|
||||
}
|
||||
writeJSON(w, PostListResponse{Items: items, NextCursor: nextCursor})
|
||||
}
|
||||
|
||||
func collectPostRows(r *http.Request, rows pgx.Rows) ([]PostDTO, error) {
|
||||
defer rows.Close()
|
||||
var ids []uuid.UUID
|
||||
list := make([]PostDTO, 0)
|
||||
for rows.Next() {
|
||||
dto, _, err := scanPostRow(r, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, _ := uuid.Parse(dto.ID)
|
||||
ids = append(ids, uid)
|
||||
dto.Attachments = []AttachmentDTO{}
|
||||
list = append(list, dto)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
atts, err := loadAttachmentsByPostIDs(r.Context(), poolFrom(r), ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range list {
|
||||
uid, _ := uuid.Parse(list[i].ID)
|
||||
if a, ok := atts[uid]; ok {
|
||||
list[i].Attachments = a
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -46,36 +46,11 @@ type ResourceDTO struct {
|
||||
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`)
|
||||
SELECT c.id, c.slug, `+categoryI18nColsSQL+`, c.icon_key, c.sort_order, c.updated_at
|
||||
FROM categories c WHERE c.is_visible = TRUE ORDER BY c.sort_order ASC, c.id ASC`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -84,15 +59,19 @@ func ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]CategoryDTO, 0)
|
||||
for rows.Next() {
|
||||
var c CategoryDTO
|
||||
var zhTW, zhCN, en *string
|
||||
var desc *string
|
||||
var nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs string
|
||||
var descZh, descEn, descJa, descKo, descVi, descId, descMs string
|
||||
var updated time.Time
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &zhTW, &zhCN, &en, &desc, &c.IconKey, &c.SortOrder, &updated); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Slug,
|
||||
&nameZh, &nameEn, &nameJa, &nameKo, &nameVi, &nameId, &nameMs,
|
||||
&descZh, &descEn, &descJa, &descKo, &descVi, &descId, &descMs,
|
||||
&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)
|
||||
texts := scanCategoryTextI18n(nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs, descZh, descEn, descJa, descKo, descVi, descId, descMs)
|
||||
c.Name = texts.pickName(r)
|
||||
c.Description = texts.pickDesc(r)
|
||||
c.UpdatedAt = updated.UTC().Format(time.RFC3339)
|
||||
out = append(out, c)
|
||||
}
|
||||
@@ -138,7 +117,7 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
base := `
|
||||
SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, ` + categoryNameColsSQL + `,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), 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
|
||||
@@ -159,10 +138,12 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
|
||||
start := len(args) + 1
|
||||
args = append(args, pat)
|
||||
cond += fmt.Sprintf(` AND (
|
||||
r.title_zh_tw ILIKE $%d OR r.title_zh_cn ILIKE $%d OR r.title_en ILIKE $%d OR
|
||||
r.description_zh_tw ILIKE $%d OR r.description_zh_cn ILIKE $%d OR r.description_en ILIKE $%d OR
|
||||
r.title_zh ILIKE $%d OR r.title_en ILIKE $%d OR r.title_ja ILIKE $%d OR r.title_ko ILIKE $%d OR
|
||||
r.title_vi ILIKE $%d OR r.title_id ILIKE $%d OR r.title_ms ILIKE $%d OR
|
||||
r.description_zh ILIKE $%d OR r.description_en ILIKE $%d OR r.description_ja ILIKE $%d OR
|
||||
r.description_ko ILIKE $%d OR r.description_vi ILIKE $%d OR r.description_id ILIKE $%d OR r.description_ms 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, start, start, start, start, start)
|
||||
start, start, start, start, start, start, start, start, start, start, start, start, start, start, start)
|
||||
}
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
if tag != "" {
|
||||
@@ -217,7 +198,7 @@ func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order st
|
||||
limit = 50
|
||||
}
|
||||
sqlStr := `
|
||||
SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, ` + categoryNameColsSQL + `,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), 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
|
||||
@@ -247,7 +228,7 @@ func GetResource(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
row := pool.QueryRow(r.Context(), `
|
||||
SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, `+categoryNameColsSQL+`,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), 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
|
||||
@@ -278,7 +259,7 @@ func RelatedResources(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rows, err := pool.Query(r.Context(), `
|
||||
SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
|
||||
SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, `+categoryNameColsSQL+`,
|
||||
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), 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
|
||||
@@ -404,15 +385,16 @@ func scanResourceRow(scanner interface {
|
||||
var dto ResourceDTO
|
||||
var pubAt *time.Time
|
||||
var updated time.Time
|
||||
var zhTW, zhCN, en *string
|
||||
var catNameZh, catNameEn, catNameJa, catNameKo, catNameVi, catNameId, catNameMs string
|
||||
var id uuid.UUID
|
||||
var texts resourceTextI18n
|
||||
err := scanner.Scan(
|
||||
&id,
|
||||
&texts.TitleZhTw, &texts.TitleZhCn, &texts.TitleEn,
|
||||
&texts.DescZhTw, &texts.DescZhCn, &texts.DescEn,
|
||||
&texts.BodyZhTw, &texts.BodyZhCn, &texts.BodyEn,
|
||||
&dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en,
|
||||
&texts.TitleZh, &texts.TitleEn, &texts.TitleJa, &texts.TitleKo, &texts.TitleVi, &texts.TitleId, &texts.TitleMs,
|
||||
&texts.DescZh, &texts.DescEn, &texts.DescJa, &texts.DescKo, &texts.DescVi, &texts.DescId, &texts.DescMs,
|
||||
&texts.BodyZh, &texts.BodyEn, &texts.BodyJa, &texts.BodyKo, &texts.BodyVi, &texts.BodyId, &texts.BodyMs,
|
||||
&dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug,
|
||||
&catNameZh, &catNameEn, &catNameJa, &catNameKo, &catNameVi, &catNameId, &catNameMs,
|
||||
&dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BadgeLabel,
|
||||
&dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated,
|
||||
)
|
||||
@@ -421,7 +403,8 @@ func scanResourceRow(scanner interface {
|
||||
}
|
||||
dto.ID = id.String()
|
||||
dto.Title, dto.Description, dto.BodyText = texts.pick(r)
|
||||
dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en))
|
||||
catTexts := categoryTextI18n{NameZh: catNameZh, NameEn: catNameEn, NameJa: catNameJa, NameKo: catNameKo, NameVi: catNameVi, NameId: catNameId, NameMs: catNameMs}
|
||||
dto.CategoryName = catTexts.pickName(r)
|
||||
if pubAt != nil {
|
||||
s := pubAt.UTC().Format(time.RFC3339)
|
||||
dto.PublishedAt = &s
|
||||
|
||||
@@ -9,30 +9,49 @@ import (
|
||||
// EnsureResourceI18nColumns adds per-locale text columns and backfills from legacy fields.
|
||||
func EnsureResourceI18nColumns(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_en TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ja TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ko TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_vi TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_id TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ms TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ja TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ko TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_vi TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_id TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ms TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_en TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ja TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ko TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_vi TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_id TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ms TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_tw TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_cn TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_en TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_tw TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_cn TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_tw TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_cn TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_en TEXT`)
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_cn TEXT`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
UPDATE resources SET title_zh_tw = title WHERE COALESCE(title_zh_tw, '') = '' AND COALESCE(title, '') <> '';
|
||||
UPDATE resources SET description_zh_tw = description WHERE description_zh_tw IS NULL AND description IS NOT NULL;
|
||||
UPDATE resources SET body_text_zh_tw = body_text WHERE body_text_zh_tw IS NULL AND body_text IS NOT NULL;
|
||||
UPDATE resources SET title_zh_cn = title WHERE language = 'zh-CN' AND COALESCE(title_zh_cn, '') = '';
|
||||
UPDATE resources SET description_zh_cn = description WHERE language = 'zh-CN' AND description_zh_cn IS NULL;
|
||||
UPDATE resources SET body_text_zh_cn = body_text WHERE language = 'zh-CN' AND body_text_zh_cn IS NULL;
|
||||
UPDATE resources SET title_en = title WHERE language = 'en' AND COALESCE(title_en, '') = '';
|
||||
UPDATE resources SET title_zh = COALESCE(NULLIF(title_zh, ''), NULLIF(title_zh_cn, ''), NULLIF(title_zh_tw, ''), title)
|
||||
WHERE COALESCE(title_zh, '') = '';
|
||||
UPDATE resources SET description_zh = COALESCE(description_zh, description_zh_cn, description_zh_tw, description)
|
||||
WHERE description_zh IS NULL;
|
||||
UPDATE resources SET body_text_zh = COALESCE(body_text_zh, body_text_zh_cn, body_text_zh_tw, body_text)
|
||||
WHERE body_text_zh IS NULL;
|
||||
UPDATE resources SET title_en = title WHERE language = 'en' AND COALESCE(title_en, '') = '' AND COALESCE(title, '') <> '';
|
||||
UPDATE resources SET description_en = description WHERE language = 'en' AND description_en IS NULL;
|
||||
UPDATE resources SET body_text_en = body_text WHERE language = 'en' AND body_text_en IS NULL;
|
||||
UPDATE resources SET title = COALESCE(NULLIF(title_zh_tw, ''), title);
|
||||
UPDATE resources SET description = description_zh_tw;
|
||||
UPDATE resources SET body_text = body_text_zh_tw`)
|
||||
UPDATE resources SET title = COALESCE(NULLIF(title_zh, ''), title);
|
||||
UPDATE resources SET description = description_zh;
|
||||
UPDATE resources SET body_text = body_text_zh;
|
||||
UPDATE resources SET language = 'zh' WHERE language IN ('zh-TW', 'zh-CN', 'zh-tw', 'zh-cn')`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,45 +5,91 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SQL fragment: nine i18n text columns on resources.
|
||||
const resourceI18nColsSQL = `
|
||||
COALESCE(r.title_zh_tw,''), COALESCE(r.title_zh_cn,''), COALESCE(r.title_en,''),
|
||||
COALESCE(r.description_zh_tw,''), COALESCE(r.description_zh_cn,''), COALESCE(r.description_en,''),
|
||||
COALESCE(r.body_text_zh_tw,''), COALESCE(r.body_text_zh_cn,''), COALESCE(r.body_text_en,'')`
|
||||
COALESCE(r.title_zh,''), COALESCE(r.title_en,''), COALESCE(r.title_ja,''),
|
||||
COALESCE(r.title_ko,''), COALESCE(r.title_vi,''), COALESCE(r.title_id,''), COALESCE(r.title_ms,''),
|
||||
COALESCE(r.description_zh,''), COALESCE(r.description_en,''), COALESCE(r.description_ja,''),
|
||||
COALESCE(r.description_ko,''), COALESCE(r.description_vi,''), COALESCE(r.description_id,''), COALESCE(r.description_ms,''),
|
||||
COALESCE(r.body_text_zh,''), COALESCE(r.body_text_en,''), COALESCE(r.body_text_ja,''),
|
||||
COALESCE(r.body_text_ko,''), COALESCE(r.body_text_vi,''), COALESCE(r.body_text_id,''), COALESCE(r.body_text_ms,'')`
|
||||
|
||||
type resourceTextI18n struct {
|
||||
TitleZhTw, TitleZhCn, TitleEn string
|
||||
DescZhTw, DescZhCn, DescEn string
|
||||
BodyZhTw, BodyZhCn, BodyEn string
|
||||
TitleZh, TitleEn, TitleJa, TitleKo, TitleVi, TitleId, TitleMs string
|
||||
DescZh, DescEn, DescJa, DescKo, DescVi, DescId, DescMs string
|
||||
BodyZh, BodyEn, BodyJa, BodyKo, BodyVi, BodyId, BodyMs string
|
||||
}
|
||||
|
||||
func pickLangField(r *http.Request, zhTW, zhCN, en string) string {
|
||||
func pickLangField(r *http.Request, zh, en, ja, ko, vi, id, ms 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, "zh"):
|
||||
if zh != "" {
|
||||
return zh
|
||||
}
|
||||
case strings.HasPrefix(lang, "en"):
|
||||
if en != "" {
|
||||
return en
|
||||
}
|
||||
case strings.HasPrefix(lang, "ja"):
|
||||
if ja != "" {
|
||||
return ja
|
||||
}
|
||||
case strings.HasPrefix(lang, "ko"):
|
||||
if ko != "" {
|
||||
return ko
|
||||
}
|
||||
case strings.HasPrefix(lang, "vi"):
|
||||
if vi != "" {
|
||||
return vi
|
||||
}
|
||||
case lang == "id", strings.HasPrefix(lang, "in"):
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
case strings.HasPrefix(lang, "ms"):
|
||||
if ms != "" {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
if zhTW != "" {
|
||||
return zhTW
|
||||
if zh != "" {
|
||||
return zh
|
||||
}
|
||||
if zhCN != "" {
|
||||
return zhCN
|
||||
if en != "" {
|
||||
return en
|
||||
}
|
||||
return en
|
||||
if ja != "" {
|
||||
return ja
|
||||
}
|
||||
if ko != "" {
|
||||
return ko
|
||||
}
|
||||
if vi != "" {
|
||||
return vi
|
||||
}
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
func (t resourceTextI18n) pick(r *http.Request) (title, description, body string) {
|
||||
return pickLangField(r, t.TitleZhTw, t.TitleZhCn, t.TitleEn),
|
||||
pickLangField(r, t.DescZhTw, t.DescZhCn, t.DescEn),
|
||||
pickLangField(r, t.BodyZhTw, t.BodyZhCn, t.BodyEn)
|
||||
return pickLangField(r, t.TitleZh, t.TitleEn, t.TitleJa, t.TitleKo, t.TitleVi, t.TitleId, t.TitleMs),
|
||||
pickLangField(r, t.DescZh, t.DescEn, t.DescJa, t.DescKo, t.DescVi, t.DescId, t.DescMs),
|
||||
pickLangField(r, t.BodyZh, t.BodyEn, t.BodyJa, t.BodyKo, t.BodyVi, t.BodyId, t.BodyMs)
|
||||
}
|
||||
|
||||
func scanResourceTextI18n(
|
||||
titleZh, titleEn, titleJa, titleKo, titleVi, titleId, titleMs string,
|
||||
descZh, descEn, descJa, descKo, descVi, descId, descMs string,
|
||||
bodyZh, bodyEn, bodyJa, bodyKo, bodyVi, bodyId, bodyMs string,
|
||||
) resourceTextI18n {
|
||||
return resourceTextI18n{
|
||||
TitleZh: titleZh, TitleEn: titleEn, TitleJa: titleJa, TitleKo: titleKo, TitleVi: titleVi, TitleId: titleId, TitleMs: titleMs,
|
||||
DescZh: descZh, DescEn: descEn, DescJa: descJa, DescKo: descKo, DescVi: descVi, DescId: descId, DescMs: descMs,
|
||||
BodyZh: bodyZh, BodyEn: bodyEn, BodyJa: bodyJa, BodyKo: bodyKo, BodyVi: bodyVi, BodyId: bodyId, BodyMs: bodyMs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
@@ -88,7 +94,7 @@ func UploadFile(d UploadDeps) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
pub := publicObjectURL(d.S3PublicBase, d.S3Bucket, d.AWSRegion, key)
|
||||
writeJSON(w, map[string]any{"url": pub, "filename": name, "storage": "s3"})
|
||||
writeUploadJSON(w, pub, hdr.Filename, name, ct, int64(len(data)), data, "s3")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,10 +113,33 @@ func UploadFile(d UploadDeps) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"url": "/uploads/" + name, "filename": name, "storage": "local"})
|
||||
writeUploadJSON(w, "/uploads/"+name, hdr.Filename, name, ct, int64(len(data)), data, "local")
|
||||
}
|
||||
}
|
||||
|
||||
func writeUploadJSON(w http.ResponseWriter, url, originalName, storedName, mime string, size int64, data []byte, storage string) {
|
||||
kind := classifyAttachmentKind(mime, originalName)
|
||||
if kind == "" {
|
||||
kind = classifyAttachmentKind(mime, storedName)
|
||||
}
|
||||
out := map[string]any{
|
||||
"url": url,
|
||||
"filename": storedName,
|
||||
"storage": storage,
|
||||
"mime": mime,
|
||||
"sizeBytes": size,
|
||||
"kind": kind,
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(mime), "image/") {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
out["width"] = cfg.Width
|
||||
out["height"] = cfg.Height
|
||||
}
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// s3PutObjectCannedACL maps env S3_OBJECT_ACL to SDK enum; unknown values are ignored.
|
||||
func s3PutObjectCannedACL(raw string) (types.ObjectCannedACL, bool) {
|
||||
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||
|
||||
32
migrations/006_resource_seven_locales.sql
Normal file
32
migrations/006_resource_seven_locales.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Seven resource locales: zh (Simplified Chinese), en, ja, ko, vi, id, ms.
|
||||
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ja TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ko TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_vi TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_id TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ms TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ja TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ko TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_vi TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_id TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ms TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ja TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ko TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_vi TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_id TEXT;
|
||||
ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ms TEXT;
|
||||
|
||||
UPDATE resources SET title_zh = COALESCE(NULLIF(title_zh, ''), NULLIF(title_zh_cn, ''), NULLIF(title_zh_tw, ''), title)
|
||||
WHERE COALESCE(title_zh, '') = '';
|
||||
UPDATE resources SET description_zh = COALESCE(description_zh, description_zh_cn, description_zh_tw, description)
|
||||
WHERE description_zh IS NULL;
|
||||
UPDATE resources SET body_text_zh = COALESCE(body_text_zh, body_text_zh_cn, body_text_zh_tw, body_text)
|
||||
WHERE body_text_zh IS NULL;
|
||||
|
||||
UPDATE resources SET title = COALESCE(NULLIF(title_zh, ''), title);
|
||||
UPDATE resources SET description = description_zh;
|
||||
UPDATE resources SET body_text = body_text_zh;
|
||||
UPDATE resources SET language = 'zh' WHERE language IN ('zh-TW', 'zh-CN', 'zh-tw', 'zh-cn');
|
||||
21
migrations/007_category_seven_locales.sql
Normal file
21
migrations/007_category_seven_locales.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Category names/descriptions: zh (Simplified), en, ja, ko, vi, id, ms.
|
||||
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_zh TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_en TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ja TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ko TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_vi TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_id TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ms TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_zh TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ja TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ko TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_vi TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_id TEXT;
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ms TEXT;
|
||||
|
||||
UPDATE categories SET name_zh = COALESCE(NULLIF(name_zh, ''), NULLIF(name_zh_cn, ''), name_zh_tw)
|
||||
WHERE COALESCE(name_zh, '') = '';
|
||||
UPDATE categories SET description_zh = COALESCE(description_zh, description_zh_tw)
|
||||
WHERE description_zh IS NULL;
|
||||
52
migrations/008_posts.sql
Normal file
52
migrations/008_posts.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Telegram-style posts feed (separate from legacy resources)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id INT NOT NULL REFERENCES categories(id),
|
||||
language TEXT NOT NULL DEFAULT 'zh',
|
||||
text_zh TEXT,
|
||||
text_en TEXT,
|
||||
text_ja TEXT,
|
||||
text_ko TEXT,
|
||||
text_vi TEXT,
|
||||
text_id TEXT,
|
||||
text_ms TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_recommended BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
view_count INT NOT NULL DEFAULT 0,
|
||||
download_count INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status_public ON posts(status, is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC NULLS LAST, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_recommended ON posts(is_recommended) WHERE is_recommended = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
width INT,
|
||||
height INT,
|
||||
duration_sec INT,
|
||||
poster_url TEXT,
|
||||
thumbnail_url TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_attachments_post ON post_attachments(post_id, sort_order ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
Reference in New Issue
Block a user