diff --git a/Dockerfile b/Dockerfile index 80dd6f3..4372323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine AS build +FROM golang:1.25-alpine AS build WORKDIR /src RUN apk add --no-cache git ca-certificates COPY go.mod go.sum* ./ diff --git a/internal/handlers/post_schema.go b/internal/handlers/post_schema.go index bf77dc7..4c04411 100644 --- a/internal/handlers/post_schema.go +++ b/internal/handlers/post_schema.go @@ -56,6 +56,9 @@ func EnsurePostsSchema(ctx context.Context, pool *pgxpool.Pool) error { 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) - )`) + ); + ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type TEXT NOT NULL DEFAULT 'text'; + ALTER TABLE posts ALTER COLUMN category_id DROP NOT NULL; + CREATE INDEX IF NOT EXISTS idx_posts_post_type ON posts(post_type)`) return err } diff --git a/internal/handlers/post_text.go b/internal/handlers/post_text.go index 67e3bf1..de25b44 100644 --- a/internal/handlers/post_text.go +++ b/internal/handlers/post_text.go @@ -51,6 +51,18 @@ 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 (t postTextI18n) toLocalizations() map[string]postLocalePayload { + return map[string]postLocalePayload{ + "zh": {Text: t.TextZh}, + "en": {Text: t.TextEn}, + "ja": {Text: t.TextJa}, + "ko": {Text: t.TextKo}, + "vi": {Text: t.TextVi}, + "id": {Text: t.TextId}, + "ms": {Text: t.TextMs}, + } +} + func postTextHasLink(text string) bool { return postLinkRe.MatchString(text) } diff --git a/internal/handlers/post_types.go b/internal/handlers/post_types.go new file mode 100644 index 0000000..59ab519 --- /dev/null +++ b/internal/handlers/post_types.go @@ -0,0 +1,35 @@ +package handlers + +import "strings" + +// Valid post_type values (Browse filter chips + admin selector). +var validPostTypes = map[string]bool{ + "image": true, "video": true, "music": true, "ppt": true, "pdf": true, + "link": true, "text": true, "archive": true, +} + +func normalizePostType(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + switch s { + case "图片", "圖片": + return "image" + case "视频", "視頻", "影片": + return "video" + case "音乐", "音樂", "音频", "音頻": + return "music" + case "ppt", "幻灯片", "簡報": + return "ppt" + case "pdf": + return "pdf" + case "链接", "連結", "link": + return "link" + case "文字", "文贴", "文貼", "text": + return "text" + case "压缩包", "壓縮包", "archive", "zip": + return "archive" + } + if validPostTypes[s] { + return s + } + return "text" +} diff --git a/internal/handlers/posts_common.go b/internal/handlers/posts_common.go index 5d6f429..a76fb11 100644 --- a/internal/handlers/posts_common.go +++ b/internal/handlers/posts_common.go @@ -28,11 +28,13 @@ type AttachmentDTO struct { 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"` + PostType string `json:"postType"` + CategoryID int `json:"categoryId,omitempty"` + CategorySlug string `json:"categorySlug,omitempty"` + Language string `json:"language"` + Text string `json:"text,omitempty"` + Localizations map[string]postLocalePayload `json:"localizations"` + Attachments []AttachmentDTO `json:"attachments"` IsRecommended bool `json:"isRecommended"` PublishedAt string `json:"publishedAt"` UpdatedAt string `json:"updatedAt"` @@ -62,7 +64,8 @@ type attachmentInput struct { type adminPost struct { ID string `json:"id"` - CategoryID int `json:"categoryId"` + PostType string `json:"postType"` + CategoryID int `json:"categoryId,omitempty"` Language string `json:"language"` Text string `json:"text"` Attachments []attachmentInput `json:"attachments"` @@ -141,30 +144,39 @@ func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) { } func postTypeFilterSQL(typ string, args *[]any) string { - typ = strings.ToLower(strings.TrimSpace(typ)) - if typ == "" || typ == "all" { + raw := strings.ToLower(strings.TrimSpace(typ)) + if raw == "" || raw == "all" { return "" } + typ = normalizePostType(typ) + *args = append(*args, typ) + n := len(*args) + // Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics. 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) + return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS ( + SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'image')))`, 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/%'))` + return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS ( + SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%%'))))`, n) + case "music": + return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS ( + SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.mime LIKE 'audio/%%' OR pa.filename ILIKE '%%.mp3' OR pa.filename ILIKE '%%.wav' OR pa.filename ILIKE '%%.m4a'))))`, n) 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'))` + return fmt.Sprintf(` AND (p.post_type = $%d OR 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')))`, n) 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%'))` + return fmt.Sprintf(` AND (p.post_type = $%d OR 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%%')))`, n) 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%'))` + return fmt.Sprintf(` AND (p.post_type = $%d OR 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%%')))`, n) 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,'') <> '')` + return fmt.Sprintf(` AND p.post_type = $%d`, n) 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?://')` + return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n) default: - return "" + return fmt.Sprintf(` AND p.post_type = $%d`, n) } } @@ -199,16 +211,17 @@ func translateNormalizePostLang(lang string) string { } func validateAdminPost(ap *adminPost) error { - if ap.CategoryID <= 0 { - return fmt.Errorf("categoryId required") + ap.PostType = normalizePostType(ap.PostType) + if ap.PostType == "" || !validPostTypes[ap.PostType] { + return fmt.Errorf("postType required (image, video, music, ppt, pdf, link, text, archive)") } 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") + if ap.Status == "published" && !hasText && !hasAtt { + return fmt.Errorf("published post requires text and/or at least one attachment") } for i, a := range ap.Attachments { if strings.TrimSpace(a.URL) == "" { @@ -363,22 +376,28 @@ 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 catID *int + var slug *string + var lang, postType 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, + &lang, &postType, &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.PostType = normalizePostType(postType) + if catID != nil { + dto.CategoryID = *catID + } + if slug != nil { + dto.CategorySlug = *slug + } dto.Language = lang dto.Text = texts.pick(r) + dto.Localizations = texts.toLocalizations() if pub != nil { dto.PublishedAt = pub.UTC().Format(time.RFC3339) } else if created != nil { @@ -395,9 +414,15 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) { func postSelectBase() string { return ` - SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), p.category_id, c.slug, + SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'), + 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` + FROM posts p + LEFT JOIN categories c ON c.id = p.category_id` +} + +func postsFromClause() string { + return `FROM posts p LEFT JOIN categories c ON c.id = p.category_id WHERE 1=1` } func postLimitDef(r *http.Request, def, max int) int { diff --git a/migrations/009_posts_post_type.sql b/migrations/009_posts_post_type.sql new file mode 100644 index 0000000..464c416 --- /dev/null +++ b/migrations/009_posts_post_type.sql @@ -0,0 +1,6 @@ +-- Posts use post_type (image/video/...) instead of category for feed classification. + +ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type TEXT NOT NULL DEFAULT 'text'; +ALTER TABLE posts ALTER COLUMN category_id DROP NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_posts_post_type ON posts(post_type);