refactor(catalog): 废弃 LEWAN_ 镜像,统一英文 SKU 与积分价,商品图按 SKU 缓存
对外目录只保留英文主 sku;迁移硬删镜像并校准 1元=100积分;发货页/凭证优先使用 local {sku}.webp。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user