- 同步上游 DLC (聚鑫源) 最新 GoodID 映射: - suit_shadow_gothic: 6a13aa55e3bce430aca29b01 - top_black_elite_trainer: 6a13aaeae3bce430aca29b03 - suit_romantic_destiny: 6a13ab21e3bce430aca29b04 - 更新 delivery_helpers 缺省兜底映射表与 sync_product_images 常量 - 更新 provider_test 单元测试断言与 docs/商品目录.md 文档 - 验证方式:go test 单元测试通过,go vet 校验通过
310 lines
11 KiB
Go
310 lines
11 KiB
Go
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)
|
|
}
|