1
Some checks failed
Deploy API / deploy (push) Failing after 26s

This commit is contained in:
2026-05-25 23:43:55 +08:00
parent b2879720de
commit 12b4ee536e
6 changed files with 114 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS build FROM golang:1.25-alpine AS build
WORKDIR /src WORKDIR /src
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum* ./ COPY go.mod go.sum* ./

View File

@@ -56,6 +56,9 @@ func EnsurePostsSchema(ctx context.Context, pool *pgxpool.Pool) error {
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id) 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 return err
} }

View File

@@ -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} 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 { func postTextHasLink(text string) bool {
return postLinkRe.MatchString(text) return postLinkRe.MatchString(text)
} }

View File

@@ -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"
}

View File

@@ -28,11 +28,13 @@ type AttachmentDTO struct {
type PostDTO struct { type PostDTO struct {
ID string `json:"id"` ID string `json:"id"`
CategoryID int `json:"categoryId"` PostType string `json:"postType"`
CategorySlug string `json:"categorySlug"` CategoryID int `json:"categoryId,omitempty"`
Language string `json:"language"` CategorySlug string `json:"categorySlug,omitempty"`
Text string `json:"text,omitempty"` Language string `json:"language"`
Attachments []AttachmentDTO `json:"attachments"` Text string `json:"text,omitempty"`
Localizations map[string]postLocalePayload `json:"localizations"`
Attachments []AttachmentDTO `json:"attachments"`
IsRecommended bool `json:"isRecommended"` IsRecommended bool `json:"isRecommended"`
PublishedAt string `json:"publishedAt"` PublishedAt string `json:"publishedAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
@@ -62,7 +64,8 @@ type attachmentInput struct {
type adminPost struct { type adminPost struct {
ID string `json:"id"` ID string `json:"id"`
CategoryID int `json:"categoryId"` PostType string `json:"postType"`
CategoryID int `json:"categoryId,omitempty"`
Language string `json:"language"` Language string `json:"language"`
Text string `json:"text"` Text string `json:"text"`
Attachments []attachmentInput `json:"attachments"` Attachments []attachmentInput `json:"attachments"`
@@ -141,30 +144,39 @@ func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) {
} }
func postTypeFilterSQL(typ string, args *[]any) string { func postTypeFilterSQL(typ string, args *[]any) string {
typ = strings.ToLower(strings.TrimSpace(typ)) raw := strings.ToLower(strings.TrimSpace(typ))
if typ == "" || typ == "all" { if raw == "" || raw == "all" {
return "" 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 { switch typ {
case "image": case "image":
*args = append(*args, "image") return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
n := len(*args) SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'image')))`, n)
return fmt.Sprintf(` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = $%d)`, n)
case "video": 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": 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": 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": 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": case "text":
return ` AND NOT EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id) return fmt.Sprintf(` AND p.post_type = $%d`, n)
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": 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: 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 { func validateAdminPost(ap *adminPost) error {
if ap.CategoryID <= 0 { ap.PostType = normalizePostType(ap.PostType)
return fmt.Errorf("categoryId required") if ap.PostType == "" || !validPostTypes[ap.PostType] {
return fmt.Errorf("postType required (image, video, music, ppt, pdf, link, text, archive)")
} }
if len(ap.Attachments) > maxPostAttachments { if len(ap.Attachments) > maxPostAttachments {
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments) return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
} }
hasText := strings.TrimSpace(ap.Text) != "" hasText := strings.TrimSpace(ap.Text) != ""
hasAtt := len(ap.Attachments) > 0 hasAtt := len(ap.Attachments) > 0
if !hasText && !hasAtt { if ap.Status == "published" && !hasText && !hasAtt {
return fmt.Errorf("post requires text and/or at least one attachment") return fmt.Errorf("published post requires text and/or at least one attachment")
} }
for i, a := range ap.Attachments { for i, a := range ap.Attachments {
if strings.TrimSpace(a.URL) == "" { if strings.TrimSpace(a.URL) == "" {
@@ -363,22 +376,28 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
var dto PostDTO var dto PostDTO
var texts postTextI18n var texts postTextI18n
var id uuid.UUID var id uuid.UUID
var catID int var catID *int
var slug string var slug *string
var lang string var lang, postType string
var pub, updated, created *time.Time var pub, updated, created *time.Time
err := row.Scan( err := row.Scan(
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs, &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 { if err != nil {
return dto, texts, err return dto, texts, err
} }
dto.ID = id.String() dto.ID = id.String()
dto.CategoryID = catID dto.PostType = normalizePostType(postType)
dto.CategorySlug = slug if catID != nil {
dto.CategoryID = *catID
}
if slug != nil {
dto.CategorySlug = *slug
}
dto.Language = lang dto.Language = lang
dto.Text = texts.pick(r) dto.Text = texts.pick(r)
dto.Localizations = texts.toLocalizations()
if pub != nil { if pub != nil {
dto.PublishedAt = pub.UTC().Format(time.RFC3339) dto.PublishedAt = pub.UTC().Format(time.RFC3339)
} else if created != nil { } else if created != nil {
@@ -395,9 +414,15 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
func postSelectBase() string { func postSelectBase() string {
return ` 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 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 { func postLimitDef(r *http.Request, def, max int) int {

View File

@@ -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);