diff --git a/backend/cmd/cache_images/main.go b/backend/cmd/cache_images/main.go index 02d240f..176a2c6 100644 --- a/backend/cmd/cache_images/main.go +++ b/backend/cmd/cache_images/main.go @@ -1,5 +1,11 @@ package main +// 已废弃:请改用按英文 SKU 同步到 frontend/public/images/products/{sku}.webp +// +// cd backend && go run ./cmd/sync_product_images +// +// 本命令仍会把乐玩缩略图缓存到 data/uploads/products/{uuid}.webp,仅作历史兼容。 + import ( "context" "crypto/sha256" diff --git a/backend/cmd/sync_product_images/main.go b/backend/cmd/sync_product_images/main.go new file mode 100644 index 0000000..647f477 --- /dev/null +++ b/backend/cmd/sync_product_images/main.go @@ -0,0 +1,309 @@ +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: "682ef39ca8f40c4234c59f38"}, + {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: "682ef39ca8f40c4234c59f37"}, + {SKU: "suit_shadow_gothic", Name: "套装-暗影哥特", LewanProductID: "8eed3610-7238-4b89-8010-6713268f7cd3", DLCGoodID: "682ef39ca8f40c4234c59f36"}, + {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) +} diff --git a/backend/internal/database/migrations/011_catalog_english_sku_and_prices.sql b/backend/internal/database/migrations/011_catalog_english_sku_and_prices.sql new file mode 100644 index 0000000..9b82975 --- /dev/null +++ b/backend/internal/database/migrations/011_catalog_english_sku_and_prices.sql @@ -0,0 +1,166 @@ +-- ============================================================================ +-- 商品目录收敛(幂等,可在开发库反复执行前先清 schema_migrations.011) +-- 1) 补齐乐玩独有品英文主 sku +-- 2) 硬删除全部 LEWAN_* / lewan_* 镜像(含已软删行) +-- 3) 统一积分价为 1元=100积分(按权威目录绝对值校准) +-- ============================================================================ + +-- 1) 乐玩独有 → 英文主商品 +WITH lewan_only(new_sku, display_name, category, legacy_sku, default_price) AS ( + VALUES + ('pack_dragon_palace', '龙宫之主大礼包', '礼包', 'LEWAN_46524A53', 80000), + ('suit_dune_song', '套装-沙丘之歌', '套装', 'LEWAN_F3B83B85', 10200), + ('suit_jester_face', '套装-花面小丑', '套装', 'LEWAN_B3891176', 20000) +) +INSERT INTO products (code, name, category, status) +SELECT v.new_sku, v.display_name, v.category, 'active' +FROM lewan_only v +ON CONFLICT (code) DO NOTHING; + +WITH lewan_only(new_sku, display_name, category, legacy_sku, default_price) AS ( + VALUES + ('pack_dragon_palace', '龙宫之主大礼包', '礼包', 'LEWAN_46524A53', 80000), + ('suit_dune_song', '套装-沙丘之歌', '套装', 'LEWAN_F3B83B85', 10200), + ('suit_jester_face', '套装-花面小丑', '套装', 'LEWAN_B3891176', 20000) +), +self_merchant AS ( + SELECT id FROM merchants WHERE code = 'self-operated' LIMIT 1 +), +legacy AS ( + SELECT + v.new_sku, + v.display_name, + v.default_price, + mp.price_amount, + mp.cost_amount, + mp.fulfillment_config, + p.id AS product_id + FROM lewan_only v + JOIN products p ON p.code = v.new_sku + LEFT JOIN merchant_products mp + ON mp.sku = v.legacy_sku + AND mp.merchant_id = (SELECT id FROM self_merchant) +) +INSERT INTO merchant_products ( + merchant_id, product_id, sku, display_name, + price_amount, cost_amount, currency, stock, status, fulfillment_config +) +SELECT + sm.id, + l.product_id, + l.new_sku, + l.display_name, + COALESCE(NULLIF(l.price_amount, 0), l.default_price), + COALESCE(NULLIF(l.cost_amount, 0), l.default_price), + 'POINT', + -1, + 'active', + COALESCE( + NULLIF(l.fulfillment_config, ''), + CASE l.new_sku + WHEN 'pack_dragon_palace' THEN + '{"routing_mode":"lewan","priority":["lewan","dlc"],"dlc_good_id":"","lewan_product_id":"46524a53-068d-4bee-97ff-d5e7873818e0","lewan_name":"龙宫之主大礼包","delivery_type":"LINK_BINDING"}' + WHEN 'suit_dune_song' THEN + '{"routing_mode":"lewan","priority":["lewan","dlc"],"dlc_good_id":"","lewan_product_id":"f3b83b85-c664-4440-94ac-a4b0cd7cf0f3","lewan_name":"套装-沙丘之歌","delivery_type":"LINK_BINDING"}' + WHEN 'suit_jester_face' THEN + '{"routing_mode":"lewan","priority":["lewan","dlc"],"dlc_good_id":"","lewan_product_id":"b3891176-5ffa-40cb-8c58-7e46eac3f381","lewan_name":"套装-花面小丑","delivery_type":"LINK_BINDING"}' + END + ) +FROM legacy l +CROSS JOIN self_merchant sm +ON CONFLICT (merchant_id, sku) DO UPDATE +SET + display_name = EXCLUDED.display_name, + price_amount = EXCLUDED.price_amount, + cost_amount = EXCLUDED.cost_amount, + fulfillment_config = COALESCE(NULLIF(merchant_products.fulfillment_config, ''), EXCLUDED.fulfillment_config), + status = 'active', + deleted_at = NULL; + +-- 已持有对应 LEWAN_* 的其它商户同步英文 sku +WITH mapping(new_sku, legacy_sku) AS ( + VALUES + ('pack_dragon_palace', 'LEWAN_46524A53'), + ('suit_dune_song', 'LEWAN_F3B83B85'), + ('suit_jester_face', 'LEWAN_B3891176') +), +template AS ( + SELECT mp.* + FROM merchant_products mp + JOIN merchants m ON m.id = mp.merchant_id AND m.code = 'self-operated' + JOIN mapping map ON map.new_sku = mp.sku + WHERE mp.deleted_at IS NULL +) +INSERT INTO merchant_products ( + merchant_id, product_id, sku, display_name, + price_amount, cost_amount, currency, stock, status, fulfillment_config +) +SELECT + legacy.merchant_id, + t.product_id, + t.sku, + t.display_name, + COALESCE(NULLIF(legacy.price_amount, 0), t.price_amount), + COALESCE(NULLIF(legacy.cost_amount, 0), t.cost_amount), + 'POINT', + -1, + 'active', + COALESCE(NULLIF(t.fulfillment_config, ''), legacy.fulfillment_config) +FROM merchant_products legacy +JOIN mapping map ON map.legacy_sku = legacy.sku +JOIN template t ON t.sku = map.new_sku +JOIN merchants m ON m.id = legacy.merchant_id AND m.code <> 'self-operated' +ON CONFLICT (merchant_id, sku) DO NOTHING; + +-- 2) 硬删除全部 LEWAN_* 镜像(含软删) +DELETE FROM merchant_products WHERE sku ILIKE 'lewan_%'; +DELETE FROM products WHERE code ILIKE 'lewan_%'; + +-- 3) 权威价格校准:1元=100积分(docs/商品目录.md 2026-09-14) +WITH prices(sku, amount) AS ( + VALUES + ('suit_alan_walker', 3600), + ('suit_shadow_gothic', 8880), + ('top_black_elite_trainer', 3600), + ('m416_hamster_gray', 16800), + ('bag_cute_bear', 28800), + ('suit_dual_fluffy', 11240), + ('suit_pink_sheep', 18800), + ('suit_first_peach', 9880), + ('suit_romantic_destiny', 3600), + ('pack_western_cowboy', 32000), + ('smoke_pink_sheep', 4880), + ('frag_pink_sheep', 4880), + ('suit_hamster_gray', 12800), + ('suit_cute_bear', 16800), + ('bag_pink_sheep', 16800), + ('helmet_pink_sheep', 16800), + ('bag_hamster_gray', 16800), + ('helmet_hamster_gray', 16800), + ('suit_western_mystery', 11500), + ('helmet_panda_treasure', 23400), + ('suit_panda_round', 23400), + ('suit_panda_tuan', 23400), + ('pack_lava_ranger', 9600), + ('suit_sand_dancer', 8400), + ('pack_star_roam_outfit', 83600), + ('pack_star_roam_weapon', 51600), + ('suit_cloud_bear', 13480), + ('honor_medal_x2', 200), + ('honor_medal_x30', 3000), + ('honor_medal_x90', 9000), + ('lucky_coin_x2', 200), + ('lucky_coin_x30', 3000), + ('lucky_coin_x90', 9000), + ('pack_dragon_palace', 80000), + ('suit_dune_song', 10200), + ('suit_jester_face', 20000) +) +UPDATE merchant_products mp +SET + price_amount = p.amount, + cost_amount = p.amount, + updated_at = NOW() +FROM prices p +WHERE mp.sku = p.sku + AND mp.deleted_at IS NULL; diff --git a/backend/internal/handler/delivery.go b/backend/internal/handler/delivery.go index f4e5ff2..6345bfb 100644 --- a/backend/internal/handler/delivery.go +++ b/backend/internal/handler/delivery.go @@ -72,7 +72,8 @@ func (h *DeliveryHandler) Submit(c *gin.Context) { response.OK(c, result) } -// ImageProxy 代理图片并支持本地持久化缓存,杜绝跨域污染和远端加载失败。 +// ImageProxy 代理外链图片并落盘缓存(仅无本地 SKU 图时的兜底)。 +// 正式商品图请使用 frontend/public/images/products/{sku}.webp(go run ./cmd/sync_product_images)。 func (h *DeliveryHandler) ImageProxy(c *gin.Context) { rawURL := strings.TrimSpace(c.Query("url")) if rawURL == "" || (!strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://")) { @@ -80,33 +81,57 @@ func (h *DeliveryHandler) ImageProxy(c *gin.Context) { return } - // 1. 优先检查本地官方商品图片缓存 - for _, base := range []string{"data/uploads/products", "../data/uploads/products", "uploads/products"} { - if strings.Contains(rawURL, "/products/") { - parts := strings.Split(rawURL, "/products/") - if len(parts) > 1 { - sub := strings.Split(parts[1], "/") - if len(sub) > 0 && sub[0] != "" { - localPath := filepath.Join(base, sub[0]+".webp") - if info, err := os.Stat(localPath); err == nil && info.Size() > 0 { - c.Header("Cache-Control", "public, max-age=2592000, immutable") - c.File(localPath) - return - } - } + sku := strings.TrimSpace(c.Query("sku")) + publicDirs := []string{ + "../frontend/public/images/products", + "frontend/public/images/products", + "public/images/products", + } + uploadDirs := []string{"data/uploads/products", "../data/uploads/products", "uploads/products"} + + tryLocal := func(dirs []string, name string) bool { + if name == "" { + return false + } + for _, base := range dirs { + localPath := filepath.Join(base, name+".webp") + if info, err := os.Stat(localPath); err == nil && info.Size() > 0 { + c.Header("Cache-Control", "public, max-age=2592000, immutable") + c.File(localPath) + return true } } - // 按 URL SHA256 哈希查本地缓存 - hash := sha256.Sum256([]byte(rawURL)) - hashPath := filepath.Join(base, hex.EncodeToString(hash[:])+".webp") - if info, err := os.Stat(hashPath); err == nil && info.Size() > 0 { - c.Header("Cache-Control", "public, max-age=2592000, immutable") - c.File(hashPath) - return - } + return false } - // 2. 本地无缓存时请求远端 + // 1. 优先 SKU 命名的静态缓存 + if tryLocal(publicDirs, sku) { + return + } + + // 2. 历史:URL 中的乐玩 UUID → 先查 public,再查 uploads + var uuidName string + if strings.Contains(rawURL, "/products/") { + parts := strings.Split(rawURL, "/products/") + if len(parts) > 1 { + sub := strings.Split(parts[1], "/") + if len(sub) > 0 && sub[0] != "" { + uuidName = sub[0] + } + } + } + if tryLocal(publicDirs, uuidName) || tryLocal(uploadDirs, uuidName) { + return + } + + // 3. URL hash 缓存(uploads 运行时兜底) + hash := sha256.Sum256([]byte(rawURL)) + hashName := hex.EncodeToString(hash[:]) + if tryLocal(uploadDirs, hashName) { + return + } + + // 4. 本地无缓存时请求远端 req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, rawURL, nil) if err != nil { response.BadRequest(c, "构建请求失败") @@ -131,20 +156,10 @@ func (h *DeliveryHandler) ImageProxy(c *gin.Context) { return } - // 3. 异步/同步回写到本地磁盘缓存 + // 5. 仅将未知外链写入 uploads 哈希缓存(不再按 UUID 污染正式目录) cacheDir := "data/uploads/products" _ = os.MkdirAll(cacheDir, 0755) - if strings.Contains(rawURL, "/products/") { - parts := strings.Split(rawURL, "/products/") - if len(parts) > 1 { - sub := strings.Split(parts[1], "/") - if len(sub) > 0 && sub[0] != "" { - _ = os.WriteFile(filepath.Join(cacheDir, sub[0]+".webp"), data, 0644) - } - } - } - hash := sha256.Sum256([]byte(rawURL)) - _ = os.WriteFile(filepath.Join(cacheDir, hex.EncodeToString(hash[:])+".webp"), data, 0644) + _ = os.WriteFile(filepath.Join(cacheDir, hashName+".webp"), data, 0644) contentType := resp.Header.Get("Content-Type") if contentType == "" { diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index 452af2b..a586c4c 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -56,8 +56,16 @@ func mapKeys(values map[string]struct{}) []string { func (h *MerchantHandler) ListProducts(c *gin.Context) { page, size := pageParams(c) - list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false) + list, total, err := h.merchantSvc.ListMerchantProductsWithOptions(middleware.GetMerchantID(c), page, size, service.ListMerchantProductsOptions{ + Status: strings.TrimSpace(c.Query("status")), + Keyword: strings.TrimSpace(c.Query("keyword")), + ExcludeLegacyLewanSKU: true, // LEWAN_* 镜像已废弃,列表永不返回 + }) if err != nil { + if err.Error() == "无效的商品状态" { + response.BadRequest(c, err.Error()) + return + } response.ServerError(c, err.Error()) return } @@ -86,6 +94,16 @@ func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) { response.OK(c, nil) } +// DeleteProduct DELETE /api/merchant/products/:id — 仅已下架可删(软删除)。 +func (h *MerchantHandler) DeleteProduct(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.merchantSvc.DeleteMerchantProduct(middleware.GetMerchantID(c), uint(id), middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + func (h *MerchantHandler) ListOrders(c *gin.Context) { page, size := pageParams(c) orderSource := strings.TrimSpace(c.Query("order_source")) @@ -103,7 +121,7 @@ func (h *MerchantHandler) ListOrders(c *gin.Context) { // ListManualOrderProducts returns active products for order creation without granting product-management access. func (h *MerchantHandler) ListManualOrderProducts(c *gin.Context) { - list, _, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), 1, 100, true) + list, _, err := h.merchantSvc.ListMerchantProductsForClient(middleware.GetMerchantID(c), 1, 100) if err != nil { response.ServerError(c, err.Error()) return diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index 0f77a15..0c56cdc 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "affiliate_dash/internal/middleware" "affiliate_dash/internal/model" @@ -30,7 +31,7 @@ func (h *OpenV1Handler) ListProducts(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) openlog.Info(c, "list_products start page=%d size=%d", page, size) - list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, true) + list, total, err := h.merchantSvc.ListMerchantProductsForClient(middleware.GetMerchantID(c), page, size) if err != nil { openlog.Warn(c, "list_products fail err=%v", err) response.ServerError(c, err.Error()) @@ -71,6 +72,11 @@ func (h *OpenV1Handler) CreateOrder(c *gin.Context) { } data = decoded } + if service.IsLegacyLewanMirrorSKU(req.SKU) { + openlog.Warn(c, "create_order reject legacy lewan sku=%s", strings.TrimSpace(req.SKU)) + response.BadRequest(c, "请使用英文主商品 sku 下单,勿使用 LEWAN_ 镜像 sku") + return + } client := middleware.GetAPIClient(c) openlog.Info(c, "create_order start client_order_no=%s sku=%s quantity=%d buyer=%s", req.ClientOrderNo, req.SKU, req.Quantity, req.BuyerReference) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 1d4877d..ef9de25 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -166,6 +166,7 @@ func Setup(h *Handlers) *gin.Engine { merchant.GET("", h.Merchant.Current) merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.ListProducts) merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.UpdateProductStatus) + merchant.DELETE("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.DeleteProduct) merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListOrders) merchant.GET("/orders/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListManualOrderProducts) merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateManualOrder) diff --git a/backend/internal/service/merchant_catalog.go b/backend/internal/service/merchant_catalog.go index e42d136..8f70af8 100644 --- a/backend/internal/service/merchant_catalog.go +++ b/backend/internal/service/merchant_catalog.go @@ -336,7 +336,10 @@ type SyncLewanProductInput struct { DeliveryType string `json:"delivery_type"` } -// SyncLewanProductToCatalog 导入或关联乐玩商品至平台商品目录 +// SyncLewanProductToCatalog 将乐玩商品关联到英文主 sku 目录。 +// - 禁止新建 LEWAN_* 镜像 sku +// - sku 为空时按 display_name 匹配已有主商品并挂接乐玩履约 +// - 新商品必须显式传入英文 sku func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, actorUserID uint) (*ProductCatalogItem, error) { if in.LewanProductID == "" { return nil, errors.New("乐玩商品ID不能为空") @@ -353,18 +356,38 @@ func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, ac } sku := strings.TrimSpace(in.SKU) + if IsLegacyLewanMirrorSKU(sku) { + return errors.New("禁止使用 LEWAN_ 前缀 sku,请填写英文主商品 sku,或留空以按中文名关联已有商品") + } + + var mp model.MerchantProduct if sku == "" { - // 自动根据乐玩 UUID 派生 SKU - cleanID := strings.ReplaceAll(in.LewanProductID, "-", "") - if len(cleanID) > 12 { - cleanID = cleanID[:12] + // 按中文名关联已有主商品(排除 LEWAN_*) + if err := tx.Where( + "merchant_id = ? AND display_name = ? AND sku NOT ILIKE ? AND status = ?", + selfMerchant.ID, in.Name, "lewan_%", model.ProductStatusActive, + ).Order("id ASC").First(&mp).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("未找到同名英文主商品,请填写英文 sku 新建(如 suit_xxx / pack_xxx)") + } + return err } - sku = "lewan_" + cleanID + sku = mp.SKU + } else { + if err := tx.Where("merchant_id = ? AND sku = ?", selfMerchant.ID, sku).First(&mp).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + mp = model.MerchantProduct{} + } } - // 检查 Product var product model.Product - if err := tx.Where("code = ?", sku).First(&product).Error; err != nil { + if mp.ID > 0 { + if err := tx.Where("id = ?", mp.ProductID).First(&product).Error; err != nil { + return err + } + } else if err := tx.Where("code = ?", sku).First(&product).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { product = model.Product{ Code: sku, @@ -397,45 +420,56 @@ func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, ac routing.DLCGoodID = builtinDLC routing.RoutingMode = RoutingModePriority } + // 保留已有 dlc_good_id(若原配置有) + if mp.ID > 0 { + if existing := ParseProductRoutingConfig(mp.FulfillmentConfig, sku); existing.DLCGoodID != "" && routing.DLCGoodID == "" { + routing.DLCGoodID = existing.DLCGoodID + routing.RoutingMode = RoutingModePriority + } + } configBytes, _ := json.Marshal(routing) configStr := string(configBytes) - // 检查自营商户商品 - var mp model.MerchantProduct - if err := tx.Where("merchant_id = ? AND sku = ?", selfMerchant.ID, sku).First(&mp).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - price := in.Price - if price <= 0 { - price = 1000 - } - mp = model.MerchantProduct{ - MerchantID: selfMerchant.ID, - ProductID: product.ID, - SKU: sku, - DisplayName: in.Name, - PriceAmount: price, - CostAmount: price, - Currency: "POINT", - Stock: -1, - Status: model.ProductStatusActive, - FulfillmentConfig: configStr, - } - if err := tx.Create(&mp).Error; err != nil { - return err - } - } else { + if mp.ID == 0 { + price := in.Price + if price <= 0 { + price = 1000 + } + mp = model.MerchantProduct{ + MerchantID: selfMerchant.ID, + ProductID: product.ID, + SKU: sku, + DisplayName: in.Name, + PriceAmount: price, + CostAmount: price, + Currency: "POINT", + Stock: -1, + Status: model.ProductStatusActive, + FulfillmentConfig: configStr, + } + if err := tx.Create(&mp).Error; err != nil { return err } } else { - // 更新现有配置 - _ = tx.Model(&mp).Update("fulfillment_config", configStr).Error + updates := map[string]interface{}{ + "fulfillment_config": configStr, + } + if in.Price > 0 { + updates["price_amount"] = in.Price + updates["cost_amount"] = in.Price + } + if err := tx.Model(&mp).Updates(updates).Error; err != nil { + return err + } + // 同步所有商户同 SKU 的履约配置 + _ = tx.Model(&model.MerchantProduct{}).Where("sku = ?", sku).Update("fulfillment_config", configStr).Error } res = &ProductCatalogItem{ ID: mp.ID, ProductID: product.ID, SKU: sku, - DisplayName: mp.DisplayName, + DisplayName: fallbackName(mp.DisplayName, in.Name), Category: product.Category, PriceAmount: mp.PriceAmount, CostAmount: mp.CostAmount, diff --git a/backend/internal/service/merchant_helpers.go b/backend/internal/service/merchant_helpers.go index cf31f78..a3126b2 100644 --- a/backend/internal/service/merchant_helpers.go +++ b/backend/internal/service/merchant_helpers.go @@ -39,3 +39,10 @@ func fallbackName(value, fallback string) string { } return fallback } + +// IsLegacyLewanMirrorSKU 判断是否为历史乐玩镜像 sku(LEWAN_*/lewan_*)。 +// 对外目录应使用英文主 sku;乐玩只作为履约配置挂接。 +func IsLegacyLewanMirrorSKU(sku string) bool { + normalized := strings.ToLower(strings.TrimSpace(sku)) + return strings.HasPrefix(normalized, "lewan_") +} diff --git a/backend/internal/service/merchant_helpers_test.go b/backend/internal/service/merchant_helpers_test.go new file mode 100644 index 0000000..fa7634e --- /dev/null +++ b/backend/internal/service/merchant_helpers_test.go @@ -0,0 +1,23 @@ +package service + +import "testing" + +func TestIsLegacyLewanMirrorSKU(t *testing.T) { + cases := []struct { + sku string + want bool + }{ + {"LEWAN_8EED3610", true}, + {"lewan_abc", true}, + {"Lewan_x", true}, + {"suit_shadow_gothic", false}, + {"pack_dragon_palace", false}, + {"", false}, + {" LEWAN_X ", true}, + } + for _, tc := range cases { + if got := IsLegacyLewanMirrorSKU(tc.sku); got != tc.want { + t.Fatalf("IsLegacyLewanMirrorSKU(%q)=%v want %v", tc.sku, got, tc.want) + } + } +} diff --git a/backend/internal/service/merchant_product.go b/backend/internal/service/merchant_product.go index ee9eafa..080f8e7 100644 --- a/backend/internal/service/merchant_product.go +++ b/backend/internal/service/merchant_product.go @@ -83,11 +83,45 @@ func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMercha return merchantProduct, nil } +type ListMerchantProductsOptions struct { + ActiveOnly bool + ExcludeLegacyLewanSKU bool + Status string // active / inactive;空表示不限(ActiveOnly 优先) + Keyword string +} + func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) { + return s.ListMerchantProductsWithOptions(merchantID, page, size, ListMerchantProductsOptions{ + ActiveOnly: activeOnly, + }) +} + +// ListMerchantProductsForClient 商户开放 API / 手工下单选品:排除 LEWAN_* 镜像,只返回英文主 sku。 +func (s *MerchantService) ListMerchantProductsForClient(merchantID uint, page, size int) ([]model.MerchantProduct, int64, error) { + return s.ListMerchantProductsWithOptions(merchantID, page, size, ListMerchantProductsOptions{ + ActiveOnly: true, + ExcludeLegacyLewanSKU: true, + }) +} + +func (s *MerchantService) ListMerchantProductsWithOptions(merchantID uint, page, size int, opts ListMerchantProductsOptions) ([]model.MerchantProduct, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID) - if activeOnly { + status := strings.TrimSpace(opts.Status) + if opts.ActiveOnly { tx = tx.Where("status = ?", model.ProductStatusActive) + } else if status != "" { + if status != model.ProductStatusActive && status != model.ProductStatusInactive { + return nil, 0, errors.New("无效的商品状态") + } + tx = tx.Where("status = ?", status) + } + if opts.ExcludeLegacyLewanSKU { + tx = tx.Where("sku NOT ILIKE ?", "lewan_%") + } + if keyword := strings.TrimSpace(opts.Keyword); keyword != "" { + like := "%" + keyword + "%" + tx = tx.Where("(sku ILIKE ? OR display_name ILIKE ?)", like, like) } var total int64 if err := tx.Count(&total).Error; err != nil { @@ -98,6 +132,28 @@ func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, return products, total, err } +// DeleteMerchantProduct 软删除商户商品;仅允许删除已下架商品,避免误删在售品。 +func (s *MerchantService) DeleteMerchantProduct(merchantID, id uint, actorUserID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var item model.MerchantProduct + if err := tx.Where("id = ? AND merchant_id = ?", id, merchantID).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("商品不存在") + } + return err + } + if item.Status != model.ProductStatusInactive { + return errors.New("请先下架后再删除") + } + if err := tx.Delete(&item).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.delete", "merchant_product", fmt.Sprint(id), map[string]string{ + "sku": item.SKU, + }) + }) +} + type UpdateMerchantProductInput struct { DisplayName *string PriceAmount *int64 diff --git a/docs/商品列表.md b/docs/商品列表.md index dc4c8e2..ecce5f2 100644 --- a/docs/商品列表.md +++ b/docs/商品列表.md @@ -1,37 +1,2 @@ -商品 sku -套装-Alan Walker suit_alan_walker -套装-暗影哥特 suit_shadow_gothic -黑色高级特训官上衣 top_black_elite_trainer -M416-仓鼠灰灰 m416_hamster_gray -萌熊伴侣背包 bag_cute_bear -套装-双彩绵绵 suit_dual_fluffy -套装-糯粉咩咩 suit_pink_sheep -套装-恋恋初桃 suit_first_peach -套装-浪漫天命 suit_romantic_destiny -西部牛仔大礼包 pack_western_cowboy -烟雾弹-糯粉咩咩 smoke_pink_sheep -破片手榴弹-糯粉咩咩 frag_pink_sheep -套装-仓鼠灰灰 suit_hamster_gray -套装-萌熊伴侣 suit_cute_bear -糯粉咩咩背包 bag_pink_sheep -糯粉咩咩头盔 helmet_pink_sheep -仓鼠灰灰背包 bag_hamster_gray -仓鼠灰灰头盔 helmet_hamster_gray -套装-西部谜踪 suit_western_mystery -国宝胖达头盔 helmet_panda_treasure -套装-胖达圆圆 suit_panda_round -套装-胖达团团 suit_panda_tuan -熔岩游骑兵礼包 pack_lava_ranger -套装-狂沙舞者 suit_sand_dancer -星际漫游服装礼包 pack_star_roam_outfit -星际漫游枪械礼包 pack_star_roam_weapon -套装-绵云熊熊 suit_cloud_bear - - -荣耀勋章2个 honor_medal_x2 -荣耀勋章30个 honor_medal_x30 -荣耀勋章90个 honor_medal_x90 - -幸运币2个 lucky_coin_x2 -幸运币30个 lucky_coin_x30 -幸运币90个 lucky_coin_x90 \ No newline at end of file +商品目录已合并到 docs/商品目录.md(含中英文 SKU、价格、履约与图片缓存约定)。 +本文件仅保留历史速查,请以商品目录为准。 diff --git a/docs/商品目录.md b/docs/商品目录.md new file mode 100644 index 0000000..e44507b --- /dev/null +++ b/docs/商品目录.md @@ -0,0 +1,120 @@ +# 商品目录(权威快照) + +> 更新时间:2026-09-14 +> 数据来源:自营商户开放/后台商品列表(`merchant_id=1`,共 **36** 条) +> **积分比例:1 元 = 100 积分**(`price_amount` / `cost_amount` 单位均为积分) +> 本地图片缓存:`frontend/public/images/products/{sku}.webp`(按商户 SKU 命名) + +## 约定 + +| 字段 | 说明 | +|------|------| +| 中文名 | `display_name` / 目录 `product.name` | +| 英文 SKU | 对外主 sku,下单、映射、图片文件名均用此值 | +| 目录编码 | `product.code`,与英文 SKU 一致 | +| 售价/成本 | 积分;展示元时 ÷100 | +| 履约 | `fulfillment_config`:`dlc` / `lewan` / `priority`;空配置表示尚未挂乐玩,默认走 DLC 内置 GoodID | +| 图片 | 优先本地 `/images/products/{sku}.webp` | + +**不要再使用 `LEWAN_*` 镜像 sku 对外下单。** + +## 全量目录 + +| 中文名 | 英文 SKU | 分类 | 售价(积分) | 成本(积分) | 约合人民币 | 履约模式 | DLC GoodID | 乐玩 Product ID | +|--------|----------|------|------------|------------|------------|----------|------------|-----------------| +| 套装-花面小丑 | `suit_jester_face` | 套装 | 20000 | 20000 | ¥200.00 | lewan | — | `b3891176-5ffa-40cb-8c58-7e46eac3f381` | +| 套装-沙丘之歌 | `suit_dune_song` | 套装 | 10200 | 10200 | ¥102.00 | lewan | — | `f3b83b85-c664-4440-94ac-a4b0cd7cf0f3` | +| 龙宫之主大礼包 | `pack_dragon_palace` | 礼包 | 80000 | 80000 | ¥800.00 | lewan | — | `46524a53-068d-4bee-97ff-d5e7873818e0` | +| 幸运币90个 | `lucky_coin_x90` | 道具 | 9000 | 9000 | ¥90.00 | priority | `682ef39ca8f40c4234c59f42` | `518e3bec-f9f2-483b-89ae-d826c9cf14e5` | +| 幸运币30个 | `lucky_coin_x30` | 道具 | 3000 | 3000 | ¥30.00 | priority | `682ef39ca8f40c4234c59f44` | `b1c64119-d164-41e6-a1dc-0d8167dd09de` | +| 幸运币2个 | `lucky_coin_x2` | 道具 | 200 | 200 | ¥2.00 | priority | `682ef39ca8f40c4234c59f46` | `d5dfa4e3-675b-4187-8163-8aa04727c6e4` | +| 荣耀勋章90个 | `honor_medal_x90` | 道具 | 9000 | 9000 | ¥90.00 | priority | `682ef39ca8f40c4234c59f41` | `779857c7-e6db-40e3-9f4f-4eb5eca350e1` | +| 荣耀勋章30个 | `honor_medal_x30` | 道具 | 3000 | 3000 | ¥30.00 | priority | `682ef39ca8f40c4234c59f43` | `a09d18ce-4a55-4313-925b-6de66d96154b` | +| 荣耀勋章2个 | `honor_medal_x2` | 道具 | 200 | 200 | ¥2.00 | priority | `682ef39ca8f40c4234c59f45` | `ed57398a-a470-4d85-abc4-761c81274d9f` | +| 套装-绵云熊熊 | `suit_cloud_bear` | 套装 | 13480 | 13480 | ¥134.80 | dlc(默认) | `682ef39ca8f40c4234c59f40` | — | +| 星际漫游枪械礼包 | `pack_star_roam_weapon` | 礼包 | 51600 | 51600 | ¥516.00 | dlc(默认) | `682ef39ca8f40c4234c59f3f` | — | +| 星际漫游服装礼包 | `pack_star_roam_outfit` | 礼包 | 83600 | 83600 | ¥836.00 | dlc(默认) | `682ef39ca8f40c4234c59f3e` | — | +| 套装-狂沙舞者 | `suit_sand_dancer` | 套装 | 8400 | 8400 | ¥84.00 | dlc(默认) | `682ef39ca8f40c4234c59f3d` | — | +| 熔岩游骑兵礼包 | `pack_lava_ranger` | 礼包 | 9600 | 9600 | ¥96.00 | dlc(默认) | `682ef39ca8f40c4234c59f3c` | — | +| 套装-胖达团团 | `suit_panda_tuan` | 套装 | 23400 | 23400 | ¥234.00 | dlc(默认) | `682ef39ca8f40c4234c59f3b` | — | +| 套装-胖达圆圆 | `suit_panda_round` | 套装 | 23400 | 23400 | ¥234.00 | dlc(默认) | `682ef39ca8f40c4234c59f3a` | — | +| 国宝胖达头盔 | `helmet_panda_treasure` | 头盔 | 23400 | 23400 | ¥234.00 | dlc(默认) | `682ef39ca8f40c4234c59f39` | — | +| 套装-西部谜踪 | `suit_western_mystery` | 套装 | 11500 | 11500 | ¥115.00 | dlc(默认) | `682ef39ca8f40c4234c59f35` | — | +| 仓鼠灰灰头盔 | `helmet_hamster_gray` | 头盔 | 16800 | 16800 | ¥168.00 | dlc(默认) | `682ef3c5a8f40c4234c59f54` | — | +| 仓鼠灰灰背包 | `bag_hamster_gray` | 背包 | 16800 | 16800 | ¥168.00 | dlc(默认) | `682ef3c5a8f40c4234c59f55` | — | +| 糯粉咩咩头盔 | `helmet_pink_sheep` | 头盔 | 16800 | 16800 | ¥168.00 | dlc(默认) | `682ef3c5a8f40c4234c59f4e` | — | +| 糯粉咩咩背包 | `bag_pink_sheep` | 背包 | 16800 | 16800 | ¥168.00 | dlc(默认) | `682ef3c5a8f40c4234c59f4f` | — | +| 套装-萌熊伴侣 | `suit_cute_bear` | 套装 | 16800 | 16800 | ¥168.00 | dlc(默认) | `682ef3c5a8f40c4234c59f4b` | — | +| 套装-仓鼠灰灰 | `suit_hamster_gray` | 套装 | 12800 | 12800 | ¥128.00 | dlc(默认) | `682ef3c5a8f40c4234c59f53` | — | +| 破片手榴弹-糯粉咩咩 | `frag_pink_sheep` | 投掷物 | 4880 | 4880 | ¥48.80 | dlc(默认) | `682ef3c5a8f40c4234c59f51` | — | +| 烟雾弹-糯粉咩咩 | `smoke_pink_sheep` | 投掷物 | 4880 | 4880 | ¥48.80 | dlc(默认) | `682ef3c5a8f40c4234c59f50` | — | +| 西部牛仔大礼包 | `pack_western_cowboy` | 礼包 | 32000 | 32000 | ¥320.00 | dlc(默认) | `682ef3c5a8f40c4234c59f4c` | — | +| 套装-浪漫天命 | `suit_romantic_destiny` | 套装 | 3600 | 3600 | ¥36.00 | priority | `682ef39ca8f40c4234c59f38` | `b1fff090-e389-4c6c-9c51-32751582239d` | +| 套装-恋恋初桃 | `suit_first_peach` | 套装 | 9880 | 9880 | ¥98.80 | dlc(默认) | `682ef3c5a8f40c4234c59f48` | — | +| 套装-糯粉咩咩 | `suit_pink_sheep` | 套装 | 18800 | 18800 | ¥188.00 | dlc(默认) | `682ef3c5a8f40c4234c59f4d` | — | +| 套装-双彩绵绵 | `suit_dual_fluffy` | 套装 | 11240 | 11240 | ¥112.40 | dlc(默认) | `682ef3c5a8f40c4234c59f49` | — | +| 萌熊伴侣背包 | `bag_cute_bear` | 背包 | 28800 | 28800 | ¥288.00 | dlc(默认) | `682ef3c5a8f40c4234c59f4a` | — | +| M416-仓鼠灰灰 | `m416_hamster_gray` | 枪械 | 16800 | 16800 | ¥168.00 | dlc(默认) | `682ef3c5a8f40c4234c59f52` | — | +| 黑色高级特训官上衣 | `top_black_elite_trainer` | 上衣 | 3600 | 3600 | ¥36.00 | priority | `682ef39ca8f40c4234c59f37` | `8621502c-e355-455c-9b74-a7e642a40cfe` | +| 套装-暗影哥特 | `suit_shadow_gothic` | 套装 | 8880 | 8880 | ¥88.80 | priority | `682ef39ca8f40c4234c59f36` | `8eed3610-7238-4b89-8010-6713268f7cd3` | +| 套装-Alan Walker | `suit_alan_walker` | 套装 | 3600 | 3600 | ¥36.00 | dlc(默认) | `682ef3c5a8f40c4234c59f47` | — | + +## 中英文速查 + +| 中文名 | 英文 SKU | +|--------|----------| +| 套装-Alan Walker | suit_alan_walker | +| 套装-暗影哥特 | suit_shadow_gothic | +| 黑色高级特训官上衣 | top_black_elite_trainer | +| M416-仓鼠灰灰 | m416_hamster_gray | +| 萌熊伴侣背包 | bag_cute_bear | +| 套装-双彩绵绵 | suit_dual_fluffy | +| 套装-糯粉咩咩 | suit_pink_sheep | +| 套装-恋恋初桃 | suit_first_peach | +| 套装-浪漫天命 | suit_romantic_destiny | +| 西部牛仔大礼包 | pack_western_cowboy | +| 烟雾弹-糯粉咩咩 | smoke_pink_sheep | +| 破片手榴弹-糯粉咩咩 | frag_pink_sheep | +| 套装-仓鼠灰灰 | suit_hamster_gray | +| 套装-萌熊伴侣 | suit_cute_bear | +| 糯粉咩咩背包 | bag_pink_sheep | +| 糯粉咩咩头盔 | helmet_pink_sheep | +| 仓鼠灰灰背包 | bag_hamster_gray | +| 仓鼠灰灰头盔 | helmet_hamster_gray | +| 套装-西部谜踪 | suit_western_mystery | +| 国宝胖达头盔 | helmet_panda_treasure | +| 套装-胖达圆圆 | suit_panda_round | +| 套装-胖达团团 | suit_panda_tuan | +| 熔岩游骑兵礼包 | pack_lava_ranger | +| 套装-狂沙舞者 | suit_sand_dancer | +| 星际漫游服装礼包 | pack_star_roam_outfit | +| 星际漫游枪械礼包 | pack_star_roam_weapon | +| 套装-绵云熊熊 | suit_cloud_bear | +| 荣耀勋章2个 | honor_medal_x2 | +| 荣耀勋章30个 | honor_medal_x30 | +| 荣耀勋章90个 | honor_medal_x90 | +| 幸运币2个 | lucky_coin_x2 | +| 幸运币30个 | lucky_coin_x30 | +| 幸运币90个 | lucky_coin_x90 | +| 龙宫之主大礼包 | pack_dragon_palace | +| 套装-沙丘之歌 | suit_dune_song | +| 套装-花面小丑 | suit_jester_face | + +## 图片缓存 + +- **正式目录(按 SKU)**:`frontend/public/images/products/{sku}.webp` +- **运行时兜底**:`data/uploads/products/` 仅给 `image-proxy` 缓存未知外链(哈希文件名),正式商品图不要放这里 +- 同步命令: + +```bash +cd backend && go run ./cmd/sync_product_images +``` + +- 前端:`resolveProductImage({ sku, url })` / `resolveProductImageBySku(sku)` 一律优先 SKU 本地文件 +- 使用处:发货页 `Delivery.tsx`、电子凭证 `voucher.ts` + +## 维护说明 + +1. 以后以本表(或同结构的最新导出)为准更新价格与履约挂接。 +2. 新增商品:先定英文 SKU → 写目录/挂履约 → 跑图片同步。 +3. order_site 映射只填英文 SKU,不要填 `LEWAN_*`。 diff --git a/docs/商品英文名对照.md b/docs/商品英文名对照.md index eb8495b..78ae341 100644 --- a/docs/商品英文名对照.md +++ b/docs/商品英文名对照.md @@ -1,3 +1,7 @@ +# 商品英文名对照 + +权威全表(含价格、履约、图片约定)见:[商品目录.md](./商品目录.md) + | 中文名 | 英文名 | |--------|--------| | 套装-Alan Walker | suit_alan_walker | @@ -33,3 +37,6 @@ | 幸运币2个 | lucky_coin_x2 | | 幸运币30个 | lucky_coin_x30 | | 幸运币90个 | lucky_coin_x90 | +| 龙宫之主大礼包 | pack_dragon_palace | +| 套装-沙丘之歌 | suit_dune_song | +| 套装-花面小丑 | suit_jester_face | diff --git a/frontend/public/images/products/bag_cute_bear.webp b/frontend/public/images/products/bag_cute_bear.webp new file mode 100644 index 0000000..683b02e Binary files /dev/null and b/frontend/public/images/products/bag_cute_bear.webp differ diff --git a/frontend/public/images/products/bag_hamster_gray.webp b/frontend/public/images/products/bag_hamster_gray.webp new file mode 100644 index 0000000..c8e5068 Binary files /dev/null and b/frontend/public/images/products/bag_hamster_gray.webp differ diff --git a/frontend/public/images/products/bag_pink_sheep.webp b/frontend/public/images/products/bag_pink_sheep.webp new file mode 100644 index 0000000..3ffd916 Binary files /dev/null and b/frontend/public/images/products/bag_pink_sheep.webp differ diff --git a/frontend/public/images/products/frag_pink_sheep.webp b/frontend/public/images/products/frag_pink_sheep.webp new file mode 100644 index 0000000..342069d Binary files /dev/null and b/frontend/public/images/products/frag_pink_sheep.webp differ diff --git a/frontend/public/images/products/helmet_hamster_gray.webp b/frontend/public/images/products/helmet_hamster_gray.webp new file mode 100644 index 0000000..7bc30c3 Binary files /dev/null and b/frontend/public/images/products/helmet_hamster_gray.webp differ diff --git a/frontend/public/images/products/helmet_panda_treasure.webp b/frontend/public/images/products/helmet_panda_treasure.webp new file mode 100644 index 0000000..7765c69 Binary files /dev/null and b/frontend/public/images/products/helmet_panda_treasure.webp differ diff --git a/frontend/public/images/products/helmet_pink_sheep.webp b/frontend/public/images/products/helmet_pink_sheep.webp new file mode 100644 index 0000000..c24bedf Binary files /dev/null and b/frontend/public/images/products/helmet_pink_sheep.webp differ diff --git a/frontend/public/images/products/ed57398a-a470-4d85-abc4-761c81274d9f.webp b/frontend/public/images/products/honor_medal_x2.webp similarity index 100% rename from frontend/public/images/products/ed57398a-a470-4d85-abc4-761c81274d9f.webp rename to frontend/public/images/products/honor_medal_x2.webp diff --git a/frontend/public/images/products/a09d18ce-4a55-4313-925b-6de66d96154b.webp b/frontend/public/images/products/honor_medal_x30.webp similarity index 100% rename from frontend/public/images/products/a09d18ce-4a55-4313-925b-6de66d96154b.webp rename to frontend/public/images/products/honor_medal_x30.webp diff --git a/frontend/public/images/products/779857c7-e6db-40e3-9f4f-4eb5eca350e1.webp b/frontend/public/images/products/honor_medal_x90.webp similarity index 100% rename from frontend/public/images/products/779857c7-e6db-40e3-9f4f-4eb5eca350e1.webp rename to frontend/public/images/products/honor_medal_x90.webp diff --git a/frontend/public/images/products/d5dfa4e3-675b-4187-8163-8aa04727c6e4.webp b/frontend/public/images/products/lucky_coin_x2.webp similarity index 100% rename from frontend/public/images/products/d5dfa4e3-675b-4187-8163-8aa04727c6e4.webp rename to frontend/public/images/products/lucky_coin_x2.webp diff --git a/frontend/public/images/products/b1c64119-d164-41e6-a1dc-0d8167dd09de.webp b/frontend/public/images/products/lucky_coin_x30.webp similarity index 100% rename from frontend/public/images/products/b1c64119-d164-41e6-a1dc-0d8167dd09de.webp rename to frontend/public/images/products/lucky_coin_x30.webp diff --git a/frontend/public/images/products/518e3bec-f9f2-483b-89ae-d826c9cf14e5.webp b/frontend/public/images/products/lucky_coin_x90.webp similarity index 100% rename from frontend/public/images/products/518e3bec-f9f2-483b-89ae-d826c9cf14e5.webp rename to frontend/public/images/products/lucky_coin_x90.webp diff --git a/frontend/public/images/products/m416_hamster_gray.webp b/frontend/public/images/products/m416_hamster_gray.webp new file mode 100644 index 0000000..7ed089c Binary files /dev/null and b/frontend/public/images/products/m416_hamster_gray.webp differ diff --git a/frontend/public/images/products/46524a53-068d-4bee-97ff-d5e7873818e0.webp b/frontend/public/images/products/pack_dragon_palace.webp similarity index 100% rename from frontend/public/images/products/46524a53-068d-4bee-97ff-d5e7873818e0.webp rename to frontend/public/images/products/pack_dragon_palace.webp diff --git a/frontend/public/images/products/pack_lava_ranger.webp b/frontend/public/images/products/pack_lava_ranger.webp new file mode 100644 index 0000000..22947c2 Binary files /dev/null and b/frontend/public/images/products/pack_lava_ranger.webp differ diff --git a/frontend/public/images/products/pack_star_roam_outfit.webp b/frontend/public/images/products/pack_star_roam_outfit.webp new file mode 100644 index 0000000..5afe827 Binary files /dev/null and b/frontend/public/images/products/pack_star_roam_outfit.webp differ diff --git a/frontend/public/images/products/pack_star_roam_weapon.webp b/frontend/public/images/products/pack_star_roam_weapon.webp new file mode 100644 index 0000000..037eb0d Binary files /dev/null and b/frontend/public/images/products/pack_star_roam_weapon.webp differ diff --git a/frontend/public/images/products/pack_western_cowboy.webp b/frontend/public/images/products/pack_western_cowboy.webp new file mode 100644 index 0000000..7390f8a Binary files /dev/null and b/frontend/public/images/products/pack_western_cowboy.webp differ diff --git a/frontend/public/images/products/smoke_pink_sheep.webp b/frontend/public/images/products/smoke_pink_sheep.webp new file mode 100644 index 0000000..09504b6 Binary files /dev/null and b/frontend/public/images/products/smoke_pink_sheep.webp differ diff --git a/frontend/public/images/products/suit_alan_walker.webp b/frontend/public/images/products/suit_alan_walker.webp new file mode 100644 index 0000000..d064dca Binary files /dev/null and b/frontend/public/images/products/suit_alan_walker.webp differ diff --git a/frontend/public/images/products/suit_cloud_bear.webp b/frontend/public/images/products/suit_cloud_bear.webp new file mode 100644 index 0000000..ec782a6 Binary files /dev/null and b/frontend/public/images/products/suit_cloud_bear.webp differ diff --git a/frontend/public/images/products/suit_cute_bear.webp b/frontend/public/images/products/suit_cute_bear.webp new file mode 100644 index 0000000..d391e64 Binary files /dev/null and b/frontend/public/images/products/suit_cute_bear.webp differ diff --git a/frontend/public/images/products/suit_dual_fluffy.webp b/frontend/public/images/products/suit_dual_fluffy.webp new file mode 100644 index 0000000..d8959f5 Binary files /dev/null and b/frontend/public/images/products/suit_dual_fluffy.webp differ diff --git a/frontend/public/images/products/f3b83b85-c664-4440-94ac-a4b0cd7cf0f3.webp b/frontend/public/images/products/suit_dune_song.webp similarity index 100% rename from frontend/public/images/products/f3b83b85-c664-4440-94ac-a4b0cd7cf0f3.webp rename to frontend/public/images/products/suit_dune_song.webp diff --git a/frontend/public/images/products/suit_first_peach.webp b/frontend/public/images/products/suit_first_peach.webp new file mode 100644 index 0000000..56f2268 Binary files /dev/null and b/frontend/public/images/products/suit_first_peach.webp differ diff --git a/frontend/public/images/products/suit_hamster_gray.webp b/frontend/public/images/products/suit_hamster_gray.webp new file mode 100644 index 0000000..237d908 Binary files /dev/null and b/frontend/public/images/products/suit_hamster_gray.webp differ diff --git a/frontend/public/images/products/b3891176-5ffa-40cb-8c58-7e46eac3f381.webp b/frontend/public/images/products/suit_jester_face.webp similarity index 100% rename from frontend/public/images/products/b3891176-5ffa-40cb-8c58-7e46eac3f381.webp rename to frontend/public/images/products/suit_jester_face.webp diff --git a/frontend/public/images/products/suit_panda_round.webp b/frontend/public/images/products/suit_panda_round.webp new file mode 100644 index 0000000..c169502 Binary files /dev/null and b/frontend/public/images/products/suit_panda_round.webp differ diff --git a/frontend/public/images/products/suit_panda_tuan.webp b/frontend/public/images/products/suit_panda_tuan.webp new file mode 100644 index 0000000..eb4220d Binary files /dev/null and b/frontend/public/images/products/suit_panda_tuan.webp differ diff --git a/frontend/public/images/products/suit_pink_sheep.webp b/frontend/public/images/products/suit_pink_sheep.webp new file mode 100644 index 0000000..c4b7d92 Binary files /dev/null and b/frontend/public/images/products/suit_pink_sheep.webp differ diff --git a/frontend/public/images/products/b1fff090-e389-4c6c-9c51-32751582239d.webp b/frontend/public/images/products/suit_romantic_destiny.webp similarity index 100% rename from frontend/public/images/products/b1fff090-e389-4c6c-9c51-32751582239d.webp rename to frontend/public/images/products/suit_romantic_destiny.webp diff --git a/frontend/public/images/products/suit_sand_dancer.webp b/frontend/public/images/products/suit_sand_dancer.webp new file mode 100644 index 0000000..d61dc83 Binary files /dev/null and b/frontend/public/images/products/suit_sand_dancer.webp differ diff --git a/frontend/public/images/products/8eed3610-7238-4b89-8010-6713268f7cd3.webp b/frontend/public/images/products/suit_shadow_gothic.webp similarity index 100% rename from frontend/public/images/products/8eed3610-7238-4b89-8010-6713268f7cd3.webp rename to frontend/public/images/products/suit_shadow_gothic.webp diff --git a/frontend/public/images/products/suit_western_mystery.webp b/frontend/public/images/products/suit_western_mystery.webp new file mode 100644 index 0000000..4951fdc Binary files /dev/null and b/frontend/public/images/products/suit_western_mystery.webp differ diff --git a/frontend/public/images/products/8621502c-e355-455c-9b74-a7e642a40cfe.webp b/frontend/public/images/products/top_black_elite_trainer.webp similarity index 100% rename from frontend/public/images/products/8621502c-e355-455c-9b74-a7e642a40cfe.webp rename to frontend/public/images/products/top_black_elite_trainer.webp diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e2f06b4..d003a60 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -71,6 +71,8 @@ export const merchantApi = { request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult), updateProductStatus: (id: number, status: MerchantProduct['status']) => request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data), + deleteProduct: (id: number) => + request.delete(`/merchant/products/${id}`).then((r) => r.data.data), orders: (params?: Record) => request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult), manualOrderProducts: () => diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 2138474..33d8454 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -358,19 +358,26 @@ export default function Delivery() { {/* 商品卡片信息 */}
- {order.product?.image ? ( - {order.product.name} - ) : ( -
- 道具 -
- )} + {(() => { + const imgSrc = resolveProductImage({ sku: order.product?.sku, url: order.product?.image }) + if (!imgSrc) { + return ( +
+ 道具 +
+ ) + } + return ( + {order.product?.name + ) + })()}
{order.product?.name || '游戏道具商品'} diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 505f281..889dd38 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -66,6 +66,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [orders, setOrders] = useState>({ list: [], total: 0, page: 1, size: 10 }) const [orderSource, setOrderSource] = useState<'api' | 'manual'>('api') const [orderFilters, setOrderFilters] = useState<{ keyword: string; order_status: string }>({ keyword: '', order_status: '' }) + const [productFilters, setProductFilters] = useState<{ keyword: string; status: string }>({ + keyword: '', + status: '', + }) const [ledger, setLedger] = useState>({ list: [], total: 0, page: 1, size: 10 }) const [wallet, setWallet] = useState(null) const [apiClients, setApiClients] = useState([]) @@ -159,10 +163,19 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer return data }, []) - const loadProducts = useCallback(async (page = products.page, size = products.size) => { - const data = await merchantApi.products({ page, size }) + const loadProducts = useCallback(async ( + page = products.page, + size = products.size, + filters = productFilters, + ) => { + const data = await merchantApi.products({ + page, + size, + keyword: filters.keyword || undefined, + status: filters.status || undefined, + }) setProducts(data) - }, [products.page, products.size]) + }, [productFilters, products.page, products.size]) const loadOrders = useCallback(async ( page = orders.page, @@ -279,6 +292,20 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer .catch((e) => message.error(e instanceof Error ? e.message : '操作失败')) } + const deleteProduct = (record: MerchantProduct) => { + merchantApi.deleteProduct(record.id) + .then(() => { + message.success('商品已删除') + loadProducts() + }) + .catch((e) => message.error(e instanceof Error ? e.message : '删除失败')) + } + + const changeProductFilters = (filters: { keyword: string; status: string }) => { + setProductFilters(filters) + loadProducts(1, products.size, filters).catch((e) => message.error(e instanceof Error ? e.message : '商品加载失败')) + } + const handleWalletFilter = async () => { const values = await walletFilterForm.validateFields() loadWallet(1, ledger.size, merchantPermissions, { @@ -513,7 +540,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const tabItems = [ { key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: ( - + ) }, { key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: ( diff --git a/frontend/src/pages/PlatformProducts.tsx b/frontend/src/pages/PlatformProducts.tsx index d874e68..ca9421e 100644 --- a/frontend/src/pages/PlatformProducts.tsx +++ b/frontend/src/pages/PlatformProducts.tsx @@ -227,7 +227,7 @@ export default function PlatformProducts() { lewan_product_id: lewanProduct.id, name: lewanProduct.name, category: lewanProduct.categoryName || '未分类', - sku: `LEWAN_${lewanProduct.id.slice(0, 8).toUpperCase()}`, + sku: '', price_yuan: unitPriceYuan, delivery_type: lewanProduct.deliveryType || 'LINK_BINDING', }) @@ -269,7 +269,8 @@ export default function PlatformProducts() { content: (
- 将自动按乐玩供货价生成自营商品并配置为乐玩履约模式,同步加入平台自营目录。 + 将按中文名关联到已有英文主商品并挂接乐玩履约;无同名主商品时请用单条导入并填写英文 sku。 + 不再自动创建 LEWAN_ 镜像 sku。
- - + { + const sku = String(value || '').trim() + if (!sku) return + if (/^lewan_/i.test(sku)) { + throw new Error('勿使用 LEWAN_ 前缀,请填写英文主 sku') + } + }, + }, + ]} + > + diff --git a/frontend/src/pages/merchantCenterTabs.tsx b/frontend/src/pages/merchantCenterTabs.tsx index 3aaadfc..becba7f 100644 --- a/frontend/src/pages/merchantCenterTabs.tsx +++ b/frontend/src/pages/merchantCenterTabs.tsx @@ -63,15 +63,29 @@ export function ProductsTab({ loading, products, canManage, + filters, onToggleProduct, + onDeleteProduct, + onFilterChange, onLoadProducts, }: { loading: boolean products: PageResult canManage: boolean + filters: { keyword: string; status: string } onToggleProduct: (record: MerchantProduct) => void + onDeleteProduct: (record: MerchantProduct) => void + onFilterChange: (filters: { keyword: string; status: string }) => void onLoadProducts: (page?: number, size?: number) => void }) { + const [keyword, setKeyword] = useState(filters.keyword) + + useEffect(() => { + setKeyword(filters.keyword) + }, [filters.keyword]) + + const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() }) + const productColumns: ColumnsType = [ { title: '商户 SKU', @@ -128,19 +142,34 @@ export function ProductsTab({ { title: '操作', key: 'action', - width: 90, + width: 140, fixed: 'right', render: (_, record) => canManage ? ( - onToggleProduct(record)} - > - - + + onToggleProduct(record)} + > + + + {record.status === 'inactive' && ( + onDeleteProduct(record)} + > + + + )} + ) : '-', }, ] @@ -148,15 +177,40 @@ export function ProductsTab({ return ( -
-
- 商品上架与售卖管理 - - 商户可自主上架/下架商品;下架后不可再创建该商品订单。 - -
+
+ 商品上架与售卖管理 + + 可按状态与关键词筛选;已下架商品可删除。 +
+ + setKeyword(e.target.value)} + onSearch={submitKeyword} + style={{ width: 240 }} + /> +