refactor(catalog): 废弃 LEWAN_ 镜像,统一英文 SKU 与积分价,商品图按 SKU 缓存

对外目录只保留英文主 sku;迁移硬删镜像并校准 1元=100积分;发货页/凭证优先使用 local {sku}.webp。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-14 20:00:26 +08:00
co-authored by Cursor
parent bf9614569e
commit df1a170d44
57 changed files with 1052 additions and 171 deletions
+6
View File
@@ -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"
+309
View File
@@ -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)
}
@@ -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;
+51 -36
View File
@@ -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 == "" {
+20 -2
View File
@@ -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
+7 -1
View File
@@ -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)
+1
View File
@@ -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)
+69 -35
View File
@@ -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,
@@ -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_")
}
@@ -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)
}
}
}
+57 -1
View File
@@ -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
+2 -37
View File
@@ -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
商品目录已合并到 docs/商品目录.md(含中英文 SKU、价格、履约与图片缓存约定)。
本文件仅保留历史速查,请以商品目录为准。
+120
View File
@@ -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_*`。
+7
View File
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+2
View File
@@ -71,6 +71,8 @@ export const merchantApi = {
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
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<string, unknown>) =>
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
manualOrderProducts: () =>
+20 -13
View File
@@ -358,19 +358,26 @@ export default function Delivery() {
{/* 商品卡片信息 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 20, paddingBottom: 16, borderBottom: '1px solid #e2e8f0', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1, minWidth: 220 }}>
{order.product?.image ? (
<Image
src={resolveProductImage(order.product.image)}
width={72}
height={72}
alt={order.product.name}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
/>
) : (
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
道具
</div>
)}
{(() => {
const imgSrc = resolveProductImage({ sku: order.product?.sku, url: order.product?.image })
if (!imgSrc) {
return (
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
道具
</div>
)
}
return (
<Image
src={imgSrc}
width={72}
height={72}
alt={order.product?.name || '商品'}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='72' height='72'%3E%3Crect fill='%23e2e8f0' width='72' height='72'/%3E%3C/svg%3E"
/>
)
})()}
<div>
<div style={{ fontSize: 18, fontWeight: 600, color: '#0f172a', marginBottom: 4 }}>
{order.product?.name || '游戏道具商品'}
+40 -4
View File
@@ -66,6 +66,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ 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<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
const [wallet, setWallet] = useState<WalletAccount | null>(null)
const [apiClients, setApiClients] = useState<ApiClient[]>([])
@@ -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: (
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
<ProductsTab
loading={loading}
products={products}
canManage={canManage}
filters={productFilters}
onToggleProduct={openProductToggle}
onDeleteProduct={deleteProduct}
onFilterChange={changeProductFilters}
onLoadProducts={loadProducts}
/>
) },
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} filters={orderFilters} canManage={canOrderManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onFilterChange={changeOrderFilters} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} onCancelOrder={cancelOrder} />
+22 -5
View File
@@ -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: (
<div>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
将自动按乐玩供货价生成自营商品并配置为乐玩履约模式,同步加入平台自营目录。
将按中文名关联到已有英文主商品并挂接乐玩履约;无同名主商品时请用单条导入并填写英文 sku。
不再自动创建 LEWAN_ 镜像 sku。
</Paragraph>
<div
style={{
@@ -317,7 +318,8 @@ export default function PlatformProducts() {
lewan_product_id: p.id,
name: p.name,
category: p.categoryName || '未分类',
sku: `LEWAN_${p.id.slice(0, 8).toUpperCase()}`,
// 留空:后端按中文名关联已有英文主商品;无匹配则跳过(见 BatchSync 错误汇总)
sku: '',
price: Number.isFinite(unitPriceFen) ? Math.round(unitPriceFen) : 1000,
delivery_type: p.deliveryType || 'LINK_BINDING',
}
@@ -1045,8 +1047,23 @@ export default function PlatformProducts() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sku" label="自定义平台 SKU" rules={[{ required: true }]}>
<Input placeholder="大写英文或拼音" />
<Form.Item
name="sku"
label="英文主 SKU"
extra="留空则按中文名关联已有商品;新建请填 suit_xxx / pack_xxx,勿用 LEWAN_ 前缀"
rules={[
{
validator: async (_, value) => {
const sku = String(value || '').trim()
if (!sku) return
if (/^lewan_/i.test(sku)) {
throw new Error('勿使用 LEWAN_ 前缀,请填写英文主 sku')
}
},
},
]}
>
<Input placeholder="留空=按名称关联;或 suit_xxx / pack_xxx" />
</Form.Item>
</Col>
</Row>
+72 -18
View File
@@ -63,15 +63,29 @@ export function ProductsTab({
loading,
products,
canManage,
filters,
onToggleProduct,
onDeleteProduct,
onFilterChange,
onLoadProducts,
}: {
loading: boolean
products: PageResult<MerchantProduct>
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<MerchantProduct> = [
{
title: '商户 SKU',
@@ -128,19 +142,34 @@ export function ProductsTab({
{
title: '操作',
key: 'action',
width: 90,
width: 140,
fixed: 'right',
render: (_, record) => canManage ? (
<Popconfirm
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
okText="确认"
onConfirm={() => onToggleProduct(record)}
>
<Button type="link" size="small">
{record.status === 'active' ? '下架' : '上架'}
</Button>
</Popconfirm>
<Space size={0}>
<Popconfirm
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
okText="确认"
onConfirm={() => onToggleProduct(record)}
>
<Button type="link" size="small">
{record.status === 'active' ? '下架' : '上架'}
</Button>
</Popconfirm>
{record.status === 'inactive' && (
<Popconfirm
title="删除该商品?"
description="仅从商品列表移除(软删除),历史订单不受影响。删除后不可在此恢复。"
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => onDeleteProduct(record)}
>
<Button type="link" size="small" danger>
删除
</Button>
</Popconfirm>
)}
</Space>
) : '-',
},
]
@@ -148,15 +177,40 @@ export function ProductsTab({
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
商户可自主上架/下架商品;下架后不可再创建该商品订单。
</Typography.Text>
</div>
<div>
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
可按状态与关键词筛选;已下架商品可删除。
</Typography.Text>
</div>
</Card>
<Space wrap>
<Input.Search
allowClear
placeholder="SKU / 商品名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={submitKeyword}
style={{ width: 240 }}
/>
<Select
allowClear
placeholder="状态"
value={filters.status || undefined}
style={{ width: 120 }}
options={[
{ value: 'active', label: '已上架' },
{ value: 'inactive', label: '已下架' },
]}
onChange={(status) => onFilterChange({ ...filters, status: status || '' })}
/>
<Button onClick={() => {
setKeyword('')
onFilterChange({ keyword: '', status: '' })
}}>
重置
</Button>
</Space>
<Table
rowKey="id"
loading={loading}
+47 -15
View File
@@ -1,24 +1,56 @@
/**
* 解析商品图片地址:
* 1. 本地相对路径(以 / 开头)直接返回;
* 2. 乐玩/官方商品 UUID 地址,优先使用已下载至本地的图片缓存;
* 3. 其他外链图片走后端持久化缓存代理 (/api/delivery/v1/image-proxy),杜绝跨域拦截与画布污染。
* 商品图统一解析:优先英文 SKU 本地缓存。
*
* 约定目录:frontend/public/images/products/{sku}.webp
* 同步:cd backend && go run ./cmd/sync_product_images
*/
export function resolveProductImage(url?: string | null): string {
if (!url) return ''
const trimmed = url.trim()
if (!trimmed) return ''
if (trimmed.startsWith('/')) {
return trimmed
export type ProductImageInput = {
sku?: string | null
/** 上游缩略图 URL(乐玩/DLC);仅在无本地 SKU 图时作回退 */
url?: string | null
}
function trim(value?: string | null): string {
return String(value || '').trim()
}
/** 按商户英文 SKU 取本地缓存图。 */
export function resolveProductImageBySku(sku?: string | null): string {
const normalized = trim(sku)
if (!normalized) return ''
return `/images/products/${normalized}.webp`
}
/**
* 解析展示用商品图:
* 1. 有 sku → `/images/products/{sku}.webp`
* 2. 否则从 url 提取乐玩 UUID → `/images/products/{uuid}.webp`(历史缓存)
* 3. 其它外链 → image-proxy(运行时落盘到 data/uploads/products,仅兜底)
*/
export function resolveProductImage(input?: string | ProductImageInput | null): string {
if (input == null) return ''
let sku = ''
let url = ''
if (typeof input === 'string') {
url = trim(input)
} else {
sku = trim(input.sku)
url = trim(input.url)
}
// 匹配乐玩官方商品 ID:/products/<uuid>/...
const uuidMatch = trimmed.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/)
if (uuidMatch && uuidMatch[1]) {
if (sku) {
return resolveProductImageBySku(sku)
}
if (!url) return ''
if (url.startsWith('/')) return url
const uuidMatch = url.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/)
if (uuidMatch?.[1]) {
return `/images/products/${uuidMatch[1]}.webp`
}
// 其它外部地址统一经由代理及本地磁盘缓存返回
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(trimmed)}`
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(url)}`
}
+4 -4
View File
@@ -89,12 +89,12 @@ export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise
ctx.lineWidth = 1
strokeRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16)
// 尝试加载商品图片(优先命中本地下载缓存)
// 尝试加载商品图片(优先 SKU 本地缓存)
let productImgLoaded = false
if (order.product?.image) {
const productImgURL = resolveProductImage({ sku: order.product?.sku, url: order.product?.image })
if (productImgURL) {
try {
const imgURL = resolveProductImage(order.product.image)
const img = await loadImage(imgURL)
const img = await loadImage(productImgURL)
const imgSize = 146
const imgX = cardX + 64
const imgY = prodCardY + 32