update backend
This commit is contained in:
@ -4,7 +4,7 @@
|
||||
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
|
||||
//
|
||||
// 数据库已迁移至 PostgreSQL(见 scripts/pgvector 的本地 Docker 环境)。
|
||||
// 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的 MySQL 迁移脚本。
|
||||
// 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的旧迁移脚本。
|
||||
package database
|
||||
|
||||
import (
|
||||
@ -52,7 +52,7 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
|
||||
// AutoMigrate 幂等创建 / 更新全部表结构。
|
||||
//
|
||||
// 表结构由 GORM 模型定义托管(替代原 scripts/sql、db/migrations 下的手写 MySQL 脚本)。
|
||||
// 表结构由 GORM 模型定义托管(替代原 scripts/sql、db/migrations 下的手写旧迁移脚本)。
|
||||
// 仅在缺少列/索引时增量变更,已存在的表不会被重建;对存量大表(如 brand_runway_images)
|
||||
// 的加列操作可能短暂加锁,属一次性开销。
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
@ -60,6 +60,92 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector").Error; err != nil {
|
||||
return fmt.Errorf("create extension vector: %w", err)
|
||||
}
|
||||
// 兼容旧库:phash 早期以 bigint 存储 64-bit dHash 指纹(uint64),现模型改为 vector(64)。
|
||||
// pgvector 不支持 bigint→vector 直接转换,需逐位拆成 0/1 向量(位 i → 第 i 维,与 ToVectorBits 一致,
|
||||
// 保证旧数据与新写入的向量在同一语义空间可比)。已为 vector/text 类型的列跳过,幂等可重复执行。
|
||||
const migratePhashSQL = `
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
dt text;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('brand_runway_images','brand_runway_draft_images','street_snap_images','street_snap_draft_images')
|
||||
LOOP
|
||||
SELECT data_type INTO dt
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = t AND column_name = 'phash';
|
||||
|
||||
IF dt = 'bigint' THEN
|
||||
-- bigint 即 64-bit dHash 指纹;pgvector 不支持 bigint->vector 直接转换,且 ALTER ... USING 的
|
||||
-- transform 表达式不允许子查询(0A000)。改用纯表达式:bigint::bit(64) 取 64 位模式(MSB 在左),
|
||||
-- reverse 成 LSB 在左(与 phash.ToVectorBits 维度顺序一致:第 i 维 = bit i),插逗号后 text::vector(64)。
|
||||
-- NULL 自然透传。
|
||||
EXECUTE format($e$
|
||||
ALTER TABLE %I ALTER COLUMN phash TYPE vector(64)
|
||||
USING (
|
||||
('[' ||
|
||||
rtrim(regexp_replace(reverse(phash::bit(64)::text), '(.)', '\1,', 'g'), ',') ||
|
||||
']')::vector(64)
|
||||
)
|
||||
$e$, t);
|
||||
RAISE NOTICE 'migrated phash (bigint -> vector) on %', t;
|
||||
ELSIF dt IN ('character varying','text') THEN
|
||||
EXECUTE format($e$
|
||||
ALTER TABLE %I ALTER COLUMN phash TYPE vector(64) USING phash::vector(64)
|
||||
$e$, t);
|
||||
RAISE NOTICE 'migrated phash (text -> vector) on %', t;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;`
|
||||
if err := db.Exec(migratePhashSQL).Error; err != nil {
|
||||
return fmt.Errorf("migrate phash column: %w", err)
|
||||
}
|
||||
// 回填历史 NULL:迁移 012 给 4 张 image 表的 is_duplicate/dup_of 声明了 not null,
|
||||
// 但存量行可能是 NULL;GORM 直接 SET NOT NULL 会失败(23502)。先按默认值回填再交给 GORM(幂等)。
|
||||
// 注意:各表 schema 演化路径不同,可能缺其中某列——故按列存在性动态拼 SQL,只回填真实存在的列;
|
||||
// 缺失的列交由随后的 db.AutoMigrate 新建(带默认值,不会触发 NULL 问题)。
|
||||
const backfillDedupSQL = `
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
setc text := '';
|
||||
wherec text := '';
|
||||
has_dup boolean;
|
||||
has_dupof boolean;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT unnest(ARRAY[
|
||||
'brand_runway_images','brand_runway_draft_images',
|
||||
'street_snap_images','street_snap_draft_images'])
|
||||
LOOP
|
||||
SELECT
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name=t AND column_name='is_duplicate'),
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name=t AND column_name='dup_of')
|
||||
INTO has_dup, has_dupof;
|
||||
|
||||
setc := '';
|
||||
wherec := '';
|
||||
IF has_dup THEN
|
||||
setc := setc || 'is_duplicate = COALESCE(is_duplicate, 0)';
|
||||
wherec := wherec || 'is_duplicate IS NULL';
|
||||
END IF;
|
||||
IF has_dupof THEN
|
||||
IF setc <> '' THEN setc := setc || ', '; wherec := wherec || ' OR '; END IF;
|
||||
setc := setc || 'dup_of = COALESCE(dup_of, '''')';
|
||||
wherec := wherec || 'dup_of IS NULL';
|
||||
END IF;
|
||||
|
||||
IF setc <> '' THEN
|
||||
EXECUTE format('UPDATE %I SET %s WHERE %s', t, setc, wherec);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;`
|
||||
if err := db.Exec(backfillDedupSQL).Error; err != nil {
|
||||
return fmt.Errorf("backfill dedup columns: %w", err)
|
||||
}
|
||||
return db.AutoMigrate(
|
||||
&model.Brand{},
|
||||
&model.User{},
|
||||
@ -79,37 +165,47 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
)
|
||||
}
|
||||
|
||||
// ensureDedupSchema 在 GORM AutoMigrate 之外,补充去重所需的索引与语义 embedding 表。
|
||||
// 全部幂等(IF NOT EXISTS),可随服务启动重复执行。
|
||||
// EnsureDedupSchema 在 GORM AutoMigrate 之外,补充去重所需的索引与语义 embedding 表。
|
||||
// 全部幂等(IF NOT EXISTS / DROP IF EXISTS),可随服务启动重复执行。
|
||||
//
|
||||
// - 各 image 表的 content_sha1 部分唯一索引(排除迁移前遗留的空串行,避免唯一约束冲突);
|
||||
// - phash 的 HNSW 索引(bit_hamming_ops,汉明距离近邻检索,替代原先的全表扫描);
|
||||
// - phash 的 HNSW 索引(vector_l2_ops,L2²==汉明距离,替代原先的全表扫描);
|
||||
// - 幂等清理 content_sha1 精确去重废弃后遗留的旧索引(uq_*_sha1 及其 HNSW);
|
||||
// - image_embeddings 表 + cosine HNSW 索引:语义 embedding(CLIP/DINOv2)落库位,
|
||||
// 待后续接入推理填充,当前不写入。
|
||||
func ensureDedupSchema(db *gorm.DB) error {
|
||||
tables := []struct {
|
||||
name string
|
||||
idx string
|
||||
}{
|
||||
{"brand_runway_images", "uq_br_imgs_sha1"},
|
||||
{"brand_runway_draft_images", "uq_br_draft_imgs_sha1"},
|
||||
{"street_snap_images", "uq_ss_imgs_sha1"},
|
||||
{"street_snap_draft_images", "uq_ss_draft_imgs_sha1"},
|
||||
// 待后续推理接入填充,当前不写入。
|
||||
func EnsureDedupSchema(db *gorm.DB) error {
|
||||
// 统一去重方案:废弃早期独立 image_dhash 表(与 phash 直接挂各 image 表冲突)。
|
||||
// 该表从未被 Go 写入,启动即幂等清理遗留表,避免与现行方案并存造成混淆。
|
||||
if err := db.Exec("DROP TABLE IF EXISTS image_dhash CASCADE").Error; err != nil {
|
||||
return fmt.Errorf("drop legacy image_dhash: %w", err)
|
||||
}
|
||||
for _, t := range tables {
|
||||
// 精确去重:content_sha1 唯一(排除空串遗留行)。
|
||||
if err := db.Exec(fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (content_sha1) WHERE content_sha1 <> ''",
|
||||
t.idx, t.name,
|
||||
)).Error; err != nil {
|
||||
return fmt.Errorf("create unique index %s: %w", t.idx, err)
|
||||
// content_sha1 精确去重已废弃:幂等清理其遗留的唯一索引 uq_*_sha1 与旧 HNSW 索引 uq_*_sha1_phash_hnsw。
|
||||
// 新 HNSW 索引统一改名为 <table>_phash_hnsw。
|
||||
legacyIdx := []string{
|
||||
"uq_br_imgs_sha1", "uq_br_draft_imgs_sha1",
|
||||
"uq_ss_imgs_sha1", "uq_ss_draft_imgs_sha1",
|
||||
}
|
||||
for _, idx := range legacyIdx {
|
||||
for _, suffix := range []string{"", "_phash_hnsw"} {
|
||||
if err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s%s", idx, suffix)).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy index %s%s: %w", idx, suffix, err)
|
||||
}
|
||||
}
|
||||
// 近重复检索:dHash 向量(vector(64) 二进制)汉明 HNSW 索引。
|
||||
}
|
||||
tables := []string{
|
||||
"brand_runway_images",
|
||||
"brand_runway_draft_images",
|
||||
"street_snap_images",
|
||||
"street_snap_draft_images",
|
||||
}
|
||||
for _, name := range tables {
|
||||
// 近重复检索:dHash 向量(vector(64) 的 0/1)汉明 HNSW 索引。
|
||||
// 用 vector_l2_ops:两 {0,1}^64 向量的 L2² 恰等于汉明距离,故 L2 距离即汉明语义;
|
||||
// 且 vector_l2_ops 在所有 pgvector 版本均可建 HNSW(bit_hamming_ops 在旧版不可用于 HNSW)。
|
||||
if err := db.Exec(fmt.Sprintf(
|
||||
"CREATE INDEX IF NOT EXISTS %s_phash_hnsw ON %s USING hnsw (phash bit_hamming_ops)",
|
||||
t.idx, t.name,
|
||||
"CREATE INDEX IF NOT EXISTS %s_phash_hnsw ON %s USING hnsw (phash vector_l2_ops)",
|
||||
name, name,
|
||||
)).Error; err != nil {
|
||||
return fmt.Errorf("create hnsw index %s: %w", t.idx, err)
|
||||
return fmt.Errorf("create hnsw index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
// 语义 embedding 表(CLIP/DINOv2 等 512 维向量):待后续推理接入填充;当前不写入。
|
||||
|
||||
@ -2,7 +2,7 @@ package dto
|
||||
|
||||
// ---------- 请求 ----------
|
||||
|
||||
// ArticleQuery 走秀列表查询条件(GET /public/runways)。
|
||||
// ArticleQuery 走秀列表查询条件(GET /public/runway-looks)。
|
||||
//
|
||||
// 设计原则:参数尽可能少、不复用。
|
||||
// - 所有筛选均为单值(前端已全改为单选),不再支持多选数组;
|
||||
@ -64,7 +64,7 @@ func (q ArticleQuery) Offset() int { return (q.Page - 1) * q.Size }
|
||||
|
||||
// PublicArticleImage 对外展示用的图片结构。
|
||||
//
|
||||
// ID 是图片自身的编码 id(hashid,i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
|
||||
// ID 是图片自身的编码 id(hashid,类型进密码:i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
|
||||
// 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。
|
||||
//
|
||||
// Image 与 Thumb 的关系:
|
||||
@ -99,7 +99,7 @@ type PublicArticle struct {
|
||||
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"` // 封面相对路径,如 /uploads/xxx.jpg
|
||||
BrandName string `json:"brand_name"` // JOIN brand 得到
|
||||
BrandName string `json:"brand_name"` // JOIN brands 得到
|
||||
ImageCount uint16 `json:"image_count"` // 该走秀图片总数(列表卡片 "N+" 角标用)
|
||||
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
||||
}
|
||||
@ -117,7 +117,7 @@ type PublicArticleDetail struct {
|
||||
BrandName string `json:"brand_name"`
|
||||
// Images 仅含主图(look 图),默认展示;每个主图的细节图挂在它自己的 Detail 子数组下。
|
||||
Images []PublicArticleImage `json:"images"`
|
||||
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级,r= 前缀)
|
||||
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
|
||||
}
|
||||
|
||||
// AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段,
|
||||
@ -125,7 +125,7 @@ type PublicArticleDetail struct {
|
||||
type AdminRunway struct {
|
||||
UID string `json:"id"` // 编码后的文章 id(无序串)
|
||||
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||
BrandName string `json:"brand_name"` // JOIN brand 得到(优先 en)
|
||||
BrandName string `json:"brand_name"` // JOIN brands 得到(优先 en)
|
||||
Title string `json:"title"`
|
||||
Year uint16 `json:"year"` // 年份(如 2024)
|
||||
Season string `json:"season"` // spring / fall
|
||||
|
||||
@ -7,7 +7,7 @@ type UserPayload struct {
|
||||
ID uint32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
// Tier 用户等级 free/vip;详情页据其决定返回普通还是高清图。
|
||||
// Tier 用户等级 free/vip;仅作账号标记,图片质量模型已统一,不再影响图片 URL。
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,8 @@ package dto
|
||||
|
||||
// ---------- 浏览历史 ----------
|
||||
|
||||
// HistoryItem 对外返回的浏览历史项:id 即 target_uid(带 r=/s= 前缀的对外编码串)。
|
||||
// 前端据此判断类型(s= 街拍,其余走秀)并回查公开详情接口取标题/封面用于渲染。
|
||||
// HistoryItem 对外返回的浏览历史项:id 即 target_uid(类型进密码、无前缀的对外编码串,
|
||||
// 见 hashid.EncodeWithType)。类型由类型化路由 / 存储 type 区分,前端回查公开详情接口取标题/封面用于渲染。
|
||||
type HistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
ViewedAt uint32 `json:"viewed_at"`
|
||||
|
||||
@ -66,5 +66,5 @@ type PublicStreetSnapDetail struct {
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
Images []PublicArticleImage `json:"images"`
|
||||
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级,s= 前缀)
|
||||
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ func (h *ArticleHandler) parseListQuery(c *gin.Context) dto.ArticleQuery {
|
||||
|
||||
// Detail 走秀详情(含完整图片集)。
|
||||
//
|
||||
// GET /api/public/runways/:id
|
||||
// GET /api/v1/public/runway-looks/:id
|
||||
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
|
||||
func (h *ArticleHandler) Detail(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.Param("id"))
|
||||
|
||||
@ -42,7 +42,7 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
|
||||
// Login 账号登录,校验账号密码后签发 access + refresh 双令牌。
|
||||
//
|
||||
// POST /api/auth/login
|
||||
// POST /api/v1/auth/login
|
||||
// body: { account, password }
|
||||
// 成功:200 { access_token, refresh_token, expires_in, user }
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
@ -67,7 +67,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
|
||||
// Refresh 用 refresh token 换取新的 access token。
|
||||
//
|
||||
// POST /api/auth/refresh
|
||||
// POST /api/v1/auth/refresh
|
||||
// body: { refresh_token }
|
||||
// 成功:200 { access_token, expires_in }
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
|
||||
@ -448,7 +448,7 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, reviewListURL(kind))
|
||||
}
|
||||
|
||||
// ReviewDraftImageDelete 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。
|
||||
// ReviewDraftImageDelete 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
// 校验 kind 合法后委派给审核服务,成功后跳回草稿详情页。
|
||||
func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
@ -903,7 +903,7 @@ button{padding:7px 12px;background:#111;color:#fff;border:0;border-radius:6px;cu
|
||||
<div class="bar"><span>管理后台 · 用户管理</span><span><a href="/admin/">返回首页</a> · <a href="/admin/logout">退出</a></span></div>
|
||||
<div class="main">
|
||||
<h1>用户管理({{len .Users}})</h1>
|
||||
<p class="muted">VIP 用户在图集详情页可看高清原图,普通 / 未登录用户只看 normal 普通图。</p>
|
||||
<p class="muted">图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。</p>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>等级</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
@ -916,7 +916,7 @@ button{padding:7px 12px;background:#111;color:#fff;border:0;border-radius:6px;cu
|
||||
<form class="tier" method="post" action="/admin/users/{{.ID}}/tier">
|
||||
<select name="tier">
|
||||
<option value="free" {{if eq .Tier "free"}}selected{{end}}>free(普通)</option>
|
||||
<option value="vip" {{if eq .Tier "vip"}}selected{{end}}>vip(高清)</option>
|
||||
<option value="vip" {{if eq .Tier "vip"}}selected{{end}}>vip</option>
|
||||
</select>
|
||||
<button type="submit">保存</button>
|
||||
</form>
|
||||
@ -1341,7 +1341,7 @@ button.reject{background:#c0392b}
|
||||
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
||||
</form>
|
||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把七牛孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||
</div></body></html>`
|
||||
|
||||
const tplSnapEdit = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||
@ -1391,7 +1391,7 @@ button.reject{background:#c0392b}
|
||||
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
||||
</form>
|
||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把七牛孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||
</div></body></html>`
|
||||
|
||||
const tplIngestJobs = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||
|
||||
@ -23,7 +23,7 @@ func NewBrandHandler(brands service.BrandService) *BrandHandler {
|
||||
|
||||
// List 品牌列表(A-Z 字母索引 / 搜索 / 分页)。
|
||||
//
|
||||
// GET /api/public/brands
|
||||
// GET /api/v1/public/brands
|
||||
//
|
||||
// 查询参数:
|
||||
// - letter A-Z 字母;other 表示中文分桶;缺省返回全部拉丁字母开头的品牌
|
||||
|
||||
@ -48,7 +48,7 @@ func (h *HistoryHandler) List(c *gin.Context) {
|
||||
// Record 记录一次浏览(点开文章详情;upsert:同文章刷新时间,不新增行)。
|
||||
//
|
||||
// POST /api/v1/me/history
|
||||
// body: { target_uid } // 带 r=/s= 前缀的对外编码串
|
||||
// body: { target_uid } // 类型进密码、无前缀的对外编码串
|
||||
// 成功:200 { data: { ok: true } }
|
||||
func (h *HistoryHandler) Record(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFrom(c)
|
||||
|
||||
@ -27,12 +27,12 @@ func NewStreetSnapHandler(svc service.StreetSnapService, favs service.FavoriteSe
|
||||
|
||||
// List 街拍列表(分页 + 排序 + 年份过滤)。
|
||||
//
|
||||
// GET /api/public/street-snaps
|
||||
// GET /api/v1/public/street-snaps
|
||||
//
|
||||
// 查询参数:
|
||||
// - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条)
|
||||
// - year 年份(整数),0 表示不过滤
|
||||
// - city 地区/城市(精确匹配 city 列),” 表示不过滤
|
||||
// - city 地区/城市(精确匹配 city 列),"" 表示不过滤
|
||||
// - sort image_count(默认,按图片数降序)/ year_desc / year_asc
|
||||
// - locale cn / en
|
||||
//
|
||||
@ -65,7 +65,7 @@ func (h *StreetSnapHandler) parseListQuery(c *gin.Context) dto.StreetSnapQuery {
|
||||
|
||||
// Detail 街拍详情(含完整图片集)。
|
||||
//
|
||||
// GET /api/public/street-snaps/:id
|
||||
// GET /api/v1/public/street-snaps/:id
|
||||
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
|
||||
// 不走前端 JS 签名,单独挂载(与 runway 详情一致),单篇枚举防护由 HashID 承担。
|
||||
func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
||||
|
||||
@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
ContextUserID = "userID"
|
||||
ContextUsername = "username"
|
||||
// ContextTier 存放用户等级(free/vip),供详情接口据其拼装普通/高清图。
|
||||
// ContextTier 存放用户等级(free/vip)。图片质量模型已统一,当前无消费方,仅作兼容保留。
|
||||
ContextTier = "tier"
|
||||
)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ package model
|
||||
|
||||
// Brand 品牌表。
|
||||
//
|
||||
// 注意:时间戳字段为 int unsigned 存储的 Unix 秒,不是 MySQL datetime,
|
||||
// 注意:时间戳字段为 int unsigned 存储的 Unix 秒,不是数据库 datetime,
|
||||
// 迁移时保持原样,避免与既有数据不兼容。
|
||||
type Brand struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
@ -17,4 +17,4 @@ type Brand struct {
|
||||
}
|
||||
|
||||
// TableName 指定表名。
|
||||
func (Brand) TableName() string { return "brand" }
|
||||
func (Brand) TableName() string { return "brands" }
|
||||
|
||||
@ -4,9 +4,9 @@ package model
|
||||
//
|
||||
// 设计:爬虫 POST 到 :8092 的 ingest 接口(HMAC 验签后)只做「入队 + 立即 202」,
|
||||
// 真正的去重 / 补分类 / 下载图 / 写正式表 由后台 worker 异步处理,
|
||||
// 因此接口本身很薄、不会被慢任务(尤其是 OSS 上传)拖垮。
|
||||
// 因此接口本身很薄、不会被慢任务(尤其是 S4 上传)拖垮。
|
||||
//
|
||||
// 并发安全靠 MySQL 的 `SELECT ... FOR UPDATE SKIP LOCKED`:多个 worker 同时
|
||||
// 并发安全靠 PostgreSQL 的 `SELECT ... FOR UPDATE SKIP LOCKED`:多个 worker 同时
|
||||
// Claim 不会抢到同一条,未提交的行对其它 worker 不可见。
|
||||
type IngestJob struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
@ -32,10 +32,10 @@ const (
|
||||
IngestStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// IngestJob 任务类型(kind 列):区分「爬虫入库」与「清理七牛孤儿图」两条管线。
|
||||
// IngestJob 任务类型(kind 列):区分「爬虫入库」与「清理S4孤儿图」两条管线。
|
||||
const (
|
||||
IngestKindCrawl = "crawl" // 爬虫上报的走秀/街拍入库
|
||||
IngestKindMediaCleanup = "media_cleanup" // 删除图集时异步清理七牛孤儿文件
|
||||
IngestKindMediaCleanup = "media_cleanup" // 删除图集时异步清理S4孤儿文件
|
||||
)
|
||||
|
||||
// IngestMaxAttempts 单条任务最大处理次数(含首次)。达上限仍未成功则置 failed,需后台手动重试。
|
||||
|
||||
@ -25,11 +25,11 @@ type BrandRunway struct {
|
||||
}
|
||||
|
||||
// TableName 指定表名。
|
||||
func (BrandRunway) TableName() string { return "brand_runway" }
|
||||
func (BrandRunway) TableName() string { return "brand_runways" }
|
||||
|
||||
// RunwayRow 走秀档案的查询投影行。
|
||||
//
|
||||
// 独立于 BrandRunway 定义,是为了承载 JOIN brand 得到的品牌名(en/cn 两列)。
|
||||
// 独立于 BrandRunway 定义,是为了承载 JOIN brands 得到的品牌名(en/cn 两列)。
|
||||
type RunwayRow struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
BrandID uint32 `gorm:"column:brand_id"`
|
||||
|
||||
@ -5,8 +5,8 @@ import "database/sql"
|
||||
// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。
|
||||
//
|
||||
// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending;
|
||||
// 后台审核通过(Approve)后再晋升(SaveRunwayFromDraft)到 brand_runway 正式表。
|
||||
// 字段与 brand_runway 对齐(含 review 专用字段),便于审核页直接预览/微调。
|
||||
// 后台审核通过(Approve)后再晋升(SaveRunwayFromDraft)到 brand_runways 正式表。
|
||||
// 字段与 brand_runways 对齐(含 review 专用字段),便于审核页直接预览/微调。
|
||||
type BrandRunwayDraft struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||
@ -30,7 +30,7 @@ type BrandRunwayDraft struct {
|
||||
}
|
||||
|
||||
// TableName 指定草稿主表名。
|
||||
func (BrandRunwayDraft) TableName() string { return "brand_runway_draft" }
|
||||
func (BrandRunwayDraft) TableName() string { return "brand_runway_drafts" }
|
||||
|
||||
// BrandRunwayDraftImage 草稿图片(对应 brand_runway_draft_images)。
|
||||
type BrandRunwayDraftImage struct {
|
||||
@ -46,7 +46,6 @@ type BrandRunwayDraftImage struct {
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
|
||||
@ -16,11 +16,9 @@ type BrandRunwayImage struct {
|
||||
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
|
||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
|
||||
|
||||
// 去重字段(精确 + 近重复 + 语义 embedding 留痕):
|
||||
// content_sha1 图片内容 sha1,与存储 key 同源,空串为迁移前遗留行;
|
||||
// 去重字段(近重复 + 语义 embedding 留痕):
|
||||
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
|
||||
// is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
|
||||
@ -2,10 +2,10 @@ package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// StreetSnap 街拍主表(对应 db_dev.street_snap)。
|
||||
// StreetSnap 街拍主表(对应 db_dev.street_snaps)。
|
||||
//
|
||||
// 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。
|
||||
// 与 brand_runway 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
|
||||
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
|
||||
type StreetSnap struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
@ -19,7 +19,7 @@ type StreetSnap struct {
|
||||
}
|
||||
|
||||
// TableName 指定主表名。
|
||||
func (StreetSnap) TableName() string { return "street_snap" }
|
||||
func (StreetSnap) TableName() string { return "street_snaps" }
|
||||
|
||||
// StreetSnapImage 街拍图片表(对应 db_dev.street_snap_images)。
|
||||
//
|
||||
@ -35,7 +35,6 @@ type StreetSnapImage struct {
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
|
||||
@ -4,10 +4,10 @@ import "database/sql"
|
||||
|
||||
// StreetSnapDraft 街拍草稿(爬虫入库待审核)。
|
||||
//
|
||||
// 与 brand_runway_draft 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
|
||||
// pending;后台审核通过(Approve)后再晋升(SaveStreetSnapFromDraft)到 street_snap 正式表。
|
||||
// 与 brand_runway_drafts 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
|
||||
// pending;后台审核通过(Approve)后再晋升(SaveStreetSnapFromDraft)到 street_snaps 正式表。
|
||||
//
|
||||
// 字段对齐 street_snap 正式表(单 Title、有 City、无 brand_id、无 season 系列列),
|
||||
// 字段对齐 street_snaps 正式表(单 Title、有 City、无 brand_id、无 season 系列列),
|
||||
// 外加审核专用字段,便于审核页直接预览/微调。
|
||||
type StreetSnapDraft struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
@ -26,7 +26,7 @@ type StreetSnapDraft struct {
|
||||
}
|
||||
|
||||
// TableName 指定草稿主表名。
|
||||
func (StreetSnapDraft) TableName() string { return "street_snap_draft" }
|
||||
func (StreetSnapDraft) TableName() string { return "street_snap_drafts" }
|
||||
|
||||
// StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。
|
||||
type StreetSnapDraftImage struct {
|
||||
@ -40,7 +40,6 @@ type StreetSnapDraftImage struct {
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
|
||||
@ -11,7 +11,7 @@ type User struct {
|
||||
Email string `gorm:"column:email;size:191;uniqueIndex" json:"email"`
|
||||
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
// Tier 用户等级:free(普通/未登录)/ vip(高级/VIP)。详情页据其决定返回普通还是高清图。
|
||||
// Tier 用户等级:free / vip。仅作账号标记(后台可提级 VIP);图片质量模型已统一,tier 不再影响图片 URL。
|
||||
Tier string `gorm:"column:tier;size:16" json:"tier"`
|
||||
}
|
||||
|
||||
|
||||
@ -198,8 +198,8 @@ func DecodeWithType(s string, typ byte) (uint32, error) {
|
||||
//
|
||||
// 前端按类型化路由(/runway-looks、/street-snaps)或存储 type 分流,后端按路由 typ 调 DecodeWithType。
|
||||
const (
|
||||
TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表)
|
||||
TypeSnap = 's' // 街拍(street_snap 表)
|
||||
TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runways 表)
|
||||
TypeSnap = 's' // 街拍(street_snaps 表)
|
||||
|
||||
// 图片级前缀:单张图片也要有对外标识(图片收藏用)。
|
||||
// 与图集前缀(r/s)互不冲突,因此同一数字主键在图集与图片下得到不同编码串,
|
||||
|
||||
@ -19,7 +19,7 @@ type Claims struct {
|
||||
UserID uint32
|
||||
Username string
|
||||
Email string
|
||||
// Tier 用户等级 free/vip;详情页据其拼装图片 URL(普通/高清)。
|
||||
// Tier 用户等级 free/vip;仅作账号标记(图片质量模型已统一,不再据此拼装 URL)。
|
||||
Tier string
|
||||
}
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ func dhash(img image.Image) uint64 {
|
||||
}
|
||||
|
||||
// ToVectorBits 把 64-bit 指纹转成 pgvector 的二进制向量字符串 "[0,1,...,1]"(长度 64),
|
||||
// 用于写入 vector(64) 列,并以 bit_hamming_ops 建 HNSW 索引做汉明近邻检索。
|
||||
// 用于写入 vector(64) 列,并以 vector_l2_ops 建 HNSW 索引做汉明近邻检索(L2²==汉明)。
|
||||
// h 为 0(解码失败)时返回空串,调用方据此存 NULL、不参与检索。
|
||||
func ToVectorBits(h uint64) string {
|
||||
if h == 0 {
|
||||
|
||||
122
internal/pkg/phash/phash_test.go
Normal file
122
internal/pkg/phash/phash_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package phash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// genGradient 生成一张水平灰阶渐变的 PNG 字节流(w 宽、h 高)。
|
||||
// 用于验证 dHash 对「同一图案」的稳定性,以及对「缩放 / 转码」的鲁棒性。
|
||||
func genGradient(w, h int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
v := uint8(x * 255 / w)
|
||||
img.Set(x, y, color.Gray{Y: v})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// genGradientReverse 生成一张「左亮右暗」的反向渐变 PNG。
|
||||
// 与 genGradient(左暗右亮)方向相反,dHash 的相邻比较结果完全相反,
|
||||
// 汉明距离应为 64(最大),用于验证「差异显著」的图能被正确区分。
|
||||
func genGradientReverse(w, h int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
v := uint8(255 - x*255/w)
|
||||
img.Set(x, y, color.Gray{Y: v})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// genSolid 生成一张纯色 PNG。
|
||||
func genSolid(w, h int, v uint8) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
c := color.Gray{Y: v}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestOfIdentical 同一字节流 → 指纹完全一致。
|
||||
func TestOfIdentical(t *testing.T) {
|
||||
a := genGradient(64, 64)
|
||||
b := genGradient(64, 64)
|
||||
if Of(a) != Of(b) {
|
||||
t.Fatalf("相同图片应得到相同 dHash,got %016x vs %016x", Of(a), Of(b))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOfResizeSimilar 同一图案缩放到不同尺寸 → 汉明距离很小(近重复可容忍范围内)。
|
||||
func TestOfResizeSimilar(t *testing.T) {
|
||||
a := genGradient(64, 64)
|
||||
b := genGradient(128, 128)
|
||||
d := Hamming(Of(a), Of(b))
|
||||
if d > 20 {
|
||||
t.Fatalf("同一图案缩放后汉明距离应很小,got %d", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOfDifferent 方向相反的图案(左暗右亮 vs 左亮右暗)→ 汉明距离应显著大于近重复阈值。
|
||||
func TestOfDifferent(t *testing.T) {
|
||||
a := genGradient(64, 64)
|
||||
b := genGradientReverse(64, 64)
|
||||
d := Hamming(Of(a), Of(b))
|
||||
if d <= DefaultThreshold {
|
||||
t.Fatalf("不同图片汉明距离应大于阈值 %d,got %d", DefaultThreshold, d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToVectorBits 验证向量串格式与维度(64 位、方括号包裹、0/1 组成)。
|
||||
func TestToVectorBits(t *testing.T) {
|
||||
s := ToVectorBits(0b1) // 仅最低位为 1
|
||||
if len(s) != 2*Size+1 { // 64 个 0/1 + 63 个逗号 + 2 个方括号 = 129
|
||||
t.Fatalf("向量串长度应为 %d,got %d(%s)", 2*Size+1, len(s), s)
|
||||
}
|
||||
if s[0] != '[' || s[len(s)-1] != ']' {
|
||||
t.Fatalf("向量串应以方括号包裹,got %s", s)
|
||||
}
|
||||
if s[1] != '1' { // bit0 = 1
|
||||
t.Fatalf("第 0 维应为 1,got %c", s[1])
|
||||
}
|
||||
if s[3] != '0' { // bit1 = 0([1,0,...] 中第二个元素)
|
||||
t.Fatalf("第 1 维应为 0,got %c", s[3])
|
||||
}
|
||||
if ToVectorBits(0) != "" {
|
||||
t.Fatalf("解码失败(0)应返回空串,got %q", ToVectorBits(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHamming 验证汉明距离的对称性与已知值。
|
||||
func TestHamming(t *testing.T) {
|
||||
if Hamming(0, 0) != 0 {
|
||||
t.Fatalf("相同指纹汉明距离应为 0")
|
||||
}
|
||||
if Hamming(0b1111, 0b0000) != 4 {
|
||||
t.Fatalf("1111 vs 0000 汉明距离应为 4")
|
||||
}
|
||||
if Hamming(0b1111, 0b0000) != Hamming(0b0000, 0b1111) {
|
||||
t.Fatalf("汉明距离应对称")
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,7 @@ type ArticleRepository interface {
|
||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteRunwayImage 软删单张走秀图片。
|
||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||
// GetRunwayImageKey 取单张走秀图片的七牛 key(删除前清理用);不存在返回空串。
|
||||
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||
@ -52,25 +52,25 @@ func NewArticleRepository(db *gorm.DB) ArticleRepository {
|
||||
// selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。
|
||||
// 注意:year / season / season_code / collection_type / created_at 等仅用于排序,不 SELECT 到响应里
|
||||
// (前端按年份/季节筛选是走查询参数,响应无需回带这些元数据)。
|
||||
const articleListColumns = `brand_runway.id, brand_runway.brand_id,
|
||||
brand_runway.title_en, brand_runway.title_cn,
|
||||
brand_runway.cover, brand_runway.image_count,
|
||||
const articleListColumns = `brand_runways.id, brand_runways.brand_id,
|
||||
brand_runways.title_en, brand_runways.title_cn,
|
||||
brand_runways.cover, brand_runways.image_count,
|
||||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runway.title_cn,
|
||||
brand_runway.description_en, brand_runway.description_cn,
|
||||
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_runways.title_cn,
|
||||
brand_runways.description_en, brand_runways.description_cn,
|
||||
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||||
// collection_type / season_code 等管理字段(前端列表不展示这些)。
|
||||
const articleAdminColumns = `brand_runway.id, brand_runway.brand_id,
|
||||
brand_runway.title_en, brand_runway.title_cn,
|
||||
brand_runway.cover, brand_runway.image_count, brand_runway.year,
|
||||
brand_runway.collection_type, brand_runway.season, brand_runway.season_code,
|
||||
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
|
||||
brand_runways.title_en, brand_runways.title_cn,
|
||||
brand_runways.cover, brand_runways.image_count, brand_runways.year,
|
||||
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
|
||||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||
const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0"
|
||||
const brandJoin = "LEFT JOIN brands b ON b.id = brand_runways.brand_id AND b.is_deleted = 0"
|
||||
|
||||
// filterScope 把查询条件编译为 GORM Scope。
|
||||
//
|
||||
@ -78,18 +78,18 @@ const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_de
|
||||
// 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。
|
||||
func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
db = db.Where("brand_runway.is_deleted = 0")
|
||||
db = db.Where("brand_runways.is_deleted = 0")
|
||||
|
||||
// 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。
|
||||
db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs))
|
||||
db = whereMulti(db, "brand_runways.brand_id", toAnySlice(q.BrandIDs))
|
||||
if q.Collection != "" {
|
||||
db = db.Where("brand_runway.collection_type = ?", q.Collection)
|
||||
db = db.Where("brand_runways.collection_type = ?", q.Collection)
|
||||
}
|
||||
if q.Season != "" {
|
||||
db = db.Where("brand_runway.season = ?", q.Season)
|
||||
db = db.Where("brand_runways.season = ?", q.Season)
|
||||
}
|
||||
if q.Year != 0 {
|
||||
db = db.Where("brand_runway.year = ?", q.Year)
|
||||
db = db.Where("brand_runways.year = ?", q.Year)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@ -126,14 +126,14 @@ func toAnySlice[T any](in []T) []any {
|
||||
// newest(默认):按走秀真实新旧排序 —— created_at 仅是导入时间,不能代表走秀新旧,
|
||||
// 故用 year DESC + season_code 优先级(FW>PF>SS>RES)+ id 兜底,与首页「最新走秀」口径一致。
|
||||
func orderBy(sort string) string {
|
||||
seasonPrio := `CASE brand_runway.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END`
|
||||
seasonPrio := `CASE brand_runways.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END`
|
||||
switch sort {
|
||||
case "year_asc":
|
||||
return "brand_runway.year ASC, brand_runway.id ASC"
|
||||
return "brand_runways.year ASC, brand_runways.id ASC"
|
||||
case "year_desc":
|
||||
return "brand_runway.year DESC, brand_runway.id DESC"
|
||||
return "brand_runways.year DESC, brand_runways.id DESC"
|
||||
default: // newest
|
||||
return "brand_runway.year DESC, " + seasonPrio + " DESC, brand_runway.id DESC"
|
||||
return "brand_runways.year DESC, " + seasonPrio + " DESC, brand_runways.id DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,7 +202,7 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
|
||||
Model(&model.BrandRunway{}).
|
||||
Select(articleDetailColumns).
|
||||
Joins(brandJoin).
|
||||
Where("brand_runway.id = ? AND brand_runway.is_deleted = 0", id).
|
||||
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
||||
First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@ -309,7 +309,7 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
|
||||
}
|
||||
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
|
||||
@ -176,7 +176,7 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ListByIDs 按 id 集合取品牌行;MySQL IN 查询不保证返回顺序,调用方自行重排。
|
||||
// ListByIDs 按 id 集合取品牌行;IN 查询不保证返回顺序,调用方自行重排。
|
||||
func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) {
|
||||
if len(ids) == 0 {
|
||||
return []model.Brand{}, nil
|
||||
@ -281,7 +281,7 @@ WITH latest_per_brand AS (
|
||||
END DESC,
|
||||
id DESC
|
||||
) AS rn
|
||||
FROM brand_runway
|
||||
FROM brand_runways
|
||||
WHERE is_deleted = 0 AND brand_id IN (?)
|
||||
)
|
||||
SELECT id, brand_id, cover, title_en, title_cn
|
||||
|
||||
96
internal/repository/dedup_integration_test.go
Normal file
96
internal/repository/dedup_integration_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:去重核心查询需要真实 PostgreSQL + pgvector 环境。
|
||||
//
|
||||
// 运行方式(先起库,见 scripts/pgvector):
|
||||
//
|
||||
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
||||
// go test -tags integration ./internal/repository/ -run TestDedup -v
|
||||
//
|
||||
// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/config"
|
||||
"fashionapi/internal/database"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/phash"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testDB 连真实库并完成 AutoMigrate + 去重 schema 初始化。
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
db, err := database.New(cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("AutoMigrate 失败: %v", err)
|
||||
}
|
||||
if err := database.EnsureDedupSchema(db); err != nil {
|
||||
t.Fatalf("去重 schema 失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close(db) })
|
||||
return db
|
||||
}
|
||||
|
||||
// insertRunwayImage 直接插入一条走秀图片行(无外键约束,runway_id / brand_id 可填占位值)。
|
||||
func insertRunwayImage(t *testing.T, db *gorm.DB, image, phashBits string) {
|
||||
t.Helper()
|
||||
var ph sql.NullString
|
||||
if phashBits != "" {
|
||||
ph = sql.NullString{String: phashBits, Valid: true}
|
||||
}
|
||||
row := model.BrandRunwayImage{
|
||||
RunwayID: 1,
|
||||
BrandID: 1,
|
||||
Image: image,
|
||||
Name: "look",
|
||||
SortOrder: 1,
|
||||
Phash: ph,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("插入图片失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Where("image = ?", image).Delete(&model.BrandRunwayImage{})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDedupNearDuplicate 近重复:dHash 汉明距离 ≤ 阈值应被 FindNearDuplicateImage 命中。
|
||||
func TestDedupNearDuplicate(t *testing.T) {
|
||||
db := testDB(t)
|
||||
repo := NewIngestRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// 插入一张已有图片,phash 为 64 维全 1 向量。
|
||||
insertRunwayImage(t, db, "runway/near-dup.jpg", phash.ToVectorBits(^uint64(0)))
|
||||
|
||||
tables := []string{"brand_runway_images"}
|
||||
|
||||
// 探测向量:仅第 0 位为 0、其余为 1 → 与全 1 向量汉明距离 = 1,应命中。
|
||||
probe := ^uint64(0) &^ 1
|
||||
_, found, err := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(probe), phash.DefaultThreshold)
|
||||
if err != nil {
|
||||
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("汉明距离 1 的近重复应被命中")
|
||||
}
|
||||
|
||||
// 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。
|
||||
_, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold)
|
||||
if found2 {
|
||||
t.Fatalf("汉明距离 64 的图不应被误判为近重复")
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,7 @@ func (r *historyRepository) Upsert(ctx context.Context, h *model.History) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避 MySQL「同表不可直接子查删除」限制)。
|
||||
// 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避数据库「同表不可直接子查删除」限制)。
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.History{}).Where("user_id = ?", h.UserID).Count(&n).Error; err != nil {
|
||||
return err
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
@ -11,13 +12,13 @@ import (
|
||||
)
|
||||
|
||||
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
||||
// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images)。
|
||||
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
|
||||
//
|
||||
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
|
||||
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
|
||||
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
|
||||
//
|
||||
// 入队与领取用同一张 ingest_jobs 表,领取靠 MySQL 的 FOR UPDATE SKIP LOCKED
|
||||
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
|
||||
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
||||
type IngestRepository interface {
|
||||
// Enqueue 写入一条待处理任务(payload 为原始 JSON)。
|
||||
@ -44,8 +45,6 @@ type IngestRepository interface {
|
||||
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
|
||||
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
|
||||
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
|
||||
// ImageExistsBySha1 在给定图片表中是否已存在相同内容哈希(精确重复);用于入库前跳过该行。
|
||||
ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error)
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
|
||||
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
|
||||
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||
@ -54,8 +53,8 @@ type IngestRepository interface {
|
||||
CountJobs(ctx context.Context) (int64, error)
|
||||
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
|
||||
RetryJob(ctx context.Context, id uint32) error
|
||||
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。
|
||||
// 由删除图集的服务调用,把同步的七牛删除改为异步队列,避免阻塞删除请求。
|
||||
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。
|
||||
// 由删除图集的服务调用,把同步的S4删除改为异步队列,避免阻塞删除请求。
|
||||
EnqueueMediaCleanup(ctx context.Context, payload string) error
|
||||
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
|
||||
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
|
||||
@ -118,7 +117,7 @@ func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.Ingest
|
||||
}
|
||||
|
||||
// 2) 领取 pending 且已到重试时间的任务(含刚回收的 + 新入队的 + 退避已到期的)。
|
||||
// 优先处理 media_cleanup(删除图集时异步清理七牛),避免被大批 crawl 任务排到后面、清理迟迟不触发。
|
||||
// 优先处理 media_cleanup(删除图集时异步清理S4),避免被大批 crawl 任务排到后面、清理迟迟不触发。
|
||||
var ids []uint32
|
||||
if err := tx.Raw(
|
||||
"SELECT id FROM ingest_jobs WHERE status = ? AND next_attempt_at <= ? "+
|
||||
@ -209,7 +208,7 @@ func (r *ingestRepository) ScheduleRetry(ctx context.Context, id uint32, errMsg
|
||||
}).Error
|
||||
}
|
||||
|
||||
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(kind=media_cleanup),
|
||||
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(kind=media_cleanup),
|
||||
// payload 为待清理 key 的 JSON,由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。
|
||||
func (r *ingestRepository) EnqueueMediaCleanup(ctx context.Context, payload string) error {
|
||||
now := uint32(time.Now().Unix())
|
||||
@ -372,32 +371,20 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||
|
||||
|
||||
|
||||
// ImageExistsBySha1 在给定图片表中是否已存在相同 content_sha1(精确重复)。
|
||||
func (r *ingestRepository) ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error) {
|
||||
for _, t := range tables {
|
||||
var c int64
|
||||
if err := r.db.WithContext(ctx).Table(t).
|
||||
Where("content_sha1 = ? AND is_deleted = 0", sha1).Count(&c).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if c > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离(pgvector <~> 算子)检索近重复,
|
||||
// 取距离 ≤ threshold 的最近一条。phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
|
||||
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
||||
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
||||
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
||||
l2Limit := math.Sqrt(float64(threshold))
|
||||
for _, t := range tables {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Dist int `gorm:"column:dist"`
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Dist float64 `gorm:"column:dist"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).Table(t).
|
||||
Select("id, (phash <~> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <~> ?::vector) <= ?", phashBits, threshold).
|
||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||
Order("dist ASC").
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
@ -412,13 +399,13 @@ func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []
|
||||
}
|
||||
|
||||
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
|
||||
// 直接命中 MySQL 1062 错误号更稳。
|
||||
// 命中驱动返回的唯一约束冲突错误信息更稳(兼容 MySQL/PostgreSQL/SQLite 不同措辞)。
|
||||
func isDuplicateKey(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return containsAny(msg, "Duplicate entry", "1062", "UNIQUE constraint failed")
|
||||
return containsAny(msg, "Duplicate entry", "1062", "duplicate key", "violates unique constraint", "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
func containsAny(s string, subs ...string) bool {
|
||||
|
||||
@ -2,10 +2,10 @@ package repository
|
||||
|
||||
import "testing"
|
||||
|
||||
// 仅覆盖纯函数助手(DB 相关方法需 MySQL,本环境不跑)。
|
||||
// 仅覆盖纯函数助手(DB 相关方法需连库,本环境不跑)。
|
||||
func TestContainsAny(t *testing.T) {
|
||||
if !containsAny("Error 1062: Duplicate entry", "1062") {
|
||||
t.Fatal("should detect mysql 1062")
|
||||
t.Fatal("should detect duplicate key (1062)")
|
||||
}
|
||||
if !containsAny("UNIQUE constraint failed", "UNIQUE constraint failed") {
|
||||
t.Fatal("should detect sqlite unique")
|
||||
|
||||
@ -6,14 +6,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MediaRepository 跨表图片引用计数(清理七牛孤儿文件的判定依据)。
|
||||
// MediaRepository 跨表图片引用计数(清理S4孤儿文件的判定依据)。
|
||||
//
|
||||
// 七牛 key 为 sha1 内容寻址:同一张图若出现在多个图集/草稿里,在库中是同一个 key。
|
||||
// 因此删除图集时绝不能「删库即删七牛」——必须先确认该 key 在全库已无任何引用,
|
||||
// S4 key 为 sha1 内容寻址:同一张图若出现在多个图集/草稿里,在库中是同一个 key。
|
||||
// 因此删除图集时绝不能「删库即删S4」——必须先确认该 key 在全库已无任何引用,
|
||||
// 否则会误删被其他图集仍在用的图片。本接口提供唯一权威的引用计数查询。
|
||||
type MediaRepository interface {
|
||||
// CountImageKeyRefs 统计某七牛 key 在全部图片明细表中仍被引用的数量(is_deleted=0)。
|
||||
// 返回 0 表示该 key 已成孤儿,可安全删除七牛对象。
|
||||
// CountImageKeyRefs 统计某S4 key 在全部图片明细表中仍被引用的数量(is_deleted=0)。
|
||||
// 返回 0 表示该 key 已成孤儿,可安全删除S4对象。
|
||||
CountImageKeyRefs(ctx context.Context, key string) (int, error)
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ func NewMediaRepository(db *gorm.DB) MediaRepository {
|
||||
return &mediaRepository{db: db}
|
||||
}
|
||||
|
||||
// imageTables 保存七牛 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
|
||||
// imageTables 保存S4 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
|
||||
// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
||||
var imageTables = []string{
|
||||
"brand_runway_images",
|
||||
|
||||
@ -27,9 +27,9 @@ type ReviewRepository interface {
|
||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
|
||||
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
|
||||
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。
|
||||
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。
|
||||
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。
|
||||
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
|
||||
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error)
|
||||
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error)
|
||||
@ -37,7 +37,7 @@ type ReviewRepository interface {
|
||||
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
||||
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||
// GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。
|
||||
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
|
||||
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
@ -163,7 +163,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
return 0, err
|
||||
}
|
||||
// 多来源聚合:当前草稿图 + 同实体已 approved 的其他草稿图(按 image URL 去重,
|
||||
// 七牛 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。
|
||||
// S4 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。
|
||||
curImgs, err := r.DraftImages(ctx, draftID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@ -252,7 +252,6 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: im.LookIndex,
|
||||
IsDetail: im.IsDetail,
|
||||
ContentSha1: im.ContentSha1,
|
||||
Phash: im.Phash,
|
||||
IsDuplicate: im.IsDuplicate,
|
||||
DupOf: im.DupOf,
|
||||
@ -371,7 +370,7 @@ func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按实体键 city+year 幂等 upsert,替换图片)。
|
||||
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snaps(按实体键 city+year 幂等 upsert,替换图片)。
|
||||
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
|
||||
// 在事务内完成,保证草稿与正式表一致性。
|
||||
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
|
||||
@ -445,7 +444,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
|
||||
Image: im.Image,
|
||||
Name: im.Name,
|
||||
SortOrder: uint32(i + 1),
|
||||
ContentSha1: im.ContentSha1,
|
||||
Phash: im.Phash,
|
||||
IsDuplicate: im.IsDuplicate,
|
||||
DupOf: im.DupOf,
|
||||
@ -485,7 +483,7 @@ func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excl
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// unionRunwayDraftImages 按 image URL 去重合并(七牛 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
|
||||
// unionRunwayDraftImages 按 image URL 去重合并(S4 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
|
||||
func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage {
|
||||
seen := make(map[string]struct{}, len(a)+len(b))
|
||||
out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b))
|
||||
@ -549,7 +547,7 @@ func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSna
|
||||
// ── 草稿图片删除 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。
|
||||
// 不删七牛文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
|
||||
// 不删S4文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
|
||||
func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayDraftImage{}).
|
||||
@ -558,7 +556,7 @@ func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draft
|
||||
return err
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("UPDATE brand_runway_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
|
||||
Exec("UPDATE brand_runway_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
|
||||
}
|
||||
|
||||
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
||||
@ -570,10 +568,10 @@ func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draft
|
||||
return err
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("UPDATE street_snap_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
|
||||
Exec("UPDATE street_snap_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
|
||||
}
|
||||
|
||||
// GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。
|
||||
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
||||
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。
|
||||
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) {
|
||||
switch kind {
|
||||
|
||||
@ -30,7 +30,7 @@ type StreetSnapRepository interface {
|
||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteSnapImage 软删单张街拍图片。
|
||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||
// GetSnapImageKey 取单张街拍图片的七牛 key(删除前清理用);不存在返回空串。
|
||||
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||
@ -67,11 +67,11 @@ func snapFilterScope(q dto.StreetSnapQuery) func(*gorm.DB) *gorm.DB {
|
||||
func snapOrderBy(sort string) string {
|
||||
switch sort {
|
||||
case "year_asc":
|
||||
return "street_snap.year ASC, street_snap.id ASC"
|
||||
return "street_snaps.year ASC, street_snaps.id ASC"
|
||||
case "year_desc":
|
||||
return "street_snap.year DESC, street_snap.id DESC"
|
||||
return "street_snaps.year DESC, street_snaps.id DESC"
|
||||
default: // image_count
|
||||
return "street_snap.image_count DESC, street_snap.id DESC"
|
||||
return "street_snaps.image_count DESC, street_snaps.id DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,7 +222,7 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
|
||||
}
|
||||
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
||||
var imgs []model.StreetSnapImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
|
||||
@ -14,8 +14,8 @@ import (
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// MediaCleanupEnqueuer 是「清理七牛孤儿图」任务的入队入口;由 IngestService 实现,
|
||||
// 图集服务在删除图集时调用它,把同步的七牛删除改为异步队列,避免阻塞删除请求。
|
||||
// MediaCleanupEnqueuer 是「清理S4孤儿图」任务的入队入口;由 IngestService 实现,
|
||||
// 图集服务在删除图集时调用它,把同步的S4删除改为异步队列,避免阻塞删除请求。
|
||||
type MediaCleanupEnqueuer interface {
|
||||
EnqueueMediaCleanup(ctx context.Context, keys []string) error
|
||||
}
|
||||
@ -36,7 +36,7 @@ type ArticleService interface {
|
||||
SetCover(ctx context.Context, runwayID uint32, image string) error
|
||||
// SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
|
||||
SetDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||
// PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。
|
||||
// PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理S4孤儿图。
|
||||
PurgeGallery(ctx context.Context, id uint32) error
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ type articleService struct {
|
||||
media repository.MediaRepository
|
||||
img *imgurl.Composer
|
||||
deleter storage.Deleter
|
||||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口
|
||||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口
|
||||
}
|
||||
|
||||
// NewArticleService 创建文章服务。
|
||||
@ -212,7 +212,7 @@ func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map
|
||||
}
|
||||
|
||||
func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error {
|
||||
// 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。
|
||||
// 先取 key(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。
|
||||
key, err := s.articles.GetRunwayImageKey(ctx, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -232,20 +232,20 @@ func (s *articleService) SetCover(ctx context.Context, runwayID uint32, image st
|
||||
|
||||
func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||
if deleted == 1 {
|
||||
// 下架图集:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。
|
||||
// 下架图集:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。
|
||||
return s.PurgeGallery(ctx, id)
|
||||
}
|
||||
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
|
||||
return s.articles.SetRunwayDeleted(ctx, id, 0)
|
||||
}
|
||||
|
||||
// PurgeGallery 下架图集并异步清理七牛孤儿图:
|
||||
// PurgeGallery 下架图集并异步清理S4孤儿图:
|
||||
// 1. 级联软删其全部图片(使其退出引用计数),拿到这些图片的 key;
|
||||
// 2. 把图集自身置为下架(对外不可见);
|
||||
// 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删七牛文件(异步、不阻塞本请求)。
|
||||
// 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删S4文件(异步、不阻塞本请求)。
|
||||
//
|
||||
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||||
// 图集已下架、图片已软删,七牛文件留待后续手动清理或周期性兜底扫描。
|
||||
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
||||
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
||||
if err != nil {
|
||||
@ -256,7 +256,7 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
}
|
||||
if len(keys) > 0 && s.cleaner != nil {
|
||||
if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil {
|
||||
log.Printf("[warn] 下架图集 %d 入队清理七牛失败(文件将暂留): %v", id, err)
|
||||
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -32,7 +32,7 @@ type indexService struct {
|
||||
}
|
||||
|
||||
// NewIndexService 创建首页数据服务。
|
||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(首页统一普通图)。
|
||||
// img 负责把库里存储的S4 key 拼装为可访问 URL(首页统一普通图)。
|
||||
func NewIndexService(brands repository.BrandRepository, img *imgurl.Composer) IndexService {
|
||||
return &indexService{brands: brands, img: img}
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ func TestFetchImagesCleansUpOnFailure(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
|
||||
_, out, keys, failed := s.fetchImages(context.Background(), urls, "runway", dto.IngestKindRunway)
|
||||
_, out, keys, failed, _ := s.fetchImages(context.Background(), urls, "runway", dto.IngestKindRunway)
|
||||
if !failed {
|
||||
t.Fatalf("expected failed=true when one image errors")
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
@ -27,31 +28,44 @@ import (
|
||||
//
|
||||
// 队列复用一张 ingest_jobs 表,靠 kind 列分流两类任务:
|
||||
// - crawl:爬虫上报的走秀/街拍入库(下载图 → 写草稿)
|
||||
// - media_cleanup:删除图集时异步清理七牛孤儿图(引用计数归零才真删)
|
||||
// - media_cleanup:删除图集时异步清理S4孤儿图(引用计数归零才真删)
|
||||
type IngestService struct {
|
||||
repo repository.IngestRepository
|
||||
brandRepo repository.BrandRepository
|
||||
media repository.MediaRepository // 跨表图片引用计数(media_cleanup 删孤儿用)
|
||||
uploader storage.Uploader // 主上传器(七牛启用时为七牛,否则本地)
|
||||
del storage.Deleter // 删除器(七牛或本地,media_cleanup 真删用)
|
||||
local *storage.LocalUploader // 七牛失败时的兜底落地
|
||||
uploader storage.Uploader // 主上传器(S4启用时为S4,否则本地)
|
||||
del storage.Deleter // 删除器(S4或本地,media_cleanup 真删用)
|
||||
local *storage.LocalUploader // S4失败时的兜底落地
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewIngestService 创建入库服务。
|
||||
//
|
||||
// uploader: 主上传器(七牛或本地)
|
||||
// del: 删除器(七牛或本地),用于 media_cleanup 真删七牛/本地孤儿文件
|
||||
// local: 本地兜底上传器(七牛上传失败时回退,避免图片完全丢失)
|
||||
// uploader: 主上传器(S4或本地)
|
||||
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
||||
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService {
|
||||
return &IngestService{
|
||||
repo: repo,
|
||||
brandRepo: brandRepo,
|
||||
media: media,
|
||||
uploader: uploader,
|
||||
del: del,
|
||||
local: local,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
repo: repo,
|
||||
brandRepo: brandRepo,
|
||||
media: media,
|
||||
uploader: uploader,
|
||||
del: del,
|
||||
local: local,
|
||||
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
||||
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
||||
httpClient: &http.Client{
|
||||
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
|
||||
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: imageFetchConcurrency * 4,
|
||||
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,6 +143,7 @@ func (s *IngestService) drain(ctx context.Context, batch int) {
|
||||
// process 处理单条任务:先按 job.Kind(DB 列)分流到 crawl / media_cleanup 两条管线。
|
||||
// 旧爬虫任务(迁移前)kind 为空,回落 crawl 走秀/街拍入库管线。
|
||||
func (s *IngestService) process(ctx context.Context, job model.IngestJob) {
|
||||
start := time.Now()
|
||||
switch job.Kind {
|
||||
case model.IngestKindMediaCleanup:
|
||||
s.processMediaCleanup(ctx, job)
|
||||
@ -149,6 +164,7 @@ func (s *IngestService) process(ctx context.Context, job model.IngestJob) {
|
||||
if p.Kind == "" {
|
||||
p.Kind = dto.IngestKindRunway
|
||||
}
|
||||
log.Printf("[ingest] job=%d kind=%s 开始处理(图片=%d 张)", job.ID, p.Kind, len(p.Images))
|
||||
switch p.Kind {
|
||||
case dto.IngestKindRunway:
|
||||
s.processRunway(ctx, job, p)
|
||||
@ -158,15 +174,17 @@ func (s *IngestService) process(ctx context.Context, job model.IngestJob) {
|
||||
// 未知 kind 绝不静默当成走秀处理;所有爬虫数据都必须落到已注册的审核模块,
|
||||
// 否则标记任务失败,避免出现「未审核就入库」的脏数据。
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "unknown ingest kind: "+p.Kind)
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d kind=%s 处理结束 总耗时=%v", job.ID, p.Kind, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// MediaCleanupPayload 是「清理七牛孤儿图」任务的 payload:待清理的图片 key 列表。
|
||||
// MediaCleanupPayload 是「清理S4孤儿图」任务的 payload:待清理的图片 key 列表。
|
||||
type MediaCleanupPayload struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// EnqueueMediaCleanup 把一批待清理的七牛 key 异步入队;真正删除由 worker 的
|
||||
// EnqueueMediaCleanup 把一批待清理的S4 key 异步入队;真正删除由 worker 的
|
||||
// processMediaCleanup 按引用计数判定,仅当 key 在所有图集/草稿表中引用归零才真删
|
||||
// (内容寻址共享 key 不会被误删)。
|
||||
func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string) error {
|
||||
@ -180,7 +198,7 @@ func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string)
|
||||
return s.repo.EnqueueMediaCleanup(ctx, string(raw))
|
||||
}
|
||||
|
||||
// processMediaCleanup 清理七牛孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。
|
||||
// processMediaCleanup 清理S4孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。
|
||||
// 删除/计数失败一律保守跳过(宁可留文件),因此本任务几乎总是成功置 done。
|
||||
func (s *IngestService) processMediaCleanup(ctx context.Context, job model.IngestJob) {
|
||||
if s.media == nil || s.del == nil {
|
||||
@ -211,7 +229,7 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin
|
||||
}
|
||||
}
|
||||
|
||||
// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_draft。
|
||||
// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_drafts。
|
||||
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
||||
brandID, err := hashid.Decode(p.BrandUID)
|
||||
@ -228,26 +246,31 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
|
||||
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
|
||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||
dedupStart := time.Now()
|
||||
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found {
|
||||
log.Printf("[ingest] job=%d runway 实体去重命中(season=%s type=%s),跳过 耗时=%v",
|
||||
job.ID, seasonCode, p.CollectionType, time.Since(dedupStart).Round(time.Millisecond))
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d runway 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
|
||||
|
||||
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
|
||||
|
||||
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||
var cover string
|
||||
var cover string
|
||||
var draftImages []model.BrandRunwayDraftImage
|
||||
var keys []string
|
||||
var imgFailed bool
|
||||
var imageCount uint16
|
||||
var timing *fetchTiming
|
||||
if len(p.Looks) > 0 {
|
||||
cover, draftImages, keys, imgFailed = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
cover, draftImages, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
imageCount = uint16(len(p.Looks))
|
||||
} else {
|
||||
var fimgs []fetchedImage
|
||||
cover, fimgs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||
imageCount = uint16(len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
||||
@ -256,13 +279,13 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
ContentSha1: fi.sha1,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
log.Printf("[ingest] job=%d runway 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
|
||||
if imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
@ -271,8 +294,8 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
|
||||
writeStart := time.Now()
|
||||
draft := &model.BrandRunwayDraft{
|
||||
JobID: job.ID,
|
||||
BrandID: brandID,
|
||||
@ -300,6 +323,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d runway 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(draftImages))
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
@ -307,102 +331,109 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
||||
// 每张图入库前做去重:content_sha1 已存在则整行跳过(精确重复);命中 dHash 近重复则仍入库但标记留痕。
|
||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool) {
|
||||
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
|
||||
//
|
||||
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
||||
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
|
||||
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
|
||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool, *fetchTiming) {
|
||||
start := time.Now()
|
||||
timing := &fetchTiming{}
|
||||
cover := ""
|
||||
rows := make([]model.BrandRunwayDraftImage, 0)
|
||||
keys := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
order := 0
|
||||
|
||||
// 摊平:顺序 = 逐个 look 先主图、再其细节图,与原串行遍历顺序完全一致。
|
||||
tasks := make([]lookImageTask, 0, len(looks))
|
||||
for li, look := range looks {
|
||||
lookIdx := li + 1
|
||||
if look.Main != "" {
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
} else {
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
if cover == "" {
|
||||
cover = url
|
||||
}
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 0,
|
||||
ContentSha1: sha1h,
|
||||
Phash: sqlNull(ph),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
}
|
||||
tasks = append(tasks, lookImageTask{url: look.Main, lookIdx: lookIdx})
|
||||
}
|
||||
for di, d := range look.Details {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, d, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 1,
|
||||
ContentSha1: sha1h,
|
||||
Phash: sqlNull(ph),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
tasks = append(tasks, lookImageTask{url: d, lookIdx: lookIdx, isDetail: true, detailNo: di + 1})
|
||||
}
|
||||
}
|
||||
return cover, rows, keys, failed
|
||||
|
||||
// 并发段:下载 + phash + 上传(耗时大头)。
|
||||
results := concurrentFetch(ctx, len(tasks), func(c context.Context, i int) downloadResult {
|
||||
return s.downloadAndUpload(c, tasks[i].url, prefix)
|
||||
})
|
||||
|
||||
// 串行段:按原始顺序累计耗时、做批次内去重、组装行。
|
||||
order := 0
|
||||
for i, r := range results {
|
||||
task := tasks[i]
|
||||
timing.download += r.download
|
||||
timing.phash += r.phash
|
||||
timing.upload += r.upload
|
||||
if r.err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
keys = append(keys, r.key)
|
||||
if seen[r.sha1] {
|
||||
continue
|
||||
}
|
||||
seen[r.sha1] = true
|
||||
dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)
|
||||
order++
|
||||
if cover == "" && !task.isDetail {
|
||||
cover = r.url
|
||||
}
|
||||
name := fmt.Sprintf("Look %d", task.lookIdx)
|
||||
isDetail := uint8(0)
|
||||
if task.isDetail {
|
||||
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
|
||||
isDetail = 1
|
||||
}
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: r.url,
|
||||
Name: name,
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(task.lookIdx),
|
||||
IsDetail: isDetail,
|
||||
Phash: sqlNull(r.phashBits),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
}
|
||||
timing.images = len(rows)
|
||||
timing.total = time.Since(start)
|
||||
return cover, rows, keys, failed, timing
|
||||
}
|
||||
|
||||
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
|
||||
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||
// 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
|
||||
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
|
||||
dedupStart := time.Now()
|
||||
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found {
|
||||
log.Printf("[ingest] job=%d street 实体去重命中(city=%s year=%d),跳过 耗时=%v",
|
||||
job.ID, p.City, p.Year, time.Since(dedupStart).Round(time.Millisecond))
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d street 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
|
||||
|
||||
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地)。
|
||||
cover, fimgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
||||
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||
log.Printf("[ingest] job=%d street 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
|
||||
if len(p.Images) > 0 && imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
s.cleanupUploads(ctx, keys)
|
||||
s.failOrRetry(ctx, job.ID, "image download failed")
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snap 正式表
|
||||
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snaps 正式表
|
||||
writeStart := time.Now()
|
||||
draft := &model.StreetSnapDraft{
|
||||
JobID: job.ID,
|
||||
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
|
||||
@ -424,66 +455,73 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
ContentSha1: fi.sha1,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d street 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows))
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true,
|
||||
// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
|
||||
// prefix 为S4 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
|
||||
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
|
||||
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(精确跳过 + 近重复标记)。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool) {
|
||||
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(近重复标记)。
|
||||
//
|
||||
// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → phash → 上传」,再按下标顺序串行
|
||||
// 做去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool, *fetchTiming) {
|
||||
start := time.Now()
|
||||
timing := &fetchTiming{}
|
||||
cover := ""
|
||||
out := make([]fetchedImage, 0, len(urls))
|
||||
keys := make([]string, 0, len(urls))
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
for _, u := range urls {
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, u, 0, prefix)
|
||||
if err != nil {
|
||||
|
||||
// 并发段:下载 + phash + 上传(耗时大头)。
|
||||
results := concurrentFetch(ctx, len(urls), func(c context.Context, i int) downloadResult {
|
||||
return s.downloadAndUpload(c, urls[i], prefix)
|
||||
})
|
||||
|
||||
// 串行段:按原始顺序累计耗时、做批次内去重、组装结果。
|
||||
for _, r := range results {
|
||||
timing.download += r.download
|
||||
timing.phash += r.phash
|
||||
timing.upload += r.upload
|
||||
if r.err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, kind, sha1h, ph)
|
||||
if skip {
|
||||
keys = append(keys, r.key)
|
||||
if seen[r.sha1] {
|
||||
continue
|
||||
}
|
||||
seen[r.sha1] = true
|
||||
dupID, isDup := s.dedupImage(ctx, kind, r.phashBits, timing)
|
||||
if cover == "" {
|
||||
cover = url
|
||||
cover = r.url
|
||||
}
|
||||
out = append(out, fetchedImage{
|
||||
url: url,
|
||||
key: key,
|
||||
sha1: sha1h,
|
||||
phash: ph,
|
||||
url: r.url,
|
||||
key: r.key,
|
||||
phash: r.phashBits,
|
||||
dupID: dupID,
|
||||
isDup: isDup,
|
||||
})
|
||||
}
|
||||
return cover, out, keys, failed
|
||||
timing.images = len(out)
|
||||
timing.total = time.Since(start)
|
||||
return cover, out, keys, failed, timing
|
||||
}
|
||||
|
||||
// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚:
|
||||
// cleanupUploads 删除一批本批次成功上传的对象(S4 + 本地兜底),用于任务失败回滚:
|
||||
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
|
||||
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
|
||||
// 注意:key 为 sha1 内容寻址,若恰与其他已晋升图集共享同一内容哈希会被一并移除,重试会重新上传补齐。
|
||||
@ -491,7 +529,7 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
||||
for _, k := range keys {
|
||||
if d, ok := s.uploader.(storage.Deleter); ok {
|
||||
if err := d.Delete(ctx, k); err != nil {
|
||||
log.Printf("[warn] ingest cleanup: 七牛删除失败 key=%s err=%v", k, err)
|
||||
log.Printf("[warn] ingest cleanup: S4删除失败 key=%s err=%v", k, err)
|
||||
}
|
||||
}
|
||||
if s.local != nil {
|
||||
@ -506,25 +544,64 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
||||
type fetchedImage struct {
|
||||
url string
|
||||
key string
|
||||
sha1 string // 内容 sha1(与存储 key 同源)
|
||||
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
|
||||
dupID uint32 // 命中近重复时的参考图 id
|
||||
isDup uint8 // 是否标记为近重复(供审核留痕)
|
||||
}
|
||||
|
||||
// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。
|
||||
const slowImageThreshold = 2 * time.Second
|
||||
|
||||
// imageFetchConcurrency 单条入库任务内「下载 + 上传」的并发度。
|
||||
//
|
||||
// 实测结论(theimpression.com,59 张图):
|
||||
// - 并发 1:单张下载 ~3.7s,整批 59 张约 3m54s,任务成功。
|
||||
// - 并发 5:单张下载涨到 ~23s(约 6 倍),5m24s 仅完成 12/59 张便因 30s 超时失败。
|
||||
//
|
||||
// 即源站对同 IP 的并发连接有强限流:并发不仅不提速,反而把吞吐从 ~0.27 张/秒
|
||||
// 打到 ~0.04 张/秒并直接拖垮任务。因此默认取 1(等效串行)。
|
||||
// 换到不限流的源站时,可把这个值调大(机制已就绪),但务必先做小批量实测。
|
||||
const imageFetchConcurrency = 1
|
||||
|
||||
// fetchTiming 汇总一次图集拉取各阶段的累计耗时,用于定位「慢在哪一步」。
|
||||
// 下载 / 上传是网络 IO,phash 是 CPU(解码 + 哈希),近重复是 DB(HNSW 检索)——
|
||||
// 三类瓶颈成因不同,分开计时才能对症下药。
|
||||
type fetchTiming struct {
|
||||
images int // 成功入行的图片数
|
||||
total time.Duration // 整个拉取过程的墙钟耗时
|
||||
download time.Duration // 各张 HTTP 下载耗时之和
|
||||
phash time.Duration // 各张 dHash 指纹计算耗时之和
|
||||
upload time.Duration // 各张上传存储耗时之和
|
||||
dedup time.Duration // 近重复查询累计
|
||||
}
|
||||
|
||||
// String 输出各阶段耗时(毫秒精度),一条日志看清瓶颈。
|
||||
// 注意:下载 / phash / 上传是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
|
||||
// 因此三者与「总耗时」的比值不再等于时间占比——它们用于横向对比哪个阶段最重。
|
||||
func (t *fetchTiming) String() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("图片=%d 并发=%d 下载=%v phash=%v 上传=%v 近重复=%v(下载/phash/上传为各张累计值)",
|
||||
t.images,
|
||||
imageFetchConcurrency,
|
||||
t.download.Round(time.Millisecond),
|
||||
t.phash.Round(time.Millisecond),
|
||||
t.upload.Round(time.Millisecond),
|
||||
t.dedup.Round(time.Millisecond),
|
||||
)
|
||||
}
|
||||
|
||||
// sqlNull 把 phash 字符串转成可空向量字段:空串 → NULL(不参与近邻检索)。
|
||||
func sqlNull(ph string) sql.NullString {
|
||||
return sql.NullString{String: ph, Valid: ph != ""}
|
||||
}
|
||||
|
||||
// dedupImage 判断单张图是否重复:
|
||||
// - 精确重复(content_sha1 已在库)→ 返回 skip=true(不入库该行);
|
||||
// - 近重复(dHash 汉明距离 ≤ 阈值)→ 仍入库,但标记 is_duplicate + dup_of;
|
||||
// - 否则正常入库。
|
||||
func (s *IngestService) dedupImage(ctx context.Context, kind, sha1hash, phashBits string) (skip bool, dupID uint32, isDup uint8) {
|
||||
// dedupImage 判断单张图是否近重复(dHash 汉明距离 ≤ 阈值):命中则仍入库,但标记 is_duplicate + dup_of。
|
||||
func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string, timing *fetchTiming) (dupID uint32, isDup uint8) {
|
||||
// repo 未注入(如离线单测)时跳过去重,不阻断主流程。生产环境 repo 必不为空。
|
||||
if s.repo == nil {
|
||||
return false, 0, 0
|
||||
return 0, 0
|
||||
}
|
||||
var tables []string
|
||||
switch kind {
|
||||
@ -533,61 +610,135 @@ func (s *IngestService) dedupImage(ctx context.Context, kind, sha1hash, phashBit
|
||||
case dto.IngestKindStreet:
|
||||
tables = []string{"street_snap_draft_images", "street_snap_images"}
|
||||
default:
|
||||
return false, 0, 0
|
||||
}
|
||||
if exists, err := s.repo.ImageExistsBySha1(ctx, tables, sha1hash); err == nil && exists {
|
||||
return true, 0, 0
|
||||
return 0, 0
|
||||
}
|
||||
if phashBits != "" {
|
||||
if id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold); err == nil && found {
|
||||
return false, id, 1
|
||||
start := time.Now()
|
||||
id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold)
|
||||
if timing != nil {
|
||||
timing.dedup += time.Since(start)
|
||||
}
|
||||
if err == nil && found {
|
||||
return id, 1
|
||||
}
|
||||
}
|
||||
return false, 0, 0
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地),
|
||||
// 返回 (访问地址, 对象 key, 内容 sha1, dHash 向量串, error)。访问地址可直接写入数据库
|
||||
// (七牛为完整 https URL,本地为相对 /uploads 路径)。
|
||||
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, string, string, error) {
|
||||
// downloadResult 是单张图「下载 → phash → 上传」的结果,供并发执行后按下标串行组装。
|
||||
// 各阶段耗时随结果一并带回、由调用方在串行段汇总(而非直接累加到共享 timing),
|
||||
// 这样并发路径上无需加锁即可保证计时准确。
|
||||
type downloadResult struct {
|
||||
url string // 存储访问地址(失败为空)
|
||||
key string // 对象 key(内容寻址)
|
||||
sha1 string // 内容 sha1,用于批次内去重
|
||||
phashBits string // dHash 向量串,空串表示无法解码
|
||||
download time.Duration // 本张下载耗时
|
||||
phash time.Duration // 本张指纹计算耗时
|
||||
upload time.Duration // 本张上传耗时
|
||||
err error // 下载或上传失败原因(非空即该张失败)
|
||||
}
|
||||
|
||||
// concurrentFetch 以 imageFetchConcurrency 路并发执行 fn(ctx, i)(i ∈ [0,n)),返回长度 n、
|
||||
// 下标与入参一一对齐的结果切片。
|
||||
//
|
||||
// 下标对齐是刻意的:调用方随后按原始顺序串行组装,使 cover / sort_order / 批次内去重结果
|
||||
// 与串行实现完全一致,只把「下载+上传」这段 IO 并行化。
|
||||
// 每个下标只由一个 goroutine 写入,故无需加锁。
|
||||
func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) downloadResult) []downloadResult {
|
||||
results := make([]downloadResult, n)
|
||||
if n == 0 {
|
||||
return results
|
||||
}
|
||||
sem := make(chan struct{}, imageFetchConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{} // 占坑:超过并发度即在此排队
|
||||
defer func() { <-sem }() // 释放坑位
|
||||
results[i] = fn(ctx, i)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
|
||||
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。
|
||||
type lookImageTask struct {
|
||||
url string
|
||||
lookIdx int // 1-based look 序号
|
||||
isDetail bool // true=细节图
|
||||
detailNo int // 细节图序号(1-based),主图为 0
|
||||
}
|
||||
|
||||
// downloadAndUpload 把单张远程图下载后上传到存储(S4优先,失败兜底本地)。
|
||||
// 内容 sha1 用于生成内容寻址的存储 key(重爬幂等)与批次内去重;访问地址可直接写入数据库
|
||||
// (S4为完整 https URL,本地为相对 /uploads 路径)。
|
||||
//
|
||||
// 本函数是并发调用点:每次调用各自返回一份独立的 downloadResult,不共享可变状态;
|
||||
// 耗时汇总与去重留痕由调用方在串行段统一完成。
|
||||
func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string) downloadResult {
|
||||
res := downloadResult{}
|
||||
|
||||
// ① 下载
|
||||
dlStart := time.Now()
|
||||
resp, err := s.httpClient.Get(u)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
res.err = err
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
res.err = fmt.Errorf("status %d", resp.StatusCode)
|
||||
return res
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
res.err = err
|
||||
return res
|
||||
}
|
||||
res.download = time.Since(dlStart)
|
||||
|
||||
ext := path.Ext(u)
|
||||
if ext == "" || len(ext) > 5 {
|
||||
ext = ".jpg"
|
||||
}
|
||||
// 内容哈希作 key:同一张图(无论来自哪篇文章/job)永远得到相同 key,
|
||||
// 七牛覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。
|
||||
// S4覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。
|
||||
h := sha1.Sum(data)
|
||||
hash := hex.EncodeToString(h[:])
|
||||
key := fmt.Sprintf("%s/%s%s", prefix, hash, ext)
|
||||
res.sha1 = hex.EncodeToString(h[:])
|
||||
res.key = fmt.Sprintf("%s/%s%s", prefix, res.sha1, ext)
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
|
||||
// 计算 dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
|
||||
ph := phash.Of(data)
|
||||
phashBits := phash.ToVectorBits(ph)
|
||||
// ② dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
|
||||
phStart := time.Now()
|
||||
res.phashBits = phash.ToVectorBits(phash.Of(data))
|
||||
res.phash = time.Since(phStart)
|
||||
|
||||
// 主上传器(七牛)
|
||||
if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil {
|
||||
return url, key, hash, phashBits, nil
|
||||
// ③ 上传(主存储 S4,失败兜底本地)
|
||||
upStart := time.Now()
|
||||
if url, uerr := s.uploader.Upload(ctx, res.key, data, ct); uerr == nil {
|
||||
res.url = url
|
||||
} else if s.local != nil {
|
||||
// 兜底本地,避免图片完全丢失
|
||||
if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil {
|
||||
return lurl, key, hash, phashBits, nil
|
||||
if lurl, lerr := s.local.Upload(ctx, res.key, data, ct); lerr == nil {
|
||||
res.url = lurl
|
||||
} else {
|
||||
return "", "", "", "", err
|
||||
res.err = uerr
|
||||
}
|
||||
} else {
|
||||
res.err = uerr
|
||||
}
|
||||
return "", "", "", "", err
|
||||
}
|
||||
res.upload = time.Since(upStart)
|
||||
|
||||
// 单图异常慢(合计超阈值)单独告警:便于从几十上百张图里一眼锁定是哪张、卡在哪一段。
|
||||
if total := res.download + res.phash + res.upload; total > slowImageThreshold {
|
||||
log.Printf("[ingest] 慢图 url=%s 合计=%v 下载=%v phash=%v 上传=%v",
|
||||
u, total.Round(time.Millisecond),
|
||||
res.download.Round(time.Millisecond), res.phash.Round(time.Millisecond), res.upload.Round(time.Millisecond))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ func (c *countingDeleter) Delete(ctx context.Context, key string) error {
|
||||
return c.LocalUploader.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// TestProcessMediaCleanup 验证「清理七牛孤儿图」任务:引用计数为 0 的 key 会被真删。
|
||||
// TestProcessMediaCleanup 验证「清理S4孤儿图」任务:引用计数为 0 的 key 会被真删。
|
||||
func TestProcessMediaCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
up := storage.NewLocalUploader(dir, "/uploads")
|
||||
|
||||
@ -8,14 +8,14 @@ import (
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// purgeOrphanImages 清理七牛/本地孤儿图片:仅当某 key 在所有图集/草稿表中
|
||||
// purgeOrphanImages 清理S4/本地孤儿图片:仅当某 key 在所有图集/草稿表中
|
||||
// 引用计数归零时才真正删除对象;否则保留(仍有其他图集在用,避免误删)。
|
||||
//
|
||||
// 设计要点(为何必须引用计数而非「删库即删七牛」):
|
||||
// 七牛 key 为 sha1 内容寻址,同一张图跨图集共享同一 key。直接删会误伤其他图集。
|
||||
// 设计要点(为何必须引用计数而非「删库即删S4」):
|
||||
// S4 key 为 sha1 内容寻址,同一张图跨图集共享同一 key。直接删会误伤其他图集。
|
||||
// 另外,删除失败/计数失败一律「保守跳过」——宁可留文件,不冒误删被引用文件的风险。
|
||||
//
|
||||
// 参数 del 为 storage.Deleter(七牛或本地实现),由装配点按配置注入。
|
||||
// 参数 del 为 storage.Deleter(S4或本地实现),由装配点按配置注入。
|
||||
func purgeOrphanImages(ctx context.Context, media repository.MediaRepository, del storage.Deleter, keys []string) {
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
@ -37,7 +37,7 @@ func purgeOrphanImages(ctx context.Context, media repository.MediaRepository, de
|
||||
continue
|
||||
}
|
||||
if err := del.Delete(ctx, k); err != nil {
|
||||
log.Printf("[purge] 七牛/本地删除失败,保留 key=%s: %v", k, err)
|
||||
log.Printf("[purge] S4/本地删除失败,保留 key=%s: %v", k, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[purge] 已清理孤儿图片 key=%s", k)
|
||||
|
||||
@ -100,7 +100,7 @@ type ReviewService interface {
|
||||
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||||
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||
KindTabs(ctx context.Context) []KindTab
|
||||
@ -116,8 +116,8 @@ type reviewService struct {
|
||||
}
|
||||
|
||||
// NewReviewService 创建审核服务并注册所有已知模块。
|
||||
// img 负责把库里存储的七牛 key 拼装为高清 URL(后台审核预览始终看高清原图);
|
||||
// media + deleter 用于审核阶段剔除坏图时清理七牛孤儿文件(引用计数归零才真删)。
|
||||
// img 负责把库里存储的S4 key 拼装为高清 URL(后台审核预览始终看高清原图);
|
||||
// media + deleter 用于审核阶段剔除坏图时清理S4孤儿文件(引用计数归零才真删)。
|
||||
func NewReviewService(repo repository.ReviewRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter) ReviewService {
|
||||
svc := &reviewService{
|
||||
repo: repo,
|
||||
@ -234,7 +234,7 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。
|
||||
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count;
|
||||
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理七牛孤儿文件。
|
||||
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
|
||||
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
||||
if _, ok := s.module(kind); !ok {
|
||||
return repository.ErrNotFound
|
||||
|
||||
@ -30,7 +30,7 @@ type StreetSnapService interface {
|
||||
SetCover(ctx context.Context, snapID uint32, image string) error
|
||||
// SetDeleted 上下架:deleted=1 下架,0 上架。
|
||||
SetDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||
// PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。
|
||||
// PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。
|
||||
PurgeGallery(ctx context.Context, id uint32) error
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ type streetSnapService struct {
|
||||
media repository.MediaRepository
|
||||
img *imgurl.Composer
|
||||
deleter storage.Deleter
|
||||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口
|
||||
cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口
|
||||
}
|
||||
|
||||
// NewStreetSnapService 创建街拍服务。
|
||||
@ -209,7 +209,7 @@ func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields ma
|
||||
}
|
||||
|
||||
func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) error {
|
||||
// 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。
|
||||
// 先取 key(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。
|
||||
key, err := s.repo.GetSnapImageKey(ctx, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -229,14 +229,14 @@ func (s *streetSnapService) SetCover(ctx context.Context, snapID uint32, image s
|
||||
|
||||
func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||
if deleted == 1 {
|
||||
// 下架街拍:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。
|
||||
// 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。
|
||||
return s.PurgeGallery(ctx, id)
|
||||
}
|
||||
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
|
||||
return s.repo.SetSnapDeleted(ctx, id, 0)
|
||||
}
|
||||
|
||||
// PurgeGallery 下架街拍并异步清理七牛孤儿图:逻辑同 articleService.PurgeGallery,
|
||||
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
||||
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
||||
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
||||
@ -248,7 +248,7 @@ func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
}
|
||||
if len(keys) > 0 && s.cleaner != nil {
|
||||
if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil {
|
||||
log.Printf("[warn] 下架街拍 %d 入队清理七牛失败(文件将暂留): %v", id, err)
|
||||
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user