package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "affiliate_dash/internal/pkg/lewan" "github.com/joho/godotenv" ) type catalogItem struct { SKU string Name string LewanProductID string DLCGoodID string } // 与 docs/商品目录.md 保持一致(2026-09-14 自营商户快照) var catalog = []catalogItem{ {SKU: "suit_jester_face", Name: "套装-花面小丑", LewanProductID: "b3891176-5ffa-40cb-8c58-7e46eac3f381"}, {SKU: "suit_dune_song", Name: "套装-沙丘之歌", LewanProductID: "f3b83b85-c664-4440-94ac-a4b0cd7cf0f3"}, {SKU: "pack_dragon_palace", Name: "龙宫之主大礼包", LewanProductID: "46524a53-068d-4bee-97ff-d5e7873818e0"}, {SKU: "lucky_coin_x90", Name: "幸运币90个", LewanProductID: "518e3bec-f9f2-483b-89ae-d826c9cf14e5", DLCGoodID: "682ef39ca8f40c4234c59f42"}, {SKU: "lucky_coin_x30", Name: "幸运币30个", LewanProductID: "b1c64119-d164-41e6-a1dc-0d8167dd09de", DLCGoodID: "682ef39ca8f40c4234c59f44"}, {SKU: "lucky_coin_x2", Name: "幸运币2个", LewanProductID: "d5dfa4e3-675b-4187-8163-8aa04727c6e4", DLCGoodID: "682ef39ca8f40c4234c59f46"}, {SKU: "honor_medal_x90", Name: "荣耀勋章90个", LewanProductID: "779857c7-e6db-40e3-9f4f-4eb5eca350e1", DLCGoodID: "682ef39ca8f40c4234c59f41"}, {SKU: "honor_medal_x30", Name: "荣耀勋章30个", LewanProductID: "a09d18ce-4a55-4313-925b-6de66d96154b", DLCGoodID: "682ef39ca8f40c4234c59f43"}, {SKU: "honor_medal_x2", Name: "荣耀勋章2个", LewanProductID: "ed57398a-a470-4d85-abc4-761c81274d9f", DLCGoodID: "682ef39ca8f40c4234c59f45"}, {SKU: "suit_cloud_bear", Name: "套装-绵云熊熊", DLCGoodID: "682ef39ca8f40c4234c59f40"}, {SKU: "pack_star_roam_weapon", Name: "星际漫游枪械礼包", DLCGoodID: "682ef39ca8f40c4234c59f3f"}, {SKU: "pack_star_roam_outfit", Name: "星际漫游服装礼包", DLCGoodID: "682ef39ca8f40c4234c59f3e"}, {SKU: "suit_sand_dancer", Name: "套装-狂沙舞者", DLCGoodID: "682ef39ca8f40c4234c59f3d"}, {SKU: "pack_lava_ranger", Name: "熔岩游骑兵礼包", DLCGoodID: "682ef39ca8f40c4234c59f3c"}, {SKU: "suit_panda_tuan", Name: "套装-胖达团团", DLCGoodID: "682ef39ca8f40c4234c59f3b"}, {SKU: "suit_panda_round", Name: "套装-胖达圆圆", DLCGoodID: "682ef39ca8f40c4234c59f3a"}, {SKU: "helmet_panda_treasure", Name: "国宝胖达头盔", DLCGoodID: "682ef39ca8f40c4234c59f39"}, {SKU: "suit_western_mystery", Name: "套装-西部谜踪", DLCGoodID: "682ef39ca8f40c4234c59f35"}, {SKU: "helmet_hamster_gray", Name: "仓鼠灰灰头盔", DLCGoodID: "682ef3c5a8f40c4234c59f54"}, {SKU: "bag_hamster_gray", Name: "仓鼠灰灰背包", DLCGoodID: "682ef3c5a8f40c4234c59f55"}, {SKU: "helmet_pink_sheep", Name: "糯粉咩咩头盔", DLCGoodID: "682ef3c5a8f40c4234c59f4e"}, {SKU: "bag_pink_sheep", Name: "糯粉咩咩背包", DLCGoodID: "682ef3c5a8f40c4234c59f4f"}, {SKU: "suit_cute_bear", Name: "套装-萌熊伴侣", DLCGoodID: "682ef3c5a8f40c4234c59f4b"}, {SKU: "suit_hamster_gray", Name: "套装-仓鼠灰灰", DLCGoodID: "682ef3c5a8f40c4234c59f53"}, {SKU: "frag_pink_sheep", Name: "破片手榴弹-糯粉咩咩", DLCGoodID: "682ef3c5a8f40c4234c59f51"}, {SKU: "smoke_pink_sheep", Name: "烟雾弹-糯粉咩咩", DLCGoodID: "682ef3c5a8f40c4234c59f50"}, {SKU: "pack_western_cowboy", Name: "西部牛仔大礼包", DLCGoodID: "682ef3c5a8f40c4234c59f4c"}, {SKU: "suit_romantic_destiny", Name: "套装-浪漫天命", LewanProductID: "b1fff090-e389-4c6c-9c51-32751582239d", DLCGoodID: "6a13ab21e3bce430aca29b04"}, {SKU: "suit_first_peach", Name: "套装-恋恋初桃", DLCGoodID: "682ef3c5a8f40c4234c59f48"}, {SKU: "suit_pink_sheep", Name: "套装-糯粉咩咩", DLCGoodID: "682ef3c5a8f40c4234c59f4d"}, {SKU: "suit_dual_fluffy", Name: "套装-双彩绵绵", DLCGoodID: "682ef3c5a8f40c4234c59f49"}, {SKU: "bag_cute_bear", Name: "萌熊伴侣背包", DLCGoodID: "682ef3c5a8f40c4234c59f4a"}, {SKU: "m416_hamster_gray", Name: "M416-仓鼠灰灰", DLCGoodID: "682ef3c5a8f40c4234c59f52"}, {SKU: "top_black_elite_trainer", Name: "黑色高级特训官上衣", LewanProductID: "8621502c-e355-455c-9b74-a7e642a40cfe", DLCGoodID: "6a13aaeae3bce430aca29b03"}, {SKU: "suit_shadow_gothic", Name: "套装-暗影哥特", LewanProductID: "8eed3610-7238-4b89-8010-6713268f7cd3", DLCGoodID: "6a13aa55e3bce430aca29b01"}, {SKU: "suit_alan_walker", Name: "套装-Alan Walker", DLCGoodID: "682ef3c5a8f40c4234c59f47"}, } func main() { root := findRepoRoot() _ = godotenv.Load(filepath.Join(root, ".env")) outDir := filepath.Join(root, "frontend", "public", "images", "products") if err := os.MkdirAll(outDir, 0o755); err != nil { fatal(err) } httpClient := &http.Client{Timeout: 30 * time.Second} lewanClient := lewan.NewClient(lewan.Config{ BaseURL: os.Getenv("LEWAN_BASE_URL"), AccessKey: os.Getenv("LEWAN_ACCESS_KEY"), SecretKey: os.Getenv("LEWAN_SECRET_KEY"), ApplicationID: os.Getenv("LEWAN_APPLICATION_ID"), }, httpClient) bffBase := strings.TrimRight(envOr("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff"), "/") ctx := context.Background() ok, skip, fail := 0, 0, 0 for _, item := range catalog { dest := filepath.Join(outDir, item.SKU+".webp") if st, err := os.Stat(dest); err == nil && st.Size() > 0 { fmt.Printf("skip %s (exists)\n", item.SKU) skip++ continue } var imgURL string var src string // 1) 已有按乐玩 UUID 缓存的本地文件 if item.LewanProductID != "" { legacy := filepath.Join(outDir, item.LewanProductID+".webp") if data, err := os.ReadFile(legacy); err == nil && len(data) > 0 { if err := os.WriteFile(dest, data, 0o644); err != nil { fmt.Printf("fail %s copy uuid cache: %v\n", item.SKU, err) fail++ continue } fmt.Printf("ok %s <- uuid cache %s\n", item.SKU, item.LewanProductID) ok++ continue } } // 2) 乐玩详情 thumbnailUrl if item.LewanProductID != "" { if p, err := lewanClient.GetProduct(ctx, item.LewanProductID); err == nil && strings.TrimSpace(p.ThumbnailUrl) != "" { imgURL = strings.TrimSpace(p.ThumbnailUrl) src = "lewan:" + item.LewanProductID } } // 3) DLC BFF goods detail if imgURL == "" && item.DLCGoodID != "" { if u, err := fetchDLCImage(httpClient, bffBase, item.DLCGoodID); err == nil && u != "" { imgURL = u src = "dlc:" + item.DLCGoodID } else if err != nil { fmt.Printf("warn %s dlc detail: %v\n", item.SKU, err) } } // 4) 乐玩按中文名搜索 if imgURL == "" { if p, err := findLewanByName(ctx, lewanClient, item.Name); err == nil && p != nil && strings.TrimSpace(p.ThumbnailUrl) != "" { imgURL = strings.TrimSpace(p.ThumbnailUrl) src = "lewan-name:" + p.ID } } if imgURL == "" { fmt.Printf("fail %s no image source\n", item.SKU) fail++ continue } if err := downloadFile(httpClient, imgURL, dest); err != nil { fmt.Printf("fail %s download %s: %v\n", item.SKU, imgURL, err) fail++ continue } fmt.Printf("ok %s <- %s\n", item.SKU, src) ok++ } fmt.Printf("\ndone: ok=%d skip=%d fail=%d out=%s\n", ok, skip, fail, outDir) if fail > 0 { os.Exit(1) } } func findLewanByName(ctx context.Context, client *lewan.Client, name string) (*lewan.Product, error) { page := 1 for page <= 20 { res, err := client.ListProducts(ctx, page, 50, name) if err != nil { return nil, err } for i := range res.Items { if strings.TrimSpace(res.Items[i].Name) == strings.TrimSpace(name) { return &res.Items[i], nil } } if int64(page*50) >= res.Total || len(res.Items) == 0 { break } page++ } return nil, nil } func fetchDLCImage(client *http.Client, bffBase, goodID string) (string, error) { var out struct { Good map[string]interface{} `json:"good"` } if err := signProxy(client, bffBase, "/public/goods/detail", "POST", map[string]interface{}{ "good_id": goodID, }, &out); err != nil { return "", err } if out.Good == nil { return "", fmt.Errorf("empty good") } for _, key := range []string{"image", "cover", "thumbnail", "pic", "img"} { if v, ok := out.Good[key].(string); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v), nil } } return "", fmt.Errorf("no image field in good") } func signProxy(client *http.Client, bffBase, path, method string, data interface{}, out interface{}) error { payload, err := json.Marshal(map[string]interface{}{ "path": path, "method": strings.ToUpper(method), "data": data, }) if err != nil { return err } req, err := http.NewRequest(http.MethodPost, bffBase+"/sign-proxy", bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) var envelope struct { Code interface{} `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { return fmt.Errorf("parse: %s", string(body)) } codeOK := false switch v := envelope.Code.(type) { case float64: codeOK = v == 0 case string: codeOK = v == "0" || v == "" case nil: codeOK = resp.StatusCode >= 200 && resp.StatusCode < 300 } if !codeOK { if envelope.Message != "" { return fmt.Errorf("%s", envelope.Message) } return fmt.Errorf("http %d: %s", resp.StatusCode, string(body)) } if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" { return nil } return json.Unmarshal(envelope.Data, out) } func downloadFile(client *http.Client, url, dest string) error { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return err } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("http %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return err } if len(data) < 32 { return fmt.Errorf("too small (%d bytes)", len(data)) } tmp := dest + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, dest) } func findRepoRoot() string { wd, _ := os.Getwd() dir := wd for i := 0; i < 6; i++ { if _, err := os.Stat(filepath.Join(dir, "frontend", "public")); err == nil { return dir } if _, err := os.Stat(filepath.Join(dir, ".env")); err == nil { if _, err2 := os.Stat(filepath.Join(dir, "backend")); err2 == nil { return dir } } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } // go run from backend/ if filepath.Base(wd) == "backend" { return filepath.Dir(wd) } return filepath.Dir(wd) } func envOr(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func fatal(err error) { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) }