From 69176e986b595f54dc53caf3f61c7a916d14b8b6 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 25 May 2026 00:09:44 +0800 Subject: [PATCH] 1 --- cmd/server/main.go | 4 ++ internal/handlers/public.go | 41 +++++++++++--------- internal/handlers/resource_schema.go | 38 ++++++++++++++++++ internal/handlers/resource_text.go | 49 ++++++++++++++++++++++++ migrations/005_resource_i18n_columns.sql | 27 +++++++++++++ 5 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 internal/handlers/resource_schema.go create mode 100644 internal/handlers/resource_text.go create mode 100644 migrations/005_resource_i18n_columns.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 2be9fd6..286326a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -39,6 +39,10 @@ func main() { log.Print("RUN_WALLET_AUTH_SCHEMA=false: skipping wallet_auth_* DDL at startup") } + if err := handlers.EnsureResourceI18nColumns(ctx, pool); err != nil { + log.Fatalf("resources i18n columns: %v", err) + } + if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil { log.Fatal(err) } diff --git a/internal/handlers/public.go b/internal/handlers/public.go index f85fb75..f2625de 100644 --- a/internal/handlers/public.go +++ b/internal/handlers/public.go @@ -121,7 +121,6 @@ func ListResources(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) typ := strings.TrimSpace(r.URL.Query().Get("type")) - lang := strings.TrimSpace(r.URL.Query().Get("language")) catSlug := strings.TrimSpace(r.URL.Query().Get("category")) sort := strings.TrimSpace(r.URL.Query().Get("sort")) if sort == "" { @@ -139,8 +138,8 @@ func ListResources(w http.ResponseWriter, r *http.Request) { } base := ` - SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, - COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''), + 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, + 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 WHERE r.status = 'published' AND r.is_public = TRUE` @@ -155,17 +154,15 @@ func ListResources(w http.ResponseWriter, r *http.Request) { args = append(args, typ) cond += " AND r.type = $" + strconv.Itoa(len(args)) } - if lang != "" { - args = append(args, lang) - cond += " AND r.language = $" + strconv.Itoa(len(args)) - } if q != "" { pat := "%" + q + "%" start := len(args) + 1 - args = append(args, pat, pat, pat) - cond += fmt.Sprintf(` AND (r.title ILIKE $%d OR r.description ILIKE $%d OR EXISTS ( - SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND t.name ILIKE $%d))`, - start, start+1, start+2) + 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 + 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) } tag := strings.TrimSpace(r.URL.Query().Get("tag")) if tag != "" { @@ -220,8 +217,8 @@ func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order st limit = 50 } sqlStr := ` - SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, - COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''), + 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, + 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 WHERE r.status = 'published' AND r.is_public = TRUE AND (` + extra + `) @@ -250,8 +247,8 @@ func GetResource(w http.ResponseWriter, r *http.Request) { return } row := pool.QueryRow(r.Context(), ` - SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, - COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''), + 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, + 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 WHERE r.id = $1 AND r.status = 'published' AND r.is_public = TRUE`, id) @@ -281,8 +278,8 @@ func RelatedResources(w http.ResponseWriter, r *http.Request) { return } rows, err := pool.Query(r.Context(), ` - SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, - COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''), + 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, + 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 WHERE r.status = 'published' AND r.is_public = TRUE AND r.category_id = $1 AND r.id <> $2 @@ -409,15 +406,21 @@ func scanResourceRow(scanner interface { var updated time.Time var zhTW, zhCN, en *string var id uuid.UUID + var texts resourceTextI18n err := scanner.Scan( - &id, &dto.Title, &dto.Description, &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en, - &dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BodyText, &dto.BadgeLabel, + &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, + &dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BadgeLabel, &dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated, ) if err != nil { return dto, err } dto.ID = id.String() + dto.Title, dto.Description, dto.BodyText = texts.pick(r) dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en)) if pubAt != nil { s := pubAt.UTC().Format(time.RFC3339) diff --git a/internal/handlers/resource_schema.go b/internal/handlers/resource_schema.go new file mode 100644 index 0000000..c62516d --- /dev/null +++ b/internal/handlers/resource_schema.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// 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_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`) + 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 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`) + return err +} diff --git a/internal/handlers/resource_text.go b/internal/handlers/resource_text.go new file mode 100644 index 0000000..458ba97 --- /dev/null +++ b/internal/handlers/resource_text.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "net/http" + "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,'')` + +type resourceTextI18n struct { + TitleZhTw, TitleZhCn, TitleEn string + DescZhTw, DescZhCn, DescEn string + BodyZhTw, BodyZhCn, BodyEn string +} + +func pickLangField(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 (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) +} diff --git a/migrations/005_resource_i18n_columns.sql b/migrations/005_resource_i18n_columns.sql new file mode 100644 index 0000000..60613ef --- /dev/null +++ b/migrations/005_resource_i18n_columns.sql @@ -0,0 +1,27 @@ +-- One resource row holds all languages (title / description / body per locale). + +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; + +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 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;