update
This commit is contained in:
@ -70,6 +70,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
"fashionapi/internal/config"
|
"fashionapi/internal/config"
|
||||||
|
"fashionapi/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// column 是单列的结构信息,用于生成 CREATE TABLE、INSERT 列清单与序列重置语句。
|
// column 是单列的结构信息,用于生成 CREATE TABLE、INSERT 列清单与序列重置语句。
|
||||||
@ -110,6 +111,8 @@ func main() {
|
|||||||
runImport(os.Args[2:])
|
runImport(os.Args[2:])
|
||||||
case "fixdup":
|
case "fixdup":
|
||||||
runFixDup(os.Args[2:])
|
runFixDup(os.Args[2:])
|
||||||
|
case "filltitlecn":
|
||||||
|
runFillTitleCn(os.Args[2:])
|
||||||
case "purge-rejected":
|
case "purge-rejected":
|
||||||
runPurgeRejected(os.Args[2:])
|
runPurgeRejected(os.Args[2:])
|
||||||
case "migrate":
|
case "migrate":
|
||||||
@ -128,8 +131,13 @@ func usage() {
|
|||||||
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||||
dbtool import [-config <yml>] [-in <file>] [-clean]
|
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||||
把 dump 出来的 SQL 文件灌入目标库
|
把 dump 出来的 SQL 文件灌入目标库
|
||||||
dbtool fixdup [-config <yml>]
|
dbtool fixdup [-config <yml>] [-dry-run]
|
||||||
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
||||||
|
-dry-run 只统计/列出将受影响的行,不实际改写(推荐先跑一次确认)
|
||||||
|
dbtool filltitlecn [-config <yml>] [-dry-run]
|
||||||
|
历史回填中文标题:扫描 brand_runways / street_snaps 里 title_cn 为空且英文标题
|
||||||
|
非空的存活记录,用与入库同源的规则(记忆优先、规则兜底)补全;规则无法完整翻译
|
||||||
|
的行跳过(不写脏数据)。-dry-run 只统计/列出,不实际改写(推荐先跑一次确认)
|
||||||
dbtool purge-rejected [-config <yml>]
|
dbtool purge-rejected [-config <yml>]
|
||||||
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
|
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
|
||||||
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
|
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
|
||||||
@ -304,9 +312,17 @@ func runImport(args []string) {
|
|||||||
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
||||||
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
||||||
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||||
|
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
||||||
|
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
||||||
|
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
||||||
|
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||||
|
//
|
||||||
|
// -dry-run 只统计并打印将受影响的行(含其 dup_of,便于确认源确已消失),不实际改写;
|
||||||
|
// 不带该参数则直接 UPDATE。
|
||||||
func runFixDup(args []string) {
|
func runFixDup(args []string) {
|
||||||
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
|
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
|
||||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
dryRun := fs.Bool("dry-run", false, "只统计并打印将受影响的行,不实际改写")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
db, _, err := openDB(*cfgPath)
|
db, _, err := openDB(*cfgPath)
|
||||||
@ -316,12 +332,53 @@ func runFixDup(args []string) {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
for _, t := range []string{"brand_runway_images", "street_snap_images"} {
|
for _, t := range []string{"brand_runway_images", "street_snap_images"} {
|
||||||
|
// 悬空判定:标了重复,但其 dup_of 为空 / 为 NULL / 不在「存活源」集合里。
|
||||||
|
// 子查询只取 is_deleted=0 的 id,因此「源已软删」与「源行已不存在」两种情况都被覆盖。
|
||||||
|
where := `is_duplicate = 1
|
||||||
|
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
|
||||||
|
SELECT CAST(id AS text) FROM public.` + qi(t) + ` WHERE is_deleted = 0))`
|
||||||
|
|
||||||
|
if *dryRun {
|
||||||
|
var cnt int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM public.` + qi(t) + ` WHERE ` + where).Scan(&cnt); err != nil {
|
||||||
|
log.Fatalf("✗ 统计 %s 失败: %v", t, err)
|
||||||
|
}
|
||||||
|
log.Printf("→ %s: 将清理悬空重复标记 %d 行(dry-run,未改写)", t, cnt)
|
||||||
|
const sampleLimit = 50
|
||||||
|
if cnt > 0 {
|
||||||
|
rows, qerr := db.Query(fmt.Sprintf(
|
||||||
|
`SELECT id, dup_of FROM public.%s WHERE %s ORDER BY id LIMIT %d`, qi(t), where, sampleLimit))
|
||||||
|
if qerr != nil {
|
||||||
|
log.Fatalf("✗ 列举 %s 失败: %v", t, qerr)
|
||||||
|
}
|
||||||
|
type aff struct {
|
||||||
|
ID uint32
|
||||||
|
DupOf string
|
||||||
|
}
|
||||||
|
var list []aff
|
||||||
|
for rows.Next() {
|
||||||
|
var a aff
|
||||||
|
if err := rows.Scan(&a.ID, &a.DupOf); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Fatalf("✗ 扫描 %s 失败: %v", t, err)
|
||||||
|
}
|
||||||
|
list = append(list, a)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, a := range list {
|
||||||
|
log.Printf(" id=%d dup_of=%q", a.ID, a.DupOf)
|
||||||
|
}
|
||||||
|
if cnt > sampleLimit {
|
||||||
|
log.Printf(" …(仅显示前 %d 行,共 %d 行)", sampleLimit, cnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
q := fmt.Sprintf(`
|
||||||
UPDATE public.%[1]s
|
UPDATE public.%[1]s
|
||||||
SET is_duplicate = 0, dup_of = ''
|
SET is_duplicate = 0, dup_of = ''
|
||||||
WHERE is_duplicate = 1
|
WHERE %[2]s;`, qi(t), where)
|
||||||
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
|
|
||||||
SELECT CAST(id AS text) FROM public.%[1]s WHERE is_deleted = 0));`, qi(t))
|
|
||||||
res, err := db.Exec(q)
|
res, err := db.Exec(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("✗ 清理 %s 失败: %v", t, err)
|
log.Fatalf("✗ 清理 %s 失败: %v", t, err)
|
||||||
@ -337,6 +394,128 @@ WHERE is_duplicate = 1
|
|||||||
//
|
//
|
||||||
// 与后台点「删除(清空 S4)」完全等价(后者走 articleService/streetSnapService.SetDeleted →
|
// 与后台点「删除(清空 S4)」完全等价(后者走 articleService/streetSnapService.SetDeleted →
|
||||||
// PurgeGallery),但本命令一次性全局处理历史遗留的 rejected 数据,无需逐条手动操作。
|
// PurgeGallery),但本命令一次性全局处理历史遗留的 rejected 数据,无需逐条手动操作。
|
||||||
|
// runFillTitleCn 历史回填中文标题:扫描两张表里 title_cn 为空且英文标题非空的存活记录,
|
||||||
|
// 用与入库同源的规则补全(记忆优先:先查 title_cn_memory;否则规则兜底)。
|
||||||
|
// 规则无法完整翻译的行跳过(不写脏数据)。-dry-run 只统计/列出,不实际改写。
|
||||||
|
func runFillTitleCn(args []string) {
|
||||||
|
fs := flag.NewFlagSet("filltitlecn", flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
dryRun := fs.Bool("dry-run", false, "只统计/列出将受影响的行,不实际改写")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
db, _, err := openDB(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// 加载品牌映射与翻译记忆(与入库/编辑同源)。
|
||||||
|
brandCN, err := loadBrandCNMap(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 加载品牌映射失败: %v", err)
|
||||||
|
}
|
||||||
|
mem, err := loadTitleCnMemory(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 加载翻译记忆失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := []struct {
|
||||||
|
table, enCol, cnCol, idCol string
|
||||||
|
}{
|
||||||
|
{"brand_runways", "title_en", "title_cn", "id"},
|
||||||
|
{"street_snaps", "title", "title_cn", "id"},
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
rows, qerr := db.QueryContext(context.Background(), fmt.Sprintf(
|
||||||
|
`SELECT %[1]s, %[2]s FROM public.%[3]s WHERE is_deleted = 0 AND (%[4]s IS NULL OR %[4]s = '') AND %[2]s IS NOT NULL AND %[2]s <> ''`,
|
||||||
|
qi(t.idCol), qi(t.enCol), qi(t.table), qi(t.cnCol)))
|
||||||
|
if qerr != nil {
|
||||||
|
log.Fatalf("✗ 扫描 %s 失败: %v", t.table, qerr)
|
||||||
|
}
|
||||||
|
type aff struct{ ID uint32; En, Cn string }
|
||||||
|
var list []aff
|
||||||
|
for rows.Next() {
|
||||||
|
var id uint32
|
||||||
|
var en string
|
||||||
|
if err := rows.Scan(&id, &en); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Fatalf("✗ 扫描 %s 失败: %v", t.table, err)
|
||||||
|
}
|
||||||
|
if cn, ok := mem[en]; ok && cn != "" {
|
||||||
|
list = append(list, aff{id, en, cn})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cn, ok := service.RuleTranslate(en, brandCN); ok && cn != "" {
|
||||||
|
list = append(list, aff{id, en, cn})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if *dryRun {
|
||||||
|
log.Printf("→ %s: 将补全中文标题 %d 行(dry-run,未改写)", t.table, len(list))
|
||||||
|
const limit = 50
|
||||||
|
for i, a := range list {
|
||||||
|
if i >= limit {
|
||||||
|
log.Printf(" …(仅显示前 %d 行,共 %d 行)", limit, len(list))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf(" id=%d %q → %q", a.ID, a.En, a.Cn)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated int
|
||||||
|
for _, a := range list {
|
||||||
|
res, uerr := db.ExecContext(context.Background(), fmt.Sprintf(
|
||||||
|
`UPDATE public.%s SET %s = $1 WHERE %s = $2`, qi(t.table), qi(t.cnCol), qi(t.idCol)),
|
||||||
|
a.Cn, a.ID)
|
||||||
|
if uerr != nil {
|
||||||
|
log.Fatalf("✗ 更新 %s id=%d 失败: %v", t.table, a.ID, uerr)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("✓ %s: 补全中文标题 %d 行", t.table, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBrandCNMap 取全部存活品牌的 name_en(小写)→name_cn 映射(与入库翻译共用)。
|
||||||
|
func loadBrandCNMap(db *sql.DB) (map[string]string, error) {
|
||||||
|
rows, err := db.QueryContext(context.Background(), `SELECT LOWER(name_en), name_cn FROM public.brands WHERE is_deleted = 0 AND name_en <> ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
m := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var en, cn string
|
||||||
|
if err := rows.Scan(&en, &cn); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m[en] = strings.TrimSpace(cn)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTitleCnMemory 取人工翻译记忆(英文标题→中文标题),回填时优先命中。
|
||||||
|
func loadTitleCnMemory(db *sql.DB) (map[string]string, error) {
|
||||||
|
rows, err := db.QueryContext(context.Background(), `SELECT title_en, title_cn FROM public.title_cn_memory`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
m := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var en, cn string
|
||||||
|
if err := rows.Scan(&en, &cn); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m[en] = cn
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runPurgeRejected(args []string) {
|
func runPurgeRejected(args []string) {
|
||||||
fs := flag.NewFlagSet("purge-rejected", flag.ExitOnError)
|
fs := flag.NewFlagSet("purge-rejected", flag.ExitOnError)
|
||||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
|||||||
@ -123,6 +123,10 @@ func main() {
|
|||||||
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
|
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
|
||||||
mediaRepo = repository.NewMediaRepository(db)
|
mediaRepo = repository.NewMediaRepository(db)
|
||||||
|
|
||||||
|
// 标题翻译:记忆仓储 + 服务(记忆优先、规则兜底)。入库自动补中文标题、后台编辑记记忆。
|
||||||
|
translateRepo = repository.NewTranslateRepository(db)
|
||||||
|
translateSvc = service.NewTranslateService(brandRepo, translateRepo)
|
||||||
|
|
||||||
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||||
|
|
||||||
// S4 上传器(凭证齐全才真正启用;否则为零值,图片回落本地兜底)。
|
// S4 上传器(凭证齐全才真正启用;否则为零值,图片回落本地兜底)。
|
||||||
@ -167,7 +171,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy)
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc)
|
||||||
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
||||||
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
@ -185,7 +189,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
||||||
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo)
|
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo, translateSvc)
|
||||||
// 爬虫上报 handler(HMAC 验签入口)
|
// 爬虫上报 handler(HMAC 验签入口)
|
||||||
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ server:
|
|||||||
# gin 运行模式:debug(开发,输出彩色路由日志)| release(生产)| test
|
# gin 运行模式:debug(开发,输出彩色路由日志)| release(生产)| test
|
||||||
mode: debug # env: GIN_MODE
|
mode: debug # env: GIN_MODE
|
||||||
# 优雅关闭的最长等待时间(秒)
|
# 优雅关闭的最长等待时间(秒)
|
||||||
shutdown_timeout: 10
|
shutdown_timeout: 60
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: 127.0.0.1 # env: DB_HOST
|
host: 127.0.0.1 # env: DB_HOST
|
||||||
|
|||||||
12
db/migrations/2026-09-30-02-title-cn-memory.sql
Normal file
12
db/migrations/2026-09-30-02-title-cn-memory.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- 标题中文翻译记忆表:记录「英文标题 → 人工确认的中文标题」映射。
|
||||||
|
--
|
||||||
|
-- 用途:标题高度固定(品牌/年份/季节/系列/城市),绝大多数可规则翻译;
|
||||||
|
-- 人工在后台纠正过的中文标题写入本表,之后无论入库还是编辑都优先命中,永久精确。
|
||||||
|
-- 写入方:仅由人工编辑保存时(BackstageHandler 的 Remember)upsert;
|
||||||
|
-- 入库(ingest)只读取不写入,避免把规则猜测的半成品固化成「真理」。
|
||||||
|
-- 读取方:TranslateService.SuggestTitleCn 记忆优先、规则兜底。
|
||||||
|
CREATE TABLE IF NOT EXISTS title_cn_memory (
|
||||||
|
title_en text NOT NULL PRIMARY KEY,
|
||||||
|
title_cn text NOT NULL,
|
||||||
|
updated_at integer NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
6
db/migrations/2026-09-30-03-street-snap-title-cn.sql
Normal file
6
db/migrations/2026-09-30-03-street-snap-title-cn.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- 确保 street_snaps 存在 title_cn 列(中英文标题并存:title 作英文源,title_cn 供后台翻译/前端 locale)。
|
||||||
|
--
|
||||||
|
-- 该列本应在 2026-09-22-01 单表发布迁移里通过 ADD COLUMN IF NOT EXISTS 加上,但部分从更早 dump
|
||||||
|
-- 恢复的库漏跑了这条,导致 street_snaps 缺列、街拍落库写 title_cn 时报「列不存在」。
|
||||||
|
-- 这里用 IF NOT EXISTS 幂等补齐;已存在的库执行本文件直接跳过,无副作用。
|
||||||
|
ALTER TABLE IF EXISTS street_snaps ADD COLUMN IF NOT EXISTS title_cn text NOT NULL DEFAULT '';
|
||||||
3284
db_dump.sql
3284
db_dump.sql
File diff suppressed because one or more lines are too long
@ -207,9 +207,56 @@
|
|||||||
if (focusMain) setExpanded(focusMain, true);
|
if (focusMain) setExpanded(focusMain, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 编辑页:实时翻译建议 + 季节码预览 ──────────────────────────────
|
||||||
|
// 与后端 pkg/season.Derive 同规则;仅用于只读预览,服务端保存时仍按同规则重算 season_code。
|
||||||
|
function deriveSeasonCode(year, collectionType, season) {
|
||||||
|
if (!year) return '';
|
||||||
|
var y = String(year).slice(-2);
|
||||||
|
var prefix = 'SS';
|
||||||
|
switch (collectionType) {
|
||||||
|
case 'resort': prefix = 'RES'; break;
|
||||||
|
case 'pre_fall': prefix = 'PF'; break;
|
||||||
|
case 'couture': case 'menswear': prefix = 'FW'; break;
|
||||||
|
default:
|
||||||
|
if (season === 'fall' || season === 'winter') prefix = 'FW';
|
||||||
|
else prefix = 'SS';
|
||||||
|
}
|
||||||
|
return prefix + y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEditForm() {
|
||||||
|
var en = document.getElementById('rw-title-en') || document.getElementById('snap-title-en');
|
||||||
|
if (!en) return;
|
||||||
|
var cn = document.getElementById('rw-title-cn') || document.getElementById('snap-title-cn');
|
||||||
|
|
||||||
|
// 英文标题失焦且中文为空时,拉建议回填(不覆盖手填)。
|
||||||
|
en.addEventListener('blur', function () {
|
||||||
|
if (cn && cn.value.trim() === '') {
|
||||||
|
fetch('/admin/api/suggest-title-cn?title_en=' + encodeURIComponent(en.value))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { if (d && d.title_cn) cn.value = d.title_cn; })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 走秀:年份/季节/系列变动即时算季节码(只读预览;服务端保存时仍按同规则重算)。
|
||||||
|
var year = document.getElementById('rw-year');
|
||||||
|
var season = document.getElementById('rw-season');
|
||||||
|
var collection = document.getElementById('rw-collection');
|
||||||
|
var code = document.getElementById('rw-season-code');
|
||||||
|
function refreshCode() {
|
||||||
|
if (!year || !code) return;
|
||||||
|
code.value = deriveSeasonCode(year.value, collection ? collection.value : '', season ? season.value : '');
|
||||||
|
}
|
||||||
|
if (year) year.addEventListener('input', refreshCode);
|
||||||
|
if (season) season.addEventListener('input', refreshCode);
|
||||||
|
if (collection) collection.addEventListener('input', refreshCode);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
initZoom();
|
initZoom();
|
||||||
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
|
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
|
||||||
if (document.getElementById('batch')) initArticleDetail();
|
if (document.getElementById('batch')) initArticleDetail();
|
||||||
|
initEditForm();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -5,14 +5,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/middleware"
|
"fashionapi/internal/middleware"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/repository"
|
|
||||||
"fashionapi/internal/pkg/pagination"
|
"fashionapi/internal/pkg/pagination"
|
||||||
|
"fashionapi/internal/pkg/season"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -34,11 +36,12 @@ type BackstageHandler struct {
|
|||||||
review service.ReviewService
|
review service.ReviewService
|
||||||
ingest *service.IngestService
|
ingest *service.IngestService
|
||||||
adminRepo repository.AdminRepository
|
adminRepo repository.AdminRepository
|
||||||
|
translate *service.TranslateService // 标题翻译(可为 nil:未启用时跳过记忆写入/建议)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBackstageHandler 创建后台 handler。
|
// NewBackstageHandler 创建后台 handler。
|
||||||
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository) *BackstageHandler {
|
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository, translate *service.TranslateService) *BackstageHandler {
|
||||||
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
|
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo, translate: translate}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
|
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
|
||||||
@ -534,6 +537,23 @@ type ingestJobView struct {
|
|||||||
|
|
||||||
// IngestJobs 分页列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
|
// IngestJobs 分页列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
|
||||||
// 支持 ?page(从 1 起)与 ?pageSize(默认 50,上限 200);其余查询串原样忽略。
|
// 支持 ?page(从 1 起)与 ?pageSize(默认 50,上限 200);其余查询串原样忽略。
|
||||||
|
// SuggestTitleCn 后台编辑页实时翻译建议:给定英文标题,返回建议中文标题(JSON)。
|
||||||
|
// 仅对已登录后台暴露;翻译服务未注入时返回空 title_cn(前端据此不回填)。
|
||||||
|
func (h *BackstageHandler) SuggestTitleCn(c *gin.Context) {
|
||||||
|
en := c.Query("title_en")
|
||||||
|
if strings.TrimSpace(en) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing title_en"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cn := ""
|
||||||
|
if h.translate != nil {
|
||||||
|
if s, err := h.translate.SuggestTitleCn(c.Request.Context(), en); err == nil {
|
||||||
|
cn = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"title_cn": cn})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
|
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
|
||||||
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
|
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
|
||||||
offset := p.Offset()
|
offset := p.Offset()
|
||||||
@ -754,15 +774,24 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) {
|
|||||||
year = uint16(y)
|
year = uint16(y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
seasonVal := c.PostForm("season")
|
||||||
|
collectionType := c.PostForm("collection_type")
|
||||||
|
titleEn := c.PostForm("title_en")
|
||||||
|
titleCn := strings.TrimSpace(c.PostForm("title_cn"))
|
||||||
fields := map[string]any{
|
fields := map[string]any{
|
||||||
"title_en": c.PostForm("title_en"),
|
"title_en": titleEn,
|
||||||
"title_cn": c.PostForm("title_cn"),
|
"title_cn": titleCn,
|
||||||
"description_en": c.PostForm("description_en"),
|
"description_en": c.PostForm("description_en"),
|
||||||
"description_cn": c.PostForm("description_cn"),
|
"description_cn": c.PostForm("description_cn"),
|
||||||
"season": c.PostForm("season"),
|
"season": seasonVal,
|
||||||
"collection_type": c.PostForm("collection_type"),
|
"collection_type": collectionType,
|
||||||
"season_code": c.PostForm("season_code"),
|
// season_code 一律由「年份 + 系列 + 季节」推导,忽略表单原值,杜绝错码(见 pkg/season)。
|
||||||
"year": year,
|
"season_code": season.Derive(year, collectionType, seasonVal),
|
||||||
|
"year": year,
|
||||||
|
}
|
||||||
|
// 人工填写的中文标题写入翻译记忆,供后续入库/编辑优先命中(永久精确);翻译服务未注入时跳过。
|
||||||
|
if titleCn != "" && h.translate != nil {
|
||||||
|
_ = h.translate.Remember(c.Request.Context(), titleEn, titleCn)
|
||||||
}
|
}
|
||||||
if err := h.article.UpdateRunway(c.Request.Context(), numeric, fields); err != nil {
|
if err := h.article.UpdateRunway(c.Request.Context(), numeric, fields); err != nil {
|
||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
@ -999,12 +1028,18 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
|||||||
year = uint16(y)
|
year = uint16(y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
titleEn := c.PostForm("title")
|
||||||
|
titleCn := strings.TrimSpace(c.PostForm("title_cn"))
|
||||||
fields := map[string]any{
|
fields := map[string]any{
|
||||||
"title": c.PostForm("title"),
|
"title": titleEn,
|
||||||
"title_cn": c.PostForm("title_cn"),
|
"title_cn": titleCn,
|
||||||
"city": c.PostForm("city"),
|
"city": c.PostForm("city"),
|
||||||
"year": year,
|
"year": year,
|
||||||
}
|
}
|
||||||
|
// 人工填写的中文标题写入翻译记忆,供后续入库/编辑优先命中(永久精确);翻译服务未注入时跳过。
|
||||||
|
if titleCn != "" && h.translate != nil {
|
||||||
|
_ = h.translate.Remember(c.Request.Context(), titleEn, titleCn)
|
||||||
|
}
|
||||||
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
|
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
|
||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@ -7,10 +7,16 @@
|
|||||||
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
|
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
|
||||||
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
|
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
|
||||||
<form method="post" action="/admin/runways/{{.UID}}/edit">
|
<form method="post" action="/admin/runways/{{.UID}}/edit">
|
||||||
<div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div>
|
<div class="row"><div><label>英文标题</label><input name="title_en" id="rw-title-en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" id="rw-title-cn" value="{{.Runway.TitleCn}}"></div></div>
|
||||||
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>
|
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>
|
||||||
<div class="row"><div><label>年份</label><input name="year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" value="{{.Runway.Season}}"></div></div>
|
<div class="row"><div><label>年份</label><input name="year" id="rw-year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" id="rw-season" list="season-list" value="{{.Runway.Season}}"></div></div>
|
||||||
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" value="{{.Runway.CollectionType}}"></div><div><label>季节码 (season_code)</label><input name="season_code" value="{{.Runway.SeasonCode}}"></div></div>
|
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" id="rw-collection" list="collection-list" value="{{.Runway.CollectionType}}"></div><div><label>季节码(自动)</label><input name="season_code" id="rw-season-code" value="{{.Runway.SeasonCode}}" readonly></div></div>
|
||||||
|
<datalist id="season-list">
|
||||||
|
<option value="Spring"><option value="Summer"><option value="Fall"><option value="Winter"><option value="Resort"><option value="Pre-Spring"><option value="Pre-Fall">
|
||||||
|
</datalist>
|
||||||
|
<datalist id="collection-list">
|
||||||
|
<option value="rtw"><option value="menswear"><option value="couture"><option value="resort"><option value="pre_fall">
|
||||||
|
</datalist>
|
||||||
<button type="submit">保存字段</button>
|
<button type="submit">保存字段</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
|
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
|
||||||
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
|
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
|
||||||
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
|
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
|
||||||
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
|
<div class="row"><div><label>英文标题</label><input name="title" id="snap-title-en" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" id="snap-title-cn" value="{{.Snap.TitleCn}}"></div></div>
|
||||||
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
|
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
|
||||||
<button type="submit">保存字段</button>
|
<button type="submit">保存字段</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -46,6 +46,10 @@ type ArticleRepository interface {
|
|||||||
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||||
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
|
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
|
||||||
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
|
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
|
||||||
|
// ReresolveRunwayDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||||
|
ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
||||||
|
// GetRunwayLiveImageIDs 取某走秀全部存活图片 id(级联下架前用于跨文章去重清理)。
|
||||||
|
GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error)
|
||||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||||
@ -480,6 +484,60 @@ func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID u
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReresolveRunwayDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记。
|
||||||
|
// 给定刚被软删的走秀图片 id 列表,找出所有仍存活(其它文章)且 dup_of 指向它们的图片,
|
||||||
|
// 在全局存活图里重新近重复判定:找到新源则改指新源,否则清掉重复标记。
|
||||||
|
// 为什么需要:删 B 文章/图时,A 文章中 dup_of 指向 B 图的行不会被 ReresolveRunwayDedup
|
||||||
|
// (仅本集内重算)清理,导致 A 仍误显「重复」且「查看来源」指向已删图打不开。
|
||||||
|
func (r *articleRepository) ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
||||||
|
if len(deletedImageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
strIDs := make([]string, 0, len(deletedImageIDs))
|
||||||
|
for _, id := range deletedImageIDs {
|
||||||
|
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
var affected []model.BrandRunwayImage
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
||||||
|
Find(&affected).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(affected) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, im := range affected {
|
||||||
|
if !im.Phash.Valid || im.Phash.String == "" {
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.RunwayID, im.SortOrder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if src != 0 && src != im.ID {
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
|
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
|
||||||
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
|
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
|
||||||
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
@ -554,6 +612,18 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRunwayLiveImageIDs 取某走秀全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
||||||
|
func (r *articleRepository) GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error) {
|
||||||
|
var ids []uint32
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
|
Pluck("id", &ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
|
|||||||
@ -408,3 +408,49 @@ func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
|
|||||||
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 回归「跨文章」去重清理:
|
||||||
|
// A(重复方)的图 dup_of 指向 B(源方)的图;删掉整篇 B 后,A 的图不应再挂指向已删图的 dup_of,
|
||||||
|
// 否则编辑页继续误显「重复」且「查看来源」打不开。对应线上「删了 B,A 的重复标记还在」。
|
||||||
|
func TestEditorCrossArticleRunwayDupClearedOnSourceDeleted(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
// 两篇相互独立文章(不同实体键),各含一张近重复图(同 phash)。
|
||||||
|
srcRunway := seedEditorRunway(t, db, 1) // 源方 B
|
||||||
|
dupRunway := seedEditorRunway(t, db, 1) // 重复方 A
|
||||||
|
allOne := phash.ToVectorBits(^uint64(0))
|
||||||
|
srcImg := seedEditorRunwayImagePhash(t, db, srcRunway, "src", 1, 0, allOne)
|
||||||
|
dupImg := seedEditorRunwayImagePhash(t, db, dupRunway, "dup", 1, 0, allOne)
|
||||||
|
|
||||||
|
// 预置陈旧态:A 的图标记为重复、dup_of 指向 B 的图(跨文章)。
|
||||||
|
if err := db.Model(&model.BrandRunwayImage{}).Where("id = ?", dupImg).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(srcImg), 10)}).Error; err != nil {
|
||||||
|
t.Fatalf("预置跨文章重复标记失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟后台删除整篇 B:软删其全部图片,再跨文章重算去重(PurgeGallery 的调用序列)。
|
||||||
|
delIDs, err := repo.GetRunwayLiveImageIDs(ctx, srcRunway)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取存活图片 id 失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.SoftDeleteRunwayImages(ctx, srcRunway); err != nil {
|
||||||
|
t.Fatalf("下架删图失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
||||||
|
t.Fatalf("跨文章重算去重失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
di, of := editorImageFlag(t, db, dupImg)
|
||||||
|
if di != 0 {
|
||||||
|
t.Fatalf("删掉源方 B 后,A 的图应清掉重复标记(is_duplicate=0),实际 %d", di)
|
||||||
|
}
|
||||||
|
if of != "" {
|
||||||
|
t.Fatalf("删掉源方 B 后,A 的图 dup_of 应为空,实际 %q", of)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注:街拍跨文章去重清理(ReresolveSnapDupOfSources / GetSnapLiveImageIDs)为走秀版的逐行镜像,
|
||||||
|
// 逻辑由 TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 覆盖。本条对应的街拍集成测试因本地
|
||||||
|
// street_snaps 表缺 title_cn 列(预存 schema 漂移,连既有街拍测试也跑不了)暂未加入,待本地库补迁移后补回。
|
||||||
|
|||||||
@ -48,6 +48,10 @@ type StreetSnapRepository interface {
|
|||||||
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||||
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
|
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
|
||||||
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
|
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
|
||||||
|
// ReresolveSnapDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||||
|
ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
||||||
|
// GetSnapLiveImageIDs 取某街拍全部存活图片 id(级联下架前用于跨文章去重清理)。
|
||||||
|
GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error)
|
||||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||||
// SetSnapCover 更新街拍封面。
|
// SetSnapCover 更新街拍封面。
|
||||||
@ -421,6 +425,57 @@ func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID ui
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReresolveSnapDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
||||||
|
// 逻辑同 ReresolveRunwayDupOfSources,仅表换成 street_snap_images。
|
||||||
|
func (r *streetSnapRepository) ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
||||||
|
if len(deletedImageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
strIDs := make([]string, 0, len(deletedImageIDs))
|
||||||
|
for _, id := range deletedImageIDs {
|
||||||
|
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
var affected []model.StreetSnapImage
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
||||||
|
Find(&affected).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(affected) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, im := range affected {
|
||||||
|
if !im.Phash.Valid || im.Phash.String == "" {
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.SnapID, im.SortOrder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if src != 0 && src != im.ID {
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
|
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
|
||||||
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
||||||
@ -487,6 +542,18 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSnapLiveImageIDs 取某街拍全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
||||||
|
func (r *streetSnapRepository) GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error) {
|
||||||
|
var ids []uint32
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnapImage{}).
|
||||||
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
|
Pluck("id", &ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
|
|||||||
57
internal/repository/translate_repository.go
Normal file
57
internal/repository/translate_repository.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TitleCnMemory 翻译记忆表行(title_cn_memory)。
|
||||||
|
type TitleCnMemory struct {
|
||||||
|
TitleEn string `gorm:"primaryKey;column:title_en"`
|
||||||
|
TitleCn string `gorm:"column:title_cn"`
|
||||||
|
UpdatedAt uint32 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定记忆表名。
|
||||||
|
func (TitleCnMemory) TableName() string { return "title_cn_memory" }
|
||||||
|
|
||||||
|
// TranslateRepository 标题翻译记忆的读写接口。
|
||||||
|
type TranslateRepository interface {
|
||||||
|
// GetTitleCnMemory 取某英文标题的人工中文翻译;不存在返回 (_, false, nil)。
|
||||||
|
GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error)
|
||||||
|
// UpsertTitleCnMemory 幂等写入/更新一条映射。
|
||||||
|
UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type translateRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTranslateRepository 创建翻译记忆仓储。
|
||||||
|
func NewTranslateRepository(db *gorm.DB) TranslateRepository {
|
||||||
|
return &translateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *translateRepository) GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) {
|
||||||
|
var row TitleCnMemory
|
||||||
|
err := r.db.WithContext(ctx).Where("title_en = ?", titleEn).First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return row.TitleCn, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *translateRepository) UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error {
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
row := TitleCnMemory{TitleEn: titleEn, TitleCn: titleCn, UpdatedAt: now}
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Where("title_en = ?", titleEn).
|
||||||
|
Assign(TitleCnMemory{TitleCn: titleCn, UpdatedAt: now}).
|
||||||
|
FirstOrCreate(&row).Error
|
||||||
|
}
|
||||||
@ -82,6 +82,8 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
|
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
|
||||||
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
|
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
|
||||||
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
|
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
|
||||||
|
// 编辑页实时翻译建议(已登录后台):英文标题 → 建议中文标题。
|
||||||
|
admin.GET("/api/suggest-title-cn", opt.Backstage.SuggestTitleCn)
|
||||||
|
|
||||||
admin.GET("/", opt.Backstage.Dashboard)
|
admin.GET("/", opt.Backstage.Dashboard)
|
||||||
admin.GET("/brands", opt.Backstage.Brands)
|
admin.GET("/brands", opt.Backstage.Brands)
|
||||||
|
|||||||
@ -150,6 +150,9 @@ func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
func (f *fakeArticle) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
func (f *fakeArticle) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
return map[uint32]dto.DupSource{}, nil
|
return map[uint32]dto.DupSource{}, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeArticle) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeStreet struct{}
|
type fakeStreet struct{}
|
||||||
|
|
||||||
@ -210,10 +213,17 @@ func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
func (f *fakeStreet) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
func (f *fakeStreet) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
return map[uint32]dto.DupSource{}, nil
|
return map[uint32]dto.DupSource{}, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeStreet) SumImageSize(ctx context.Context, snapID uint32) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
||||||
type fakeAdminRepo struct{}
|
type fakeAdminRepo struct{}
|
||||||
|
|
||||||
|
func (f *fakeAdminRepo) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error) {
|
||||||
|
return map[uint32]int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
|
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
|
||||||
all := []repository.AdminRecordCard{
|
all := []repository.AdminRecordCard{
|
||||||
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
|
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
|
||||||
@ -270,7 +280,7 @@ func TestBackstage(t *testing.T) {
|
|||||||
m := jwt.NewManager("test-secret", 168)
|
m := jwt.NewManager("test-secret", 168)
|
||||||
auth := &fakeAuth{jwt: m}
|
auth := &fakeAuth{jwt: m}
|
||||||
brand := &fakeBrand{}
|
brand := &fakeBrand{}
|
||||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{})
|
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{}, nil)
|
||||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||||
|
|
||||||
// 1) 登录页可访问
|
// 1) 登录页可访问
|
||||||
@ -478,7 +488,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
|||||||
auth := &fakeAuth{jwt: m}
|
auth := &fakeAuth{jwt: m}
|
||||||
brand := &fakeBrand{}
|
brand := &fakeBrand{}
|
||||||
rev := &fakeReview{attachOK: true}
|
rev := &fakeReview{attachOK: true}
|
||||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{})
|
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{}, nil)
|
||||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||||
return r, rev
|
return r, rev
|
||||||
}
|
}
|
||||||
|
|||||||
@ -300,6 +300,11 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
|
|||||||
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 跨文章清理:本图若是其它文章近重复的「源」,那些文章的 dup_of 需重算/清除
|
||||||
|
// (ReresolveRunwayDedup 只算本集内,不会碰其它文章指向本图的标记)。
|
||||||
|
if err := s.articles.ReresolveRunwayDupOfSources(ctx, []uint32{imageID}); err != nil {
|
||||||
|
log.Printf("[warn] 跨文章重算走秀图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,6 +334,11 @@ func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint
|
|||||||
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||||||
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
||||||
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
|
// 下架前先取存活图片 id:删整篇后,其它文章中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
||||||
|
delIDs, err := s.articles.GetRunwayLiveImageIDs(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -341,6 +351,12 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 跨文章去重清理:删除整篇后,其它文章中 dup_of 指向本篇图片的标记重算/清除。
|
||||||
|
if len(delIDs) > 0 {
|
||||||
|
if err := s.articles.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
||||||
|
log.Printf("[warn] 跨文章重算走秀 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type IngestService struct {
|
|||||||
del storage.Deleter // 删除器(S4或本地,media_cleanup 真删用)
|
del storage.Deleter // 删除器(S4或本地,media_cleanup 真删用)
|
||||||
local *storage.LocalUploader // S4失败时的兜底落地
|
local *storage.LocalUploader // S4失败时的兜底落地
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
translate *TranslateService // 标题中文翻译(可为 nil:测试/未启用时跳过自动补全)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIngestService 创建入库服务。
|
// NewIngestService 创建入库服务。
|
||||||
@ -45,7 +46,7 @@ type IngestService struct {
|
|||||||
// uploader: 主上传器(S4或本地)
|
// uploader: 主上传器(S4或本地)
|
||||||
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
||||||
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
||||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string) *IngestService {
|
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string, translateSvc *TranslateService) *IngestService {
|
||||||
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
||||||
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
||||||
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
||||||
@ -74,6 +75,7 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
|||||||
uploader: uploader,
|
uploader: uploader,
|
||||||
del: del,
|
del: del,
|
||||||
local: local,
|
local: local,
|
||||||
|
translate: translateSvc,
|
||||||
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
||||||
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
@ -372,6 +374,15 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
ImageCount: imageCount,
|
ImageCount: imageCount,
|
||||||
Status: model.StatusPending,
|
Status: model.StatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入库自动补全中文标题:spider 未给 title_cn 时,用翻译服务按「记忆优先、规则兜底」推导;
|
||||||
|
// 已给则保留(绝不覆盖爬虫提供/已有的翻译)。翻译服务未注入(nil)时跳过。
|
||||||
|
if rw.TitleCn == "" && s.translate != nil {
|
||||||
|
if cn, terr := s.translate.SuggestTitleCn(ctx, rw.TitleEn); terr == nil {
|
||||||
|
rw.TitleCn = cn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i := range images {
|
for i := range images {
|
||||||
images[i].BrandID = brandID
|
images[i].BrandID = brandID
|
||||||
}
|
}
|
||||||
@ -531,9 +542,19 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
|
|
||||||
// 3) 直写正式表(status=pending)
|
// 3) 直写正式表(status=pending)
|
||||||
writeStart := time.Now()
|
writeStart := time.Now()
|
||||||
|
|
||||||
|
// 街拍入库自动补全中文标题(同 runway:spider 未给才补,且翻译服务需注入)。
|
||||||
|
titleCn := p.TitleCn
|
||||||
|
if titleCn == "" && s.translate != nil {
|
||||||
|
if cn, terr := s.translate.SuggestTitleCn(ctx, title); terr == nil {
|
||||||
|
titleCn = cn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
snap := &model.StreetSnap{
|
snap := &model.StreetSnap{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
|
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
|
||||||
|
TitleCn: titleCn,
|
||||||
Year: p.Year,
|
Year: p.Year,
|
||||||
City: p.City,
|
City: p.City,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
|
|||||||
@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
|
|||||||
t.Fatalf("预置文件失败: %v", err)
|
t.Fatalf("预置文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "")
|
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil)
|
||||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||||
svc.processMediaCleanup(context.Background(), job)
|
svc.processMediaCleanup(context.Background(), job)
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
|||||||
del := &countingDeleter{LocalUploader: up}
|
del := &countingDeleter{LocalUploader: up}
|
||||||
|
|
||||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "")
|
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil)
|
||||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||||
svc.processMediaCleanup(context.Background(), job)
|
svc.processMediaCleanup(context.Background(), job)
|
||||||
|
|
||||||
|
|||||||
@ -401,6 +401,10 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
|
|||||||
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 跨文章清理:本图若是其它街拍近重复的「源」,那些街拍的 dup_of 需重算/清除。
|
||||||
|
if err := s.repo.ReresolveSnapDupOfSources(ctx, []uint32{imageID}); err != nil {
|
||||||
|
log.Printf("[warn] 跨文章重算街拍图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -425,6 +429,11 @@ func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted u
|
|||||||
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
||||||
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
||||||
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
|
// 下架前先取存活图片 id:删整篇后,其它街拍中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
||||||
|
delIDs, err := s.repo.GetSnapLiveImageIDs(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -437,5 +446,11 @@ func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 跨文章去重清理:删除整篇后,其它街拍中 dup_of 指向本篇图片的标记重算/清除。
|
||||||
|
if len(delIDs) > 0 {
|
||||||
|
if err := s.repo.ReresolveSnapDupOfSources(ctx, delIDs); err != nil {
|
||||||
|
log.Printf("[warn] 跨文章重算街拍 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
224
internal/service/translate.go
Normal file
224
internal/service/translate.go
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TranslateService 标题中英文翻译:规则兜底 + 人工记忆优先。
|
||||||
|
//
|
||||||
|
// 设计目标:标题高度固定(品牌/年份/季节/系列/城市),绝大多数可规则翻译;
|
||||||
|
// 人工在后台纠正过的中文标题写入记忆表,之后无论入库还是编辑都优先命中,永久精确。
|
||||||
|
// 入库只读取记忆(不写入),避免把规则猜测的半成品固化成「真理」。
|
||||||
|
type TranslateService struct {
|
||||||
|
brandRepo repository.BrandRepository
|
||||||
|
memRepo repository.TranslateRepository
|
||||||
|
|
||||||
|
brandMu sync.Mutex
|
||||||
|
brandOnce bool
|
||||||
|
brandMap map[string]string // name_en(小写) -> name_cn
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTranslateService 创建翻译服务。brandRepo / memRepo 任一为 nil 时,对应能力自动降级
|
||||||
|
// (品牌映射为空 / 记忆不命中),仅影响翻译覆盖率,不会 panic。
|
||||||
|
func NewTranslateService(brandRepo repository.BrandRepository, memRepo repository.TranslateRepository) *TranslateService {
|
||||||
|
return &TranslateService{brandRepo: brandRepo, memRepo: memRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestTitleCn 给出 title_en 的中文翻译:记忆优先,规则兜底。
|
||||||
|
// 规则无法完整翻译(存在未覆盖的英文词)时返回空串,绝不写半吊子脏数据。
|
||||||
|
func (s *TranslateService) SuggestTitleCn(ctx context.Context, titleEn string) (string, error) {
|
||||||
|
en := strings.TrimSpace(titleEn)
|
||||||
|
if en == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if s.memRepo != nil {
|
||||||
|
if cn, ok, err := s.memRepo.GetTitleCnMemory(ctx, en); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if ok && cn != "" {
|
||||||
|
return cn, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cn, _ := s.ruleTranslate(en)
|
||||||
|
return cn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember 记录一条「英文标题 → 中文标题」的人工映射(幂等 upsert)。
|
||||||
|
func (s *TranslateService) Remember(ctx context.Context, titleEn, titleCn string) error {
|
||||||
|
en := strings.TrimSpace(titleEn)
|
||||||
|
cn := strings.TrimSpace(titleCn)
|
||||||
|
if en == "" || cn == "" || s.memRepo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.memRepo.UpsertTitleCnMemory(ctx, en, cn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 规则翻译 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// repItem 静态替换项:英文短语 → 中文。
|
||||||
|
type repItem struct {
|
||||||
|
pat string
|
||||||
|
cn string
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticPatterns 固定映射。组合词(Spring Summer)须排在单词(Spring)之前,
|
||||||
|
// 由 init 统一按长度降序编译,保证优先匹配。
|
||||||
|
var staticPatterns = []repItem{
|
||||||
|
// 街拍短语
|
||||||
|
{"Fashion Week", "时装周"},
|
||||||
|
{"Street Style", "街拍"},
|
||||||
|
{"Street Snap", "街拍"},
|
||||||
|
{"Street Snaps", "街拍"},
|
||||||
|
// 季节(组合在前)
|
||||||
|
{"Spring Summer", "春夏"},
|
||||||
|
{"Fall Winter", "秋冬"},
|
||||||
|
{"Autumn Winter", "秋冬"},
|
||||||
|
{"Pre-Spring", "早春"},
|
||||||
|
{"Pre-Fall", "早秋"},
|
||||||
|
{"Spring", "春夏"},
|
||||||
|
{"Summer", "盛夏"},
|
||||||
|
{"Autumn", "秋冬"},
|
||||||
|
{"Winter", "秋冬"},
|
||||||
|
{"Fall", "秋冬"},
|
||||||
|
// 系列 / 品类
|
||||||
|
{"Ready-to-Wear", "成衣"},
|
||||||
|
{"Ready To Wear", "成衣"},
|
||||||
|
{"Couture", "高级定制"},
|
||||||
|
{"Resort", "度假"},
|
||||||
|
{"Menswear", "男装"},
|
||||||
|
{"Womenswear", "女装"},
|
||||||
|
{"Mens", "男装"},
|
||||||
|
{"Womens", "女装"},
|
||||||
|
{"RTW", "成衣"},
|
||||||
|
// 城市
|
||||||
|
{"New York", "纽约"},
|
||||||
|
{"Hong Kong", "香港"},
|
||||||
|
{"Los Angeles", "洛杉矶"},
|
||||||
|
{"San Francisco", "旧金山"},
|
||||||
|
{"London", "伦敦"},
|
||||||
|
{"Paris", "巴黎"},
|
||||||
|
{"Milan", "米兰"},
|
||||||
|
{"Tokyo", "东京"},
|
||||||
|
{"Seoul", "首尔"},
|
||||||
|
{"Shanghai", "上海"},
|
||||||
|
{"Beijing", "北京"},
|
||||||
|
{"Berlin", "柏林"},
|
||||||
|
{"Copenhagen", "哥本哈根"},
|
||||||
|
{"Florence", "佛罗伦萨"},
|
||||||
|
{"Madrid", "马德里"},
|
||||||
|
{"Rome", "罗马"},
|
||||||
|
{"Sydney", "悉尼"},
|
||||||
|
{"Toronto", "多伦多"},
|
||||||
|
{"Chicago", "芝加哥"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type compiledRep struct {
|
||||||
|
re *regexp.Regexp
|
||||||
|
cn string
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticReplacements 编译后的静态替换表(按短语长度降序)。
|
||||||
|
var staticReplacements []compiledRep
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sorted := append([]repItem{}, staticPatterns...)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool { return len(sorted[i].pat) > len(sorted[j].pat) })
|
||||||
|
for _, it := range sorted {
|
||||||
|
staticReplacements = append(staticReplacements, compiledRep{
|
||||||
|
re: regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(it.pat) + `\b`),
|
||||||
|
cn: it.cn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非中/非数/非字母/非空白的标点一律清掉(避免替换后遗留孤标点符号)。
|
||||||
|
var nonWordRe = regexp.MustCompile(`[^\p{Han}\p{Hangul}\p{Hiragana}\p{Katakana}0-9A-Za-z\s]`)
|
||||||
|
|
||||||
|
// 仍含 ASCII 字母 ⇒ 存在未覆盖的英文词 ⇒ 未完整翻译。
|
||||||
|
var asciiLetterRe = regexp.MustCompile(`[A-Za-z]+`)
|
||||||
|
|
||||||
|
// ruleTranslate 翻译服务的规则分支:直接用共享的 RuleTranslate(注入已加载的品牌映射)。
|
||||||
|
func (s *TranslateService) ruleTranslate(en string) (string, bool) {
|
||||||
|
s.ensureBrandMap()
|
||||||
|
return RuleTranslate(en, s.brandMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleTranslate 纯规则翻译(不含记忆):brandCN 为 name_en(小写)→name_cn 映射。
|
||||||
|
// 品牌名按长度降序、词边界(\b)匹配,避免子串误替(如 brand "On" 误伤 "fashion")。
|
||||||
|
// 所有英文词都被覆盖才成功,否则返回 ("", false) —— 绝不写半吊子脏数据。
|
||||||
|
//
|
||||||
|
// 导出供一次性脚本(dbtool filltitlecn)复用,避免规则逻辑重复实现。
|
||||||
|
func RuleTranslate(en string, brandCN map[string]string) (string, bool) {
|
||||||
|
if strings.TrimSpace(en) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(en)
|
||||||
|
for _, r := range staticReplacements {
|
||||||
|
lower = r.re.ReplaceAllString(lower, r.cn)
|
||||||
|
}
|
||||||
|
if len(brandCN) > 0 {
|
||||||
|
keys := make([]string, 0, len(brandCN))
|
||||||
|
for k := range brandCN {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) })
|
||||||
|
for _, k := range keys {
|
||||||
|
cn := brandCN[k]
|
||||||
|
if cn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if re := brandRegex(k); re != nil {
|
||||||
|
lower = re.ReplaceAllString(lower, cn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clean := nonWordRe.ReplaceAllString(lower, " ")
|
||||||
|
if asciiLetterRe.MatchString(clean) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(clean), " "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// brandReCache 缓存品牌名编译后的正则,避免每次翻译重复编译(ingest 高频场景)。
|
||||||
|
var brandReCache sync.Map // en(lower) -> *regexp.Regexp
|
||||||
|
|
||||||
|
func brandRegex(en string) *regexp.Regexp {
|
||||||
|
if v, ok := brandReCache.Load(en); ok {
|
||||||
|
return v.(*regexp.Regexp)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(en) + `\b`)
|
||||||
|
brandReCache.Store(en, re)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureBrandMap 懒加载品牌「英文→中文」映射(按名称长度降序 + 词边界正则),并发安全。
|
||||||
|
func (s *TranslateService) ensureBrandMap() {
|
||||||
|
s.brandMu.Lock()
|
||||||
|
defer s.brandMu.Unlock()
|
||||||
|
if s.brandOnce {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.brandOnce = true
|
||||||
|
if s.brandRepo == nil {
|
||||||
|
s.brandMap = map[string]string{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
brands, err := s.brandRepo.CrawlBrands(context.Background(), 0)
|
||||||
|
if err != nil || len(brands) == 0 {
|
||||||
|
s.brandMap = map[string]string{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := make(map[string]string, len(brands))
|
||||||
|
for _, b := range brands {
|
||||||
|
if b.NameEn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[strings.ToLower(b.NameEn)] = strings.TrimSpace(b.NameCn)
|
||||||
|
}
|
||||||
|
s.brandMap = m
|
||||||
|
}
|
||||||
89
internal/service/translate_test.go
Normal file
89
internal/service/translate_test.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeTranslateBrandRepo struct{ repository.BrandRepository }
|
||||||
|
|
||||||
|
func (fakeTranslateBrandRepo) CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error) {
|
||||||
|
return []model.Brand{
|
||||||
|
{NameEn: "Chanel", NameCn: "香奈儿"},
|
||||||
|
{NameEn: "Acne Studios", NameCn: "阿尼亚"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTranslateMem struct {
|
||||||
|
m map[string]string
|
||||||
|
repository.TranslateRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTranslateMem) GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) {
|
||||||
|
v, ok := f.m[titleEn]
|
||||||
|
return v, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTranslateMem) UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error {
|
||||||
|
if f.m == nil {
|
||||||
|
f.m = map[string]string{}
|
||||||
|
}
|
||||||
|
f.m[titleEn] = titleCn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTranslateServiceForTest() *TranslateService {
|
||||||
|
return &TranslateService{brandRepo: fakeTranslateBrandRepo{}, memRepo: &fakeTranslateMem{m: map[string]string{}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleTranslate(t *testing.T) {
|
||||||
|
brandCN := map[string]string{"chanel": "香奈儿", "acne studios": "阿尼亚"}
|
||||||
|
cases := []struct {
|
||||||
|
en string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"Spring 2024 Ready-to-Wear", "春夏 2024 成衣", true},
|
||||||
|
{"London Fashion Week Street Style", "伦敦 时装周 街拍", true},
|
||||||
|
{"Chanel Spring 2024 Ready-to-Wear", "香奈儿 春夏 2024 成衣", true},
|
||||||
|
{"Acne Studios Fall 2023 Couture", "阿尼亚 秋冬 2023 高级定制", true},
|
||||||
|
// 含未知品牌 → 未完整翻译 → 失败(不写脏数据)
|
||||||
|
{"UnknownBrand Spring 2024 Ready-to-Wear", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := RuleTranslate(c.en, brandCN)
|
||||||
|
if ok != c.ok || got != c.want {
|
||||||
|
t.Errorf("RuleTranslate(%q) = (%q, %v), want (%q, %v)", c.en, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestTitleCnMemory(t *testing.T) {
|
||||||
|
svc := newTranslateServiceForTest()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := svc.Remember(ctx, "Foobar Fall 2023 Couture", "自定义中文"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := svc.SuggestTitleCn(ctx, "Foobar Fall 2023 Couture")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "自定义中文" {
|
||||||
|
t.Fatalf("记忆未优先命中: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestTitleCnRule(t *testing.T) {
|
||||||
|
svc := newTranslateServiceForTest()
|
||||||
|
got, err := svc.SuggestTitleCn(context.Background(), "Chanel Spring 2024 Ready-to-Wear")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "香奈儿 春夏 2024 成衣" {
|
||||||
|
t.Fatalf("规则翻译不符: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2478,3 +2478,6 @@
|
|||||||
|
|
||||||
2026/09/28 00:29:18 [31;1mD:/project/backend/internal/repository/article_repository.go:299 [35;1mrecord not found
|
2026/09/28 00:29:18 [31;1mD:/project/backend/internal/repository/article_repository.go:299 [35;1mrecord not found
|
||||||
[0m[33m[0.660ms] [34;1m[rows:0][0m SELECT * FROM "brand_runways" WHERE id = 124 AND is_deleted = 0 ORDER BY "brand_runways"."id" LIMIT 1
|
[0m[33m[0.660ms] [34;1m[rows:0][0m SELECT * FROM "brand_runways" WHERE id = 124 AND is_deleted = 0 ORDER BY "brand_runways"."id" LIMIT 1
|
||||||
|
|
||||||
|
2026/09/29 22:23:45 [32mD:/project/backend/internal/repository/ingest_repository.go:120 [33mSLOW SQL >= 200ms
|
||||||
|
[0m[31;1m[2570.622ms] [33m[rows:0][35m UPDATE "ingest_jobs" SET "locked_at"=0,"next_attempt_at"=1790691822,"status"='pending',"updated_at"=1790691822 WHERE status = 'processing' AND locked_at > 0 AND locked_at < 1790691522[0m
|
||||||
|
|||||||
Reference in New Issue
Block a user