package repository import ( "database/sql" "encoding/json" "errors" "fmt" "strings" "cmautobuy/admin/model" "cmautobuy/admin/spec" ) // UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。 // // `[必须]` DO UPDATE SET 里绝不允许出现 pdd_goods_url / pdd_goods_id。 // 蝦皮报表里没有这两列,写进去就是写空值——操作员可能攒了几周的 // PDD 链接,导入一次就被静默洗掉,而且不报错,等到建采购任务时 // 才会发现,那时已经找不回来了。见工单 #38、admin/AGENTS.md // 「Excel 导入只做 upsert,绝不允许先清空再导入」。 func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode string) error { if goodsID == "" { return fmt.Errorf("goods_id 不能为空") } now := model.NowISO() _, err := q.Exec(` INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, source, created_at, updated_at) VALUES (?, ?, ?, ?, 'report', ?, ?) ON DUPLICATE KEY UPDATE title = VALUES(title), shopee_status = VALUES(shopee_status), main_sku_code = VALUES(main_sku_code), source = 'report', updated_at = VALUES(updated_at)`, // 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里, // 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接), // 已存在时它们完全不受这条语句影响。 goodsID, title, shopeeStatus, mainSKUCode, now, now) if err != nil { return fmt.Errorf("写入蝦皮商品 %s 失败: %w", goodsID, err) } return nil } // UpsertShopeeSKU 写入或更新一条蝦皮 SKU 行。 // // `[必须]` is_manual 不在 DO UPDATE SET 里,已存在的行导入时保持原值不变。 // 人工新增的 SKU(is_manual=1)就是靠这个不被导入覆盖成 0—— // 本工单不做手动新增功能,但这个口子现在就要留好。 func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice string, parseOK bool, skuCode string) error { if skuID == "" { return fmt.Errorf("sku_id 不能为空") } if goodsID == "" { return fmt.Errorf("goods_id 不能为空") } parseOKInt := 0 if parseOK { parseOKInt = 1 } now := model.NowISO() specKey, err := spec.SpecKey(specRaw) if err != nil { return fmt.Errorf("规格原文无效: %w", err) } fieldSources, fieldTimes := map[string]string{}, map[string]string{} for name, value := range map[string]string{"color": color, "size": size, "advice": advice, "sku_code": skuCode} { if strings.TrimSpace(value) != "" { fieldSources[name], fieldTimes[name] = "report", now } } fieldSourcesJSON, _ := json.Marshal(fieldSources) fieldTimesJSON, _ := json.Marshal(fieldTimes) _, err = q.Exec(` INSERT INTO shopee_skus (sku_id, shopee_sku_id, goods_id, spec_raw, spec_key, color, size, advice, parse_ok, sku_code, is_manual, source, field_sources, field_observed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'report', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE goods_id = CASE WHEN is_manual=1 THEN goods_id ELSE VALUES(goods_id) END, color = CASE WHEN is_manual=1 THEN color ELSE VALUES(color) END, size = CASE WHEN is_manual=1 THEN size ELSE VALUES(size) END, advice = CASE WHEN is_manual=1 THEN advice ELSE VALUES(advice) END, parse_ok = CASE WHEN is_manual=1 THEN parse_ok ELSE VALUES(parse_ok) END, sku_code = CASE WHEN is_manual=1 THEN sku_code ELSE VALUES(sku_code) END, field_sources = CASE WHEN is_manual=1 THEN field_sources ELSE VALUES(field_sources) END, field_observed_at = CASE WHEN is_manual=1 THEN field_observed_at ELSE VALUES(field_observed_at) END, updated_at = CASE WHEN is_manual=1 THEN updated_at ELSE VALUES(updated_at) END`, // 人工行的业务字段和更新时间均由 CASE 保留。 skuID, skuID, goodsID, specRaw, specKey, color, size, advice, parseOKInt, skuCode, string(fieldSourcesJSON), string(fieldTimesJSON), now, now) if err != nil { return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err) } return nil } // ShopeeProductRow 是列表页要的一行:**商品级**聚合数据,联查出采集状态。 // // `pdd_goods_url` / `pdd_goods_id` 挂在 shopee_products 上,是商品级字段—— // 一行对应一个商品,不是一个 SKU,见工单 #41。 // // 颜色数 / 尺码数只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41): // 把 parse_ok = 0 的空颜色/空尺码算进 DISTINCT 会多出一个"空值"选项。 // SKUCount 是这个商品报表里出现过的全部 SKU 数(不管解析成功与否)—— // 蝦皮报表只含有销售成绩的 SKU,这个数天然会小于"颜色数 × 尺码数", // 加这一列就是为了防止操作员把两者乘出来的数当成实际规格数(见 #41)。 // PendingCount 是 parse_ok = 0 的 SKU 数,即"待补",> 0 时列表整行标黄。 // // 采集状态挂在 pdd_products 上,不在蝦皮这边,见 // docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为 // NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。 type ShopeeProductRow struct { model.ShopeeProduct ColorCount int SizeCount int SKUCount int PendingCount int CollectStatus sql.NullString CollectMsg sql.NullString } // ShopeeFilter 是蝦皮商品列表页支持的筛选条件,两项都可以为空。 // // Status 取值见 §「状态筛选的四个取值」(工单 #43): // // "" 不筛选 // "pending_spec" 有 parse_ok = 0 的 SKU(待补规格) // "no_link" pdd_goods_url 为空(未填 PDD 链接) // "has_link" pdd_goods_url 非空(已填链接) // // 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底, // 这里不做校验——repository 只管拼 SQL。 type ShopeeFilter struct { Keyword string Status string } // shopeeFilterClause 把关键字和状态筛选拼成 WHERE 子句,供 ListShopeeProducts // 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致, // 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。 // // 「待补规格」用 EXISTS 子查询,不用 JOIN + DISTINCT:一个商品有多个失败 // SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理 // (见工单 #43)。 func shopeeFilterClause(filter ShopeeFilter) (string, []any) { var clauses []string var args []any if kw := strings.TrimSpace(filter.Keyword); kw != "" { like := "%" + escapeLike(kw) + "%" clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '!' OR sp.title LIKE ? ESCAPE '!')`) args = append(args, like, like) } switch filter.Status { case "pending_spec": clauses = append(clauses, `EXISTS (SELECT 1 FROM shopee_skus s WHERE s.goods_id = sp.goods_id AND s.parse_ok = 0)`) case "no_link": clauses = append(clauses, `(sp.pdd_goods_url IS NULL OR sp.pdd_goods_url = '')`) case "has_link": clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`) } if len(clauses) == 0 { return "", args } return " WHERE " + strings.Join(clauses, " AND "), args } // ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。 // // keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**—— // 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。 // // `[必须]` 分页用 LIMIT/OFFSET 在数据库里做,不把全量查出来在 Go 里切片 // (工单 #43:这正是改之前 HTML 一次 3.4MB 的成因)。 func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) { where, args := shopeeFilterClause(filter) sqlText := ` SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.source, sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at, COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count, COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count, COUNT(sk.sku_id) AS sku_count, COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count, pp.collect_status, pp.collect_msg FROM shopee_products sp LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` + where + ` GROUP BY sp.goods_id ORDER BY sp.goods_id LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := q.Query(sqlText, args...) if err != nil { return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err) } defer rows.Close() var list []ShopeeProductRow for rows.Next() { var r ShopeeProductRow var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString if err := rows.Scan( &r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source, &pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt, &r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount, &r.CollectStatus, &r.CollectMsg, ); err != nil { return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err) } r.Title = title.String r.ShopeeStatus = shopeeStatus.String r.MainSKUCode = mainSKUCode.String r.Source = source.String r.PddGoodsURL = pddGoodsURL.String r.PddGoodsID = pddGoodsID.String list = append(list, r) } return list, rows.Err() } // CountShopeeProductsFiltered 统计当前筛选条件下的商品总数。 // // `[必须]` 用和 ListShopeeProducts **完全相同**的筛选条件(shopeeFilterClause)—— // 底部状态条和分页页数都靠它,写成两份 WHERE 迟早有一天会忘了同步改, // 页码就会算错而且没人发现(#19 已经踩过一次,见工单 #43)。 func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) { where, args := shopeeFilterClause(filter) sqlText := `SELECT COUNT(*) FROM shopee_products sp` + where var n int if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil { return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err) } return n, nil } // GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。 // 弹窗组装商品信息时用。查不到返回 (nil, nil)。 func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) { var p model.ShopeeProduct var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString err := q.QueryRow(` SELECT goods_id, title, shopee_status, main_sku_code, source, pdd_goods_url, pdd_goods_id, created_at, updated_at FROM shopee_products WHERE goods_id = ?`, goodsID).Scan( &p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source, &pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err) } p.Title = title.String p.ShopeeStatus = shopeeStatus.String p.MainSKUCode = mainSKUCode.String p.Source = source.String p.PddGoodsURL = pddGoodsURL.String p.PddGoodsID = pddGoodsID.String return &p, nil } // UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。 // 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。 func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error { result, err := q.Exec(` UPDATE shopee_products SET pdd_goods_id = ?, pdd_goods_url = ?, updated_at = ? WHERE goods_id = ?`, pddGoodsID, pddURL, model.NowISO(), shopeeGoodsID) if err != nil { return fmt.Errorf("更新蝦皮商品 %s 的 PDD 关联失败: %w", shopeeGoodsID, err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("读取蝦皮商品 %s 的更新结果失败: %w", shopeeGoodsID, err) } if n != 1 { return fmt.Errorf("蝦皮商品 %s 不存在", shopeeGoodsID) } return nil } // GetShopeeCollectStatus 查一个 PDD 商品的采集状态和失败原因。 // pddGoodsID 为空(蝦皮商品还没填链接)或查不到对应的 pdd_products 行时, // 返回的两个 sql.NullString 都是 Valid=false,调用方按"未填链接"/"PDD 商品缺失"处理。 func GetShopeeCollectStatus(q Execer, pddGoodsID string) (status, msg sql.NullString, err error) { if pddGoodsID == "" { return status, msg, nil } err = q.QueryRow(` SELECT collect_status, collect_msg FROM pdd_products WHERE goods_id = ? AND deleted_at IS NULL`, pddGoodsID).Scan(&status, &msg) if errors.Is(err, sql.ErrNoRows) { return sql.NullString{}, sql.NullString{}, nil } if err != nil { return status, msg, fmt.Errorf("查询 PDD 商品 %s 采集状态失败: %w", pddGoodsID, err) } return status, msg, nil } // ListShopeeSKUsByGoodsID 查一个商品的完整规格表,**含解析失败的行**。 // // 弹窗要显示全部规格(不管待补与否),所以这里不按 parse_ok 过滤, // 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。 func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) { rows, err := q.Query(` SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code, is_manual, created_at, updated_at FROM shopee_skus WHERE goods_id = ? ORDER BY sku_id`, goodsID) if err != nil { return nil, fmt.Errorf("查询蝦皮商品 %s 的规格失败: %w", goodsID, err) } defer rows.Close() var list []model.ShopeeSKU for rows.Next() { var sk model.ShopeeSKU var color, size, advice, skuCode sql.NullString var parseOK, isManual int if err := rows.Scan( &sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice, &parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt, ); err != nil { return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, err) } sk.Color = color.String sk.Size = size.String sk.Advice = advice.String sk.SKUCode = skuCode.String sk.ParseOK = parseOK != 0 sk.IsManual = isManual != 0 list = append(list, sk) } return list, rows.Err() } // GetShopeeSKUByID 按规格 ID 查询一条蝦皮 SKU。查不到返回 (nil, nil)。 func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) { var sk model.ShopeeSKU var color, size, advice, skuCode sql.NullString var parseOK, isManual int err := q.QueryRow(` SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code, is_manual, created_at, updated_at FROM shopee_skus WHERE sku_id = ?`, skuID).Scan( &sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice, &parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("查询蝦皮规格 %s 失败: %w", skuID, err) } sk.Color = color.String sk.Size = size.String sk.Advice = advice.String sk.SKUCode = skuCode.String sk.ParseOK = parseOK != 0 sk.IsManual = isManual != 0 return &sk, nil } // CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。 func CountShopeeProducts(q Execer) (int, error) { var n int if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products`).Scan(&n); err != nil { return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err) } return n, nil }