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
RUN apk add --no-cache git ca-certificates
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,
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
}

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}
}
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)
}

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 {
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 {

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