update
This commit is contained in:
147
internal/ingest/client.go
Normal file
147
internal/ingest/client.go
Normal 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
81
internal/ingest/hashid.go
Normal 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
27
internal/ingest/hmac.go
Normal 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)
|
||||
}
|
||||
77
internal/ingest/ingest_test.go
Normal file
77
internal/ingest/ingest_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
74
internal/ingest/payload.go
Normal file
74
internal/ingest/payload.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user