This commit is contained in:
toom1996
2026-09-20 00:44:47 +08:00
parent d8f667e391
commit fde57984e7
16 changed files with 3908 additions and 123 deletions

147
internal/ingest/client.go Normal file
View File

@ -0,0 +1,147 @@
package ingest
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
// Client 向后台 ingest 接口发送 HMAC 签名上报的客户端。
type Client struct {
Endpoint string
Secret string
HTTPClient *http.Client
}
// NewClient 创建上报客户端。endpoint 形如 http://host:8092/admin/internal/ingest。
func NewClient(endpoint, secret string) *Client {
return &Client{
Endpoint: endpoint,
Secret: secret,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// Submit 把一条走秀上报签名后 POST 到后台,返回 job_id(202 入队即返回)。
// 失败返回 error(网络/校验/服务端拒绝),调用方应记日志并继续,不中断整轮抓取。
func (c *Client) Submit(ctx context.Context, p RunwayIngest) (uint32, error) {
if c == nil || c.Endpoint == "" {
return 0, fmt.Errorf("ingest client 未配置")
}
body, err := json.Marshal(p)
if err != nil {
return 0, err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce, err := newNonce()
if err != nil {
return 0, err
}
sig := Sign(c.Secret, ts, nonce, string(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Signature", sig)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusAccepted {
return 0, fmt.Errorf("ingest http %d: %s", resp.StatusCode, string(raw))
}
var out struct {
JobID uint32 `json:"job_id"`
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &out); err != nil {
// 202 但响应结构非预期:任务已入队,记日志后当作成功
log.Printf("[Ingest] 响应解析失败但已被接受: %s", string(raw))
return 0, nil
}
return out.JobID, nil
}
// newNonce 生成 16 字节随机十六进制串,作为一次性 X-Nonce 防重放。
func newNonce() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// CrawlBrand 后端抓取任务接口返回的单个品牌(brand_uid 已编码,name 为英文名)。
type CrawlBrand struct {
BrandUID string `json:"brand_uid"`
Name string `json:"name"`
}
// crawlBrandsURL 由 ingest endpoint 推导抓取任务接口地址(把末尾 /ingest 换成 /crawl/brands)。
func (c *Client) crawlBrandsURL() string {
base := c.Endpoint
if i := strings.LastIndex(base, "/ingest"); i != -1 {
base = base[:i]
}
return strings.TrimRight(base, "/") + "/crawl/brands"
}
// GetCrawlBrands 拉取爬虫要抓取的品牌任务列表(HMAC 签名 GET,复用与上报相同的验签算法)。
// brandID>0 时只取该品牌(对应 --brand 单品牌调试)。返回 brand_uid(直接上送 ingest)+ 英文名。
func (c *Client) GetCrawlBrands(ctx context.Context, brandID uint) ([]CrawlBrand, error) {
if c == nil || c.Endpoint == "" {
return nil, fmt.Errorf("ingest client 未配置")
}
url := c.crawlBrandsURL()
if brandID > 0 {
url += fmt.Sprintf("?brand=%d", brandID)
}
// GET 无请求体,签名串的 body 部分为空(与后台 IngestAuth 验签一致)。
ts := NowTS()
nonce, err := newNonce()
if err != nil {
return nil, err
}
sig := Sign(c.Secret, ts, nonce, "")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Signature", sig)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crawl brands http %d: %s", resp.StatusCode, string(raw))
}
var out struct {
Brands []CrawlBrand `json:"brands"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("crawl brands 解析失败: %w", err)
}
return out.Brands, nil
}

81
internal/ingest/hashid.go Normal file
View File

@ -0,0 +1,81 @@
package ingest
import (
"crypto/sha256"
"encoding/binary"
"strings"
)
// 以下 Feistel + base62 编码与后台 internal/pkg/hashid 完全一致,
// 仅实现编码侧(爬虫只需把品牌数字主键编码为 brand_uid 上送,无需解码)。
// 盐值必须 == 后台 HASHID_SECRET,否则生成的 brand_uid 后台解码不出正确主键。
const (
hidAlphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
hidBase = 62
hidMinLen = 8
)
var hidKeys [4]uint32
// InitHashID 用与后台 HASHID_SECRET 相同的盐值初始化编码密钥。
// secret 为空时回落后台内置默认盐(仅防顺序枚举,不安全)。
func InitHashID(secret string) {
if secret == "" {
secret = "fashion-archive-default-salt-change-me"
}
h := sha256.Sum256([]byte(secret))
for i := 0; i < 4; i++ {
hidKeys[i] = binary.BigEndian.Uint32(h[i*4 : i*4+4])
}
}
// hidFeistel 32-bit 平衡 Feistel 网络,encrypt=true 加密。
func hidFeistel(v uint32, encrypt bool) uint32 {
const rounds = 8
l, r := uint16(v>>16), uint16(v&0xffff)
for i := 0; i < rounds; i++ {
idx := i
if !encrypt {
idx = rounds - 1 - i
}
round := func(h uint16) uint16 {
f := uint32(h)*0x9E3779B1 + hidKeys[idx%4]
return uint16((f ^ (f >> 16)) & 0xffff)
}
if encrypt {
nl, nr := r, uint16(l)^round(r)
l, r = nl, nr
} else {
nl, nr := uint16(l)^round(l), l
l, r = nl, nr
}
}
return uint32(l)<<16 | uint32(r)
}
func encodeNum(n uint32) string {
x := hidFeistel(n, true)
var sb strings.Builder
for x > 0 {
sb.WriteByte(hidAlphabet[x%hidBase])
x /= hidBase
}
if sb.Len() == 0 {
sb.WriteByte(hidAlphabet[0])
}
runes := []rune(sb.String())
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
out := string(runes)
if len(out) < hidMinLen {
out = strings.Repeat("0", hidMinLen-len(out)) + out
}
return out
}
// EncodeBrand 把品牌数字主键编码为 hashid 串(与后台 brand_uid 同算法)。
func EncodeBrand(id uint32) string {
return encodeNum(id)
}

27
internal/ingest/hmac.go Normal file
View File

@ -0,0 +1,27 @@
package ingest
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
// Sign 与后台 internal/pkg/hmac.Sign 完全一致:
// HMAC_SHA256(secret, timestamp + "." + nonce + "." + body) 的十六进制串。
// bodyRaw 必须是请求体原始字节,确保与后台收到的字节严格一致。
func Sign(secret, timestamp, nonce, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write([]byte(nonce))
mac.Write([]byte("."))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}
// NowTS 返回当前 Unix 秒字符串(X-Timestamp 用)。
func NowTS() string {
return strconv.FormatInt(time.Now().Unix(), 10)
}

View File

@ -0,0 +1,77 @@
package ingest
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"testing"
"time"
)
// verify 是后台 internal/pkg/hmac.Verify 的逐字镜像,仅用于本测试验证「爬虫签名能被
// 后台同算法校验通过」这条契约——算法必须与后台保持一致。
func verify(secret, body, sig, ts, nonce string, ttl int) bool {
if sig == "" || ts == "" || nonce == "" {
return false
}
if ttl <= 0 {
ttl = 300
}
tsN, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if diff := now - tsN; diff > int64(ttl) || diff < -int64(ttl) {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte("."))
mac.Write([]byte(nonce))
mac.Write([]byte("."))
mac.Write([]byte(body))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
func TestSignVerifyRoundtrip(t *testing.T) {
secret := "test-secret"
body := `{"brand_uid":"001DESke","title_en":"Fall 2024 Ready-to-Wear"}`
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "abc123nonce"
sig := Sign(secret, ts, nonce, body)
if !verify(secret, body, sig, ts, nonce, 300) {
t.Fatal("后台应当校验通过爬虫签名,但未通过")
}
// 篡改 body
if verify(secret, body+"x", sig, ts, nonce, 300) {
t.Fatal("body 被篡改后不应通过校验")
}
// 错误密钥
if verify("wrong", body, sig, ts, nonce, 300) {
t.Fatal("错误密钥不应通过校验")
}
// 过期时间戳
old := strconv.FormatInt(time.Now().Unix()-600, 10)
if verify(secret, body, Sign(secret, old, nonce, body), old, nonce, 300) {
t.Fatal("过期时间戳不应通过校验")
}
}
func TestEncodeBrandNonSequential(t *testing.T) {
// 相邻主键编码结果应互不相关(防顺序枚举),且同值稳定
a := EncodeBrand(108)
b := EncodeBrand(109)
if a == b {
t.Fatal("相邻 id 编码必须不同")
}
if EncodeBrand(108) != a {
t.Fatal("编码必须稳定(同输入同输出)")
}
if len(a) < 8 {
t.Fatalf("编码长度不应短于 minLen=8,实际 %d", len(a))
}
}

View File

@ -0,0 +1,74 @@
// Package ingest 爬虫侧入库客户端:把走秀元数据以 HMAC 签名 POST 到后台
// ingest 接口(:8092/admin/internal/ingest),替代原先直写 brand_runway 表。
//
// 与后台 internal/pkg/hmac、internal/pkg/hashid 保持同算法,使爬虫模块完全解耦
// 于后端代码(不再 import 后端 internal 包),双方仅通过「签名 + JSON 载荷」契约通信。
package ingest
import "strings"
// 入库类型(与后台 dto 保持一致)。
const (
KindRunway = "runway" // 走秀(默认,需 brand_uid)
KindStreet = "street" // 街拍(无品牌,需 city/title)
)
// RunwayIngest 与后台 dto.RunwayIngest 的 JSON 字段一一对应。
// 爬虫只传元数据 + 图片 URL 列表,图下载/OSS 由后台 worker 完成。
type RunwayIngest struct {
Kind string `json:"kind"` // runway | street,缺省 runway
BrandUID string `json:"brand_uid"` // 品牌 hashid(EncodeBrand 生成),runway 必填
TitleEn string `json:"title_en"` // 英文标题(优先;street 作为单标题)
TitleCn string `json:"title_cn"` // 中文标题(可空)
DescriptionEn string `json:"description_en"` // 英文描述(可空)
DescriptionCn string `json:"description_cn"` // 中文描述(可空)
Year uint16 `json:"year"` // 年份
Season string `json:"season"` // spring / fall
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
City string `json:"city"` // 地区/城市(street 用)
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
// 结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图)。
Looks []RunwayLook `json:"looks,omitempty"`
}
// RunwayLook 一场秀中的一个 look:1 张主图 + 0..N 张细节图。
type RunwayLook struct {
Main string `json:"main"` // 主图(look)原始 URL
Details []string `json:"details"` // 细节图原始 URL 列表
}
// ParseCollection 从 Vogue 标题推导 collection_type 与 season,供后台补 season_code。
//
// "Fall 2024 Ready-to-Wear" -> ("rtw", "fall")
// "Spring 2025 Menswear" -> ("menswear", "spring")
// "Resort 2024" -> ("resort", "")
// "Pre-Fall 2024" -> ("pre_fall", "")
// "Couture Fall 2024" -> ("couture", "fall")
func ParseCollection(title string) (collectionType, season string) {
t := strings.ToLower(title)
switch {
case strings.Contains(t, "resort"):
collectionType = "resort"
case strings.Contains(t, "pre-fall"), strings.Contains(t, "pre fall"):
collectionType = "pre_fall"
case strings.Contains(t, "couture"):
collectionType = "couture"
case strings.Contains(t, "menswear"), strings.Contains(t, "men's"):
collectionType = "menswear"
case strings.Contains(t, "ready"):
collectionType = "rtw"
default:
// 仅出现 spring/fall 而无明确品类词时,回落为 rtw(绝大多数季场秀)
if strings.Contains(t, "spring") || strings.Contains(t, "fall") {
collectionType = "rtw"
}
}
switch {
case strings.Contains(t, "spring"):
season = "spring"
case strings.Contains(t, "fall"):
season = "fall"
}
return collectionType, season
}