update
This commit is contained in:
@ -183,7 +183,7 @@ go build -o bin/migrate ./cmd/migrate
|
|||||||
### 5.3 文章详情
|
### 5.3 文章详情
|
||||||
|
|
||||||
`GET /api/public/articles/:id` → `200 { data:{...} }` / `404 {"error":"文章不存在"}`。
|
`GET /api/public/articles/:id` → `200 { data:{...} }` / `404 {"error":"文章不存在"}`。
|
||||||
详情字段:`id, title, summary, description, cover, brand_name, year, image_count, source_url, published_at, images[]`。
|
详情字段:`id, title, summary, description, cover, brand_name, year, image_count, published_at, images[]`。
|
||||||
|
|
||||||
### 5.4 品牌列表
|
### 5.4 品牌列表
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,16 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// s4BaseURL 取 S4 对外访问域名:优先用上传器自动推导值(保证 free/vip 同 host),否则用配置值。
|
||||||
|
func s4BaseURL(cfgBase string, up *storage.S4Uploader) string {
|
||||||
|
if up != nil && up.Enabled() {
|
||||||
|
if b := up.PublicBaseURL(); b != "" {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfgBase
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
|
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@ -82,16 +92,21 @@ func main() {
|
|||||||
histRepo = repository.NewHistoryRepository(db)
|
histRepo = repository.NewHistoryRepository(db)
|
||||||
ingestRepo = repository.NewIngestRepository(db)
|
ingestRepo = repository.NewIngestRepository(db)
|
||||||
reviewRepo = repository.NewReviewRepository(db)
|
reviewRepo = repository.NewReviewRepository(db)
|
||||||
// 跨表图片引用计数(删除图集时判定七牛孤儿文件用)
|
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
|
||||||
mediaRepo = repository.NewMediaRepository(db)
|
mediaRepo = repository.NewMediaRepository(db)
|
||||||
|
|
||||||
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||||
|
|
||||||
// 图片 URL 拼装器:库里只存七牛 key,渲染时按 tier 拼 base_url + 样式。
|
// S4 上传器(凭证齐全才真正启用;否则为零值,图片回落本地兜底)。
|
||||||
imgComposer = imgurl.New(cfg.Qiniu.BaseURL, cfg.Qiniu.StyleNormal, cfg.Qiniu.StyleVip, cfg.Qiniu.LocalBase, cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.SignTTLNormal, cfg.Qiniu.SignTTLHD)
|
s4Up = storage.NewS4Uploader(cfg.S4.AK, cfg.S4.SK, cfg.S4.Bucket, cfg.S4.Endpoint, cfg.S4.BaseURL, cfg.S4.Region)
|
||||||
|
|
||||||
// 图片存储:七牛云优先(若启用),失败兜底本地 uploads。
|
// 图片 URL 拼装器:库里只存 S4 key,渲染时拼 base_url + 展示样式(VIP 与免费同质量)。
|
||||||
// localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用七牛时两者均为七牛实现。
|
// base_url 优先取 S4 上传器自动推导值(保证展示图 URL 同源)。
|
||||||
|
// StyleThumb 供列表/卡片等小尺寸场景使用(详情页与大图仍走 StyleDisplay)。
|
||||||
|
imgComposer = imgurl.New(s4BaseURL(cfg.S4.BaseURL, s4Up), cfg.S4.StyleDisplay, cfg.S4.StyleThumb)
|
||||||
|
|
||||||
|
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
|
||||||
|
// localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用 S4 时两者均为 S4 实现。
|
||||||
localUp *storage.LocalUploader = storage.NewLocalUploader(uploadDir, cfg.Upload.URLPrefix)
|
localUp *storage.LocalUploader = storage.NewLocalUploader(uploadDir, cfg.Upload.URLPrefix)
|
||||||
imgUp storage.Uploader = localUp
|
imgUp storage.Uploader = localUp
|
||||||
imgDel storage.Deleter = localUp
|
imgDel storage.Deleter = localUp
|
||||||
@ -108,23 +123,21 @@ func main() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
|
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
|
||||||
// 图片存储:七牛云优先(若启用),失败兜底本地 uploads。
|
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
|
||||||
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用七牛时切换为七牛实现。
|
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
|
||||||
if cfg.Qiniu.Enabled {
|
if cfg.S4.Enabled && s4Up != nil && s4Up.Enabled() {
|
||||||
// 注意:上传器不传 base_url(末参传空),Upload 只返回七牛 key;
|
// Upload 只返回 S4 key(不拼 base_url),完整可访问 URL 由 imgComposer 在读取时按 tier 拼;
|
||||||
// 完整可访问 URL 由 imgComposer 在读取时按 tier 拼 base_url + 样式,
|
// free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。
|
||||||
// 这样既方便换 CDN 域名,又能做普通/高清分级(见 internal/pkg/imgurl)。
|
imgUp = s4Up
|
||||||
qu := storage.NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "")
|
imgDel = s4Up
|
||||||
imgUp = qu
|
log.Printf("✓ 图片存储: 缤纷云 S4 bucket=%s base=%s(库只存 key,渲染时拼 base_url)", cfg.S4.Bucket, s4Up.PublicBaseURL())
|
||||||
imgDel = qu
|
|
||||||
log.Printf("✓ 图片存储: 七牛云 bucket=%s zone=%s(库只存 key,渲染时拼 base_url=%s)", cfg.Qiniu.Bucket, cfg.Qiniu.Zone, cfg.Qiniu.BaseURL)
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix)
|
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix)
|
||||||
}
|
}
|
||||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(七牛/本地删除器)。
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理七牛」异步入队。
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
||||||
// 图集服务:注入 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)
|
||||||
|
|
||||||
|
|||||||
@ -79,34 +79,31 @@ client_sign:
|
|||||||
|
|
||||||
# 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。
|
# 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。
|
||||||
# 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。
|
# 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。
|
||||||
# - enabled: false 时 ingest 路由整体不挂载(请求返回 503,等价于端点不存在)。
|
|
||||||
# - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。
|
# - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。
|
||||||
# - 依赖迁移 db/migrations/002_ingest.sql(建 ingest_jobs / ingest_nonces + brand_runway.idx_source_url)。
|
# - 依赖迁移 db/migrations/002_ingest.sql(建 ingest_jobs / ingest_nonces)。
|
||||||
# - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。
|
# - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。
|
||||||
ingest:
|
ingest:
|
||||||
enabled: true # env: INGEST_ENABLED(本地开发启用,接收爬虫上报;生产用环境变量注入随机 INGEST_SECRET)
|
|
||||||
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
||||||
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
||||||
|
|
||||||
# 七牛云对象存储(爬虫入库图片上传目标)
|
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
|
||||||
# - enabled: true 时,worker 把下载的走秀/街拍图直传七牛 bucket,库里存完整 https URL
|
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)
|
||||||
# - zone 必须匹配 bucket 创建区域,否则上传失败:z1=华北(你选择的区域)
|
# - bucket:存储桶名(须为「公开桶」,图片经 CoreIX 公开样式对外提供)
|
||||||
# - base_url 为 bucket 绑定的公开访问域名,用于拼出图片 URL(此处用七牛测试域名)
|
# - endpoint:S3 接入点,默认 https://s3.bitiful.net;region 任意(如 cn-east-1)
|
||||||
# - AK/SK 亦可用环境变量 QINIU_AK / QINIU_SK 覆盖(生产建议用环境变量注入)
|
# - base_url:对外访问域名;留空自动推导 https://<bucket>.s3.bitiful.net(务必与 endpoint 同 host)
|
||||||
qiniu:
|
# - ak/sk:缤纷云子账户 AccessKey/SecretKey(须有 PutObject/DeleteObject/GetObject 权限)
|
||||||
|
# - style_display:全量展示图 CoreIX 样式名(VIP 与免费用户同质量)。须在控制台建一个公开样式 high,
|
||||||
|
# 或填查询串(如 w=1080&q=80&fmt=webp)免建样式。代码含 "=" 即走查询串模式。
|
||||||
|
# 环境变量覆盖:S4_AK / S4_SK / S4_BUCKET / S4_ENDPOINT / S4_BASE_URL / S4_STYLE_DISPLAY / S4_STYLE_THUMB / S4_LOCAL_BASE
|
||||||
|
# (旧 QINIU_* 仅 QINIU_STYLE_NORMAL 作 style_display 的兼容回退;S4_* 优先)
|
||||||
|
s4:
|
||||||
enabled: true
|
enabled: true
|
||||||
ak: "7GLHrN7BqrI9JnWZ9Pki2q5rqPazhIFroo19a-Av"
|
ak: "ur2YaIFZvySmjTIgGT1fxhAW"
|
||||||
sk: "gmg6PLgg666Cme-gsyTlsBLhshDv-6_zsEmW4jRY"
|
sk: "JUlfcQ4HNgO5eIzj9u7qaeQomqssvtI"
|
||||||
bucket: "toom-studio"
|
bucket: "toomstudio" # 你的缤纷云 S4 桶名
|
||||||
zone: "z1" # env: QINIU_ZONE
|
endpoint: "https://s3.bitiful.net"
|
||||||
base_url: "http://toom-studio.23cm.cn" # env: QINIU_BASE_URL(自定义域名;HTTPS 证书在七牛控制台配好后改 https 即可,无需改代码)
|
region: "cn-east-1"
|
||||||
style_normal: "free" # env: QINIU_STYLE_NORMAL(免费图七牛命名样式名,须与控制台一致;如 free / free.webp)
|
base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net
|
||||||
style_vip: "vip" # env: QINIU_STYLE_VIP(VIP 高清七牛命名样式名,须与控制台一致;如 vip / vip.webp)
|
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
|
||||||
local_base: "" # env: QINIU_LOCAL_BASE(本地兜底前缀,通常空)
|
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display
|
||||||
# 私有空间下载签名过期时间(秒):
|
|
||||||
# - sign_ttl_normal:普通/压缩图(含命名样式 -free),长过期便于 SSG 静态页与 CDN 缓存;默认 31536000(1 年)
|
|
||||||
# - sign_ttl_hd:VIP 高清原图,短过期(1 小时)使泄漏窗口小
|
|
||||||
# 前置:需将 bucket 在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名(当前测试域名打不开)。
|
|
||||||
sign_ttl_normal: 31536000 # env: QINIU_SIGN_TTL_NORMAL
|
|
||||||
sign_ttl_hd: 3600 # env: QINIU_SIGN_TTL_HD
|
|
||||||
|
|
||||||
|
|||||||
18
go.mod
18
go.mod
@ -13,6 +13,24 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.47.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.33.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 // indirect
|
||||||
|
github.com/aws/smithy-go v1.28.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
|||||||
36
go.sum
36
go.sum
@ -1,5 +1,41 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 h1:0slwIjBv1sEigSUW4EfTtNw9mbHl0PlOUPX9Y1C/eLE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
|
||||||
|
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||||
|
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
|||||||
@ -24,8 +24,8 @@ type Config struct {
|
|||||||
ClientSign ClientSignConfig `yaml:"client_sign"`
|
ClientSign ClientSignConfig `yaml:"client_sign"`
|
||||||
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
|
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
|
||||||
Ingest IngestConfig `yaml:"ingest"`
|
Ingest IngestConfig `yaml:"ingest"`
|
||||||
// Qiniu 七牛云对象存储配置(爬虫入库图片上传目标)。
|
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
Qiniu QiniuConfig `yaml:"qiniu"`
|
S4 S4Config `yaml:"s4"`
|
||||||
|
|
||||||
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
||||||
// 小写不导出,yml 无法覆盖它。
|
// 小写不导出,yml 无法覆盖它。
|
||||||
@ -120,40 +120,37 @@ type CORSConfig struct {
|
|||||||
// 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。
|
// 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。
|
||||||
// 密钥必须走环境变量 INGEST_SECRET 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
|
// 密钥必须走环境变量 INGEST_SECRET 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
|
||||||
type IngestConfig struct {
|
type IngestConfig struct {
|
||||||
// Enabled 开关:false 时 ingest 接口返回 503(尚未配置密钥),便于灰度。
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
// Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。
|
// Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
||||||
TTLSeconds int `yaml:"ttl_seconds"`
|
TTLSeconds int `yaml:"ttl_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QiniuConfig 七牛云对象存储配置(爬虫入库图片上传目标)。
|
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
//
|
//
|
||||||
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传七牛 bucket,
|
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传 S4 bucket,
|
||||||
// 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。
|
// 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。
|
||||||
// - AK/SK 须与七牛控制台一致;生产经环境变量 QINIU_AK / QINIU_SK 注入。
|
//
|
||||||
// - Zone 必须匹配 bucket 创建区域,否则上传失败:z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡。
|
// 图片质量模型(见 internal/pkg/imgurl,2026-09-11 调整):VIP 与免费用户同质量,
|
||||||
// - BaseURL 为 bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL。
|
// 统一走 CoreIX 公开样式 StyleDisplay(如 high,key!style:high 匿名可访问);付费墙已取消。
|
||||||
// - StyleNormal 普通图七牛命名样式名(见 imgurl 包):详情页对免费/未登录用户拼
|
// - AK/SK 为缤纷云子账户密钥(须有 PutObject/DeleteObject/GetObject 权限);
|
||||||
// base_url/key-StyleNormal;须与七牛控制台创建的样式名完全一致。
|
// 生产经环境变量 S4_AK / S4_SK 注入。
|
||||||
type QiniuConfig struct {
|
// - Endpoint 默认 https://s3.bitiful.net;Region 任意(如 cn-east-1)。
|
||||||
|
// - BaseURL 为对外访问域名;留空自动推导 https://<bucket>.s3.bitiful.net(须与 Endpoint 同 host)。
|
||||||
|
// - StyleDisplay 全量展示图 CoreIX 样式名(如 high,控制台建公开样式)或查询串(如 w=1080&q=80&fmt=webp)。
|
||||||
|
// - StyleThumb 列表缩略图 CoreIX 样式名(如 thumb,控制台建公开样式),仅用于「列表/卡片」等
|
||||||
|
// 小尺寸场景(列表封面、卡片图条、首页热门位),详情页与大图灯箱仍走 StyleDisplay,兼顾清晰度与流量。
|
||||||
|
// 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致,不报错)。
|
||||||
|
type S4Config struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
AK string `yaml:"ak"`
|
AK string `yaml:"ak"`
|
||||||
SK string `yaml:"sk"`
|
SK string `yaml:"sk"`
|
||||||
Bucket string `yaml:"bucket"`
|
Bucket string `yaml:"bucket"`
|
||||||
Zone string `yaml:"zone"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
// StyleNormal 普通图七牛命名样式名(如 free 或 free.webp)。
|
StyleDisplay string `yaml:"style_display"`
|
||||||
StyleNormal string `yaml:"style_normal"`
|
StyleThumb string `yaml:"style_thumb"`
|
||||||
// StyleVip VIP 高清七牛命名样式名(如 vip 或 vip.webp)。VIP tier 走此样式。
|
|
||||||
StyleVip string `yaml:"style_vip"`
|
|
||||||
// LocalBase 本地兜底图片前缀(通常空;前端用 toAbs 拼 host)。仅当静态服务不在同域时有用。
|
|
||||||
LocalBase string `yaml:"local_base"`
|
|
||||||
// SignTTLNormal 普通/压缩图(含命名样式 -free)的私有空间下载签名过期秒;长过期便于 SSG 静态页与 CDN 缓存。
|
|
||||||
SignTTLNormal int64 `yaml:"sign_ttl_normal"`
|
|
||||||
// SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。
|
|
||||||
SignTTLHD int64 `yaml:"sign_ttl_hd"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
||||||
@ -221,19 +218,16 @@ func defaultConfig() *Config {
|
|||||||
TTLSeconds: 30,
|
TTLSeconds: 30,
|
||||||
},
|
},
|
||||||
Ingest: IngestConfig{
|
Ingest: IngestConfig{
|
||||||
Enabled: false,
|
|
||||||
Secret: "",
|
Secret: "",
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
},
|
},
|
||||||
Qiniu: QiniuConfig{
|
S4: S4Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Zone: "z1",
|
Endpoint: "https://s3.bitiful.net",
|
||||||
|
Region: "cn-east-1",
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
StyleNormal: "free",
|
StyleDisplay: "high",
|
||||||
StyleVip: "vip",
|
StyleThumb: "thumb",
|
||||||
LocalBase: "",
|
|
||||||
SignTTLNormal: 31536000,
|
|
||||||
SignTTLHD: 3600,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,21 +314,20 @@ func (c *Config) applyEnv() {
|
|||||||
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
||||||
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
||||||
|
|
||||||
envBool("INGEST_ENABLED", &c.Ingest.Enabled)
|
|
||||||
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
||||||
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
||||||
|
|
||||||
envBool("QINIU_ENABLED", &c.Qiniu.Enabled)
|
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
|
||||||
envStr("QINIU_AK", &c.Qiniu.AK)
|
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
|
||||||
envStr("QINIU_SK", &c.Qiniu.SK)
|
envBoolFirst(&c.S4.Enabled, "S4_ENABLED", "QINIU_ENABLED")
|
||||||
envStr("QINIU_BUCKET", &c.Qiniu.Bucket)
|
envStrFirst(&c.S4.AK, "S4_AK", "QINIU_AK")
|
||||||
envStr("QINIU_ZONE", &c.Qiniu.Zone)
|
envStrFirst(&c.S4.SK, "S4_SK", "QINIU_SK")
|
||||||
envStr("QINIU_BASE_URL", &c.Qiniu.BaseURL)
|
envStrFirst(&c.S4.Bucket, "S4_BUCKET", "QINIU_BUCKET")
|
||||||
envStr("QINIU_STYLE_NORMAL", &c.Qiniu.StyleNormal)
|
envStr("S4_ENDPOINT", &c.S4.Endpoint)
|
||||||
envStr("QINIU_STYLE_VIP", &c.Qiniu.StyleVip)
|
envStr("S4_REGION", &c.S4.Region)
|
||||||
envStr("QINIU_LOCAL_BASE", &c.Qiniu.LocalBase)
|
envStrFirst(&c.S4.BaseURL, "S4_BASE_URL", "QINIU_BASE_URL")
|
||||||
envInt64("QINIU_SIGN_TTL_NORMAL", &c.Qiniu.SignTTLNormal)
|
envStrFirst(&c.S4.StyleDisplay, "S4_STYLE_DISPLAY", "QINIU_STYLE_NORMAL")
|
||||||
envInt64("QINIU_SIGN_TTL_HD", &c.Qiniu.SignTTLHD)
|
envStr("S4_STYLE_THUMB", &c.S4.StyleThumb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
||||||
@ -391,17 +384,29 @@ func envStr(key string, dst *string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envInt(key string, dst *int) {
|
// envStrFirst 按顺序取第一个非空的环境变量写入 dst(前者优先),用于平滑切换旧变量名。
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
func envStrFirst(dst *string, keys ...string) {
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
for _, k := range keys {
|
||||||
*dst = n
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||||
|
*dst = v
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envInt64(key string, dst *int64) {
|
// envBoolFirst 按顺序取第一个非空的环境变量写入 dst(前者优先)。
|
||||||
|
func envBoolFirst(dst *bool, keys ...string) {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := strings.TrimSpace(strings.ToLower(os.Getenv(k))); v != "" {
|
||||||
|
*dst = v == "1" || v == "true" || v == "yes"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, dst *int) {
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
*dst = n
|
*dst = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,10 +66,26 @@ func (q ArticleQuery) Offset() int { return (q.Page - 1) * q.Size }
|
|||||||
//
|
//
|
||||||
// ID 是图片自身的编码 id(hashid,i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
|
// ID 是图片自身的编码 id(hashid,i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
|
||||||
// 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。
|
// 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。
|
||||||
|
//
|
||||||
|
// Image 与 Thumb 的关系:
|
||||||
|
// - Image 是「该接口场景下的主用地址」——详情接口给展示样式(high),列表接口直接给缩略图(thumb,
|
||||||
|
// 列表只需小图,不给大图以免浪费带宽)。
|
||||||
|
// - Thumb 恒为缩略图地址(thumb 样式),**仅详情接口填充**:详情返回的是展示样式大图,
|
||||||
|
// 但大图灯箱左栏缩略图条 / 右下角细节缩略图只要几十到一百多像素,用大图纯属浪费,
|
||||||
|
// 故额外给出 Thumb 供这些「小尺寸场景」使用。列表接口不填(其 Image 本身就是缩略图)。
|
||||||
type PublicArticleImage struct {
|
type PublicArticleImage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
|
Thumb string `json:"thumb,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图;细节在 Details 分组)
|
||||||
|
LookIndex uint32 `json:"look_index"` // 细节图归属的主图序号(用于前端分组展开)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunwayLookDetails 某个主图(look)对应的细节图集合,供详情页「细节入口」按需展开。
|
||||||
|
type RunwayLookDetails struct {
|
||||||
|
LookIndex uint32 `json:"look_index"` // 主图序号(与 images 中对应主图的 look_index 一致)
|
||||||
|
Images []PublicArticleImage `json:"images"` // 该主图的细节图列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicArticle 对外展示用的精简文章结构(走秀列表)。
|
// PublicArticle 对外展示用的精简文章结构(走秀列表)。
|
||||||
@ -90,7 +106,8 @@ type PublicArticle struct {
|
|||||||
|
|
||||||
// PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。
|
// PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。
|
||||||
//
|
//
|
||||||
// 与列表一致,只返回前端渲染所需的字段;image_count / source_url / 各类时间季节元数据前端均不展示,已移除。
|
// 与列表一致,只返回前端渲染所需的字段;后台管理用的额外元数据(季节 / 年份 / 系列等)
|
||||||
|
// 见 AdminRunway,由后台列表接口单独返回。
|
||||||
type PublicArticleDetail struct {
|
type PublicArticleDetail struct {
|
||||||
UID string `json:"id"` // 编码后的文章 id(无序串)
|
UID string `json:"id"` // 编码后的文章 id(无序串)
|
||||||
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||||
@ -98,8 +115,8 @@ type PublicArticleDetail struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
BrandName string `json:"brand_name"`
|
BrandName string `json:"brand_name"`
|
||||||
SourceURL string `json:"source_url"` // 采集来源链接(后台溯源用,对外无害)
|
Images []PublicArticleImage `json:"images"` // 仅主图(look 图),默认展示
|
||||||
Images []PublicArticleImage `json:"images"`
|
Details []RunwayLookDetails `json:"details"` // 按主图分组的细节图(「细节入口」展开用);无细节时为空
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段,
|
// AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段,
|
||||||
@ -114,5 +131,4 @@ type AdminRunway struct {
|
|||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
||||||
ImageCount uint16 `json:"image_count"`
|
ImageCount uint16 `json:"image_count"`
|
||||||
SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源用)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,17 +7,3 @@ type CrawlBrand struct {
|
|||||||
BrandUID string `json:"brand_uid"`
|
BrandUID string `json:"brand_uid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrawlExistsRequest 图集预检请求:一次问一批 source_url 是否已爬取过。
|
|
||||||
// 设计为批量而非逐个,是因为爬虫在拿到列表页后能一次拼出几十上百个图集链接,
|
|
||||||
// 逐个问会退化成 N 次 HTTP 往返,反而比直接抓取更慢。
|
|
||||||
type CrawlExistsRequest struct {
|
|
||||||
SourceURLs []string `json:"source_urls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CrawlExistsResponse 图集预检响应。existing 为「已爬取过、无需再抓」的 source_url 列表;
|
|
||||||
// 未出现在其中的即认为需要抓取。
|
|
||||||
type CrawlExistsResponse struct {
|
|
||||||
Existing []string `json:"existing"` // 已爬取过的 source_url,爬虫应跳过
|
|
||||||
ExistingCount int `json:"existing_count"` // 命中数量,便于爬虫打日志观察命中率
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,12 +8,11 @@ const (
|
|||||||
|
|
||||||
// RunwayIngest 爬虫上报的单场载荷(ingest_jobs.payload 的 JSON 结构)。
|
// RunwayIngest 爬虫上报的单场载荷(ingest_jobs.payload 的 JSON 结构)。
|
||||||
//
|
//
|
||||||
// 设计:爬虫只传「元数据 + 图片 source_url 列表」,不传图、不直写业务表;
|
// 设计:爬虫只传「元数据 + 图片 URL 列表」,不传图、不直写业务表;
|
||||||
// 下载与存储上传由后台 worker 完成。
|
// 下载与存储上传由后台 worker 完成。
|
||||||
//
|
//
|
||||||
// 通用字段:
|
// 通用字段:
|
||||||
// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway)
|
// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway)
|
||||||
// - SourceURL: 采集来源链接(去重 / 溯源,必填)
|
|
||||||
// - Images: 图片原始 URL 列表(worker 下载)
|
// - Images: 图片原始 URL 列表(worker 下载)
|
||||||
//
|
//
|
||||||
// runway 专属:BrandUID(品牌 hashid 编码,必填)、TitleEn/TitleCn、Description*、Year、Season、CollectionType。
|
// runway 专属:BrandUID(品牌 hashid 编码,必填)、TitleEn/TitleCn、Description*、Year、Season、CollectionType。
|
||||||
@ -29,6 +28,15 @@ type RunwayIngest struct {
|
|||||||
Season string `json:"season"` // spring / fall
|
Season string `json:"season"` // spring / fall
|
||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
City string `json:"city"` // 地区/城市(street 用)
|
City string `json:"city"` // 地区/城市(street 用)
|
||||||
SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源,必填)
|
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
||||||
Images []string `json:"images"` // 图片原始 URL 列表(worker 下载)
|
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
||||||
|
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
||||||
|
Looks []RunwayLook `json:"looks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunwayLook 一场秀中的一个 look:1 张主图 + 0..N 张细节图。
|
||||||
|
// 主图即对外默认展示的走秀图;细节图挂靠到对应主图,由「细节入口」按需展开。
|
||||||
|
type RunwayLook struct {
|
||||||
|
Main string `json:"main"` // 主图(look)原始 URL
|
||||||
|
Details []string `json:"details"` // 细节图原始 URL 列表
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,7 +94,7 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c), middleware.IsVIP(middleware.TierFrom(c)))
|
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -10,6 +11,7 @@ import (
|
|||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/middleware"
|
"fashionapi/internal/middleware"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/pkg/pagination"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -37,9 +39,24 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
|
|||||||
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest}
|
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。
|
||||||
|
// 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文,
|
||||||
|
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
|
||||||
|
var pagerTmpl = template.Must(template.New("base").Parse(`{{define "pager"}}<div class="pager">
|
||||||
|
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
||||||
|
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
|
||||||
|
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
||||||
|
</div>{{end}}`))
|
||||||
|
|
||||||
// renderHTML 用单文件模板字符串渲染 HTML(不依赖外部模板文件,保持自包含)。
|
// renderHTML 用单文件模板字符串渲染 HTML(不依赖外部模板文件,保持自包含)。
|
||||||
|
// 在解析页面正文前先 Clone 含 {{define "pager"}} 的基类模板,使各列表页可复用翻页片段。
|
||||||
func renderHTML(c *gin.Context, status int, tpl string, data any) {
|
func renderHTML(c *gin.Context, status int, tpl string, data any) {
|
||||||
t, err := template.New("t").Parse(tpl)
|
base, err := pagerTmpl.Clone()
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "template clone error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := base.Parse(tpl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "template error: "+err.Error())
|
c.String(http.StatusInternalServerError, "template error: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -136,20 +153,23 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const size = 30
|
const size = 30
|
||||||
page := q.Page
|
p := pagination.Params{Page: q.Page, PageSize: size}
|
||||||
if page < 1 {
|
if p.Page < 1 {
|
||||||
page = 1
|
p.Page = 1
|
||||||
}
|
}
|
||||||
|
meta := p.NewMeta(total)
|
||||||
renderHTML(c, http.StatusOK, tplBrands, gin.H{
|
renderHTML(c, http.StatusOK, tplBrands, gin.H{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": page,
|
|
||||||
"PrevPage": page - 1,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"HasPrev": page > 1,
|
|
||||||
"HasNext": int64(page*size) < total,
|
|
||||||
"Keyword": q.Keyword,
|
"Keyword": q.Keyword,
|
||||||
"Letter": q.Letter,
|
"Letter": q.Letter,
|
||||||
|
"Pager": pagination.Pager{
|
||||||
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
|
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||||
|
HasPrev: meta.HasPrev,
|
||||||
|
HasNext: meta.HasNext,
|
||||||
|
Label: fmt.Sprintf("第 %d 页 · 共 %d 条", meta.Page, total),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,22 +234,26 @@ func (h *BackstageHandler) Runways(c *gin.Context) {
|
|||||||
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page := q.Page
|
const size = 24
|
||||||
if page < 1 {
|
p := pagination.Params{Page: q.Page, PageSize: size}
|
||||||
page = 1
|
if p.Page < 1 {
|
||||||
|
p.Page = 1
|
||||||
}
|
}
|
||||||
|
meta := p.NewMeta(total)
|
||||||
renderHTML(c, http.StatusOK, tplRunways, gin.H{
|
renderHTML(c, http.StatusOK, tplRunways, gin.H{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": page,
|
|
||||||
"PrevPage": page - 1,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"HasPrev": page > 1,
|
|
||||||
"HasNext": int64(page*24) < total,
|
|
||||||
"Collection": q.Collection,
|
"Collection": q.Collection,
|
||||||
"Season": q.Season,
|
"Season": q.Season,
|
||||||
"Year": q.Year,
|
"Year": q.Year,
|
||||||
"Sort": q.Sort,
|
"Sort": q.Sort,
|
||||||
|
"Pager": pagination.Pager{
|
||||||
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
|
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||||
|
HasPrev: meta.HasPrev,
|
||||||
|
HasNext: meta.HasNext,
|
||||||
|
Label: fmt.Sprintf("第 %d 页 · 共 %d 条", meta.Page, total),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,7 +266,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
|||||||
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw})
|
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en", true)
|
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw})
|
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
@ -264,21 +288,25 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
|||||||
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page := q.Page
|
const size = 24
|
||||||
if page < 1 {
|
p := pagination.Params{Page: q.Page, PageSize: size}
|
||||||
page = 1
|
if p.Page < 1 {
|
||||||
|
p.Page = 1
|
||||||
}
|
}
|
||||||
|
meta := p.NewMeta(total)
|
||||||
renderHTML(c, http.StatusOK, tplStreetSnaps, gin.H{
|
renderHTML(c, http.StatusOK, tplStreetSnaps, gin.H{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": page,
|
|
||||||
"PrevPage": page - 1,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"HasPrev": page > 1,
|
|
||||||
"HasNext": int64(page*24) < total,
|
|
||||||
"Year": q.Year,
|
"Year": q.Year,
|
||||||
"City": q.City,
|
"City": q.City,
|
||||||
"Sort": q.Sort,
|
"Sort": q.Sort,
|
||||||
|
"Pager": pagination.Pager{
|
||||||
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
|
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||||
|
HasPrev: meta.HasPrev,
|
||||||
|
HasNext: meta.HasNext,
|
||||||
|
Label: fmt.Sprintf("第 %d 页 · 共 %d 条", meta.Page, total),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,7 +319,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
|||||||
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw})
|
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), true)
|
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw})
|
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
@ -334,18 +362,22 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
|||||||
if kind != "" && kind != "all" {
|
if kind != "" && kind != "all" {
|
||||||
listBase = "/" + kind
|
listBase = "/" + kind
|
||||||
}
|
}
|
||||||
|
p := pagination.Params{Page: page, PageSize: 20}
|
||||||
|
meta := p.NewMeta(total)
|
||||||
renderHTML(c, http.StatusOK, tplReviewList, gin.H{
|
renderHTML(c, http.StatusOK, tplReviewList, gin.H{
|
||||||
"Drafts": drafts,
|
"Drafts": drafts,
|
||||||
"Total": total,
|
"Total": total,
|
||||||
"Page": page,
|
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"Kind": kind,
|
"Kind": kind,
|
||||||
"Kinds": kinds,
|
"Kinds": kinds,
|
||||||
"ListBase": listBase,
|
"ListBase": listBase,
|
||||||
"HasPrev": page > 1,
|
"Pager": pagination.Pager{
|
||||||
"HasNext": int64(page*20) < total,
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
"PrevPage": page - 1,
|
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||||
"NextPage": page + 1,
|
HasPrev: meta.HasPrev,
|
||||||
|
HasNext: meta.HasNext,
|
||||||
|
Label: fmt.Sprintf("第 %d 页", meta.Page),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,14 +481,16 @@ type ingestJobView struct {
|
|||||||
ID uint32
|
ID uint32
|
||||||
Status string
|
Status string
|
||||||
Attempts uint8
|
Attempts uint8
|
||||||
SourceURL string
|
|
||||||
LastError string
|
LastError string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestJobs 列出最近的入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
|
// IngestJobs 分页列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
|
||||||
|
// 支持 ?page(从 1 起)与 ?pageSize(默认 50,上限 200);其余查询串原样忽略。
|
||||||
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
|
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
|
||||||
jobs, err := h.ingest.ListJobs(c.Request.Context(), 200)
|
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
|
||||||
|
offset := p.Offset()
|
||||||
|
jobs, total, err := h.ingest.ListJobs(c.Request.Context(), offset, p.PageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
@ -471,12 +505,27 @@ func (h *BackstageHandler) IngestJobs(c *gin.Context) {
|
|||||||
ID: j.ID,
|
ID: j.ID,
|
||||||
Status: j.Status,
|
Status: j.Status,
|
||||||
Attempts: j.Attempts,
|
Attempts: j.Attempts,
|
||||||
SourceURL: j.SourceURL,
|
|
||||||
LastError: j.LastError,
|
LastError: j.LastError,
|
||||||
UpdatedAt: ua,
|
UpdatedAt: ua,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
renderHTML(c, http.StatusOK, tplIngestJobs, gin.H{"Jobs": views})
|
// 越界页码(如手动改 URL 成大数)钳制到最后一页展示,与原有行为一致。
|
||||||
|
// meta.Clamp 仅调整展示用的 Page/Prev/Next,不改变上面已算好的查询 offset。
|
||||||
|
meta := p.NewMeta(total)
|
||||||
|
meta.Clamp()
|
||||||
|
renderHTML(c, http.StatusOK, tplIngestJobs, gin.H{
|
||||||
|
"Jobs": views,
|
||||||
|
"Page": meta.Page,
|
||||||
|
"PageSize": p.PageSize,
|
||||||
|
"Total": total,
|
||||||
|
"TotalPages": meta.TotalPages,
|
||||||
|
"Pager": pagination.Pager{
|
||||||
|
PrevURL: pagination.PageLinkWith(c, meta.PrevPage, "pageSize", p.PageSize),
|
||||||
|
NextURL: pagination.PageLinkWith(c, meta.NextPage, "pageSize", p.PageSize),
|
||||||
|
HasPrev: meta.HasPrev,
|
||||||
|
HasNext: meta.HasNext,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending),worker 会在下一个周期重新处理。
|
// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending),worker 会在下一个周期重新处理。
|
||||||
@ -945,11 +994,7 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
|
|||||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="pager">
|
{{template "pager" .Pager}}
|
||||||
{{if .HasPrev}}<a href="/admin/brands?keyword={{.Keyword}}&letter={{.Letter}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
|
||||||
<span class="muted">第 {{.Page}} 页 · 共 {{.Total}} 条</span>
|
|
||||||
{{if .HasNext}}<a href="/admin/brands?keyword={{.Keyword}}&letter={{.Letter}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
|
|
||||||
const tplRunways = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
const tplRunways = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||||
@ -1008,11 +1053,7 @@ a.view{color:#111;text-decoration:underline}
|
|||||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="pager">
|
{{template "pager" .Pager}}
|
||||||
{{if .HasPrev}}<a href="/admin/runways?collection={{.Collection}}&season={{.Season}}&year={{.Year}}&sort={{.Sort}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
|
||||||
<span class="muted">第 {{.Page}} 页 · 共 {{.Total}} 条</span>
|
|
||||||
{{if .HasNext}}<a href="/admin/runways?collection={{.Collection}}&season={{.Season}}&year={{.Year}}&sort={{.Sort}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
|
|
||||||
const tplRunwayDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
const tplRunwayDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||||
@ -1037,7 +1078,6 @@ h1{font-size:22px;margin:8px 0 2px}
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
||||||
<h1>{{.Article.Title}}</h1>
|
<h1>{{.Article.Title}}</h1>
|
||||||
<p class="meta">来源:{{if .Article.SourceURL}}<a href="{{.Article.SourceURL}}" target="_blank" rel="noopener">{{.Article.SourceURL}}</a>{{else}}—{{end}}</p>
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{{range .Article.Images}}<div class="cell"><img src="{{.Image}}" loading="lazy"><span>{{.Name}}</span></div>{{end}}
|
{{range .Article.Images}}<div class="cell"><img src="{{.Image}}" loading="lazy"><span>{{.Name}}</span></div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -1087,11 +1127,7 @@ a.view{color:#111;text-decoration:underline}
|
|||||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="pager">
|
{{template "pager" .Pager}}
|
||||||
{{if .HasPrev}}<a href="/admin/street-snaps?city={{.City}}&year={{.Year}}&sort={{.Sort}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
|
||||||
<span class="muted">第 {{.Page}} 页 · 共 {{.Total}} 条</span>
|
|
||||||
{{if .HasNext}}<a href="/admin/street-snaps?city={{.City}}&year={{.Year}}&sort={{.Sort}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
|
|
||||||
const tplStreetSnapDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
const tplStreetSnapDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||||
@ -1204,11 +1240,7 @@ a.view{color:#111;text-decoration:underline}
|
|||||||
</tr>{{end}}
|
</tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="pager">
|
{{template "pager" .Pager}}
|
||||||
{{if .HasPrev}}<a href="/admin/reviews{{.ListBase}}?status={{.Status}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
|
||||||
<span class="muted">第 {{.Page}} 页</span>
|
|
||||||
{{if .HasNext}}<a href="/admin/reviews{{.ListBase}}?status={{.Status}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
|
|
||||||
const tplReviewDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
const tplReviewDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||||
@ -1238,7 +1270,7 @@ button.reject{background:#c0392b}
|
|||||||
<body>
|
<body>
|
||||||
<div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="/admin/reviews{{if ne .Draft.Kind ""}}/{{.Draft.Kind}}{{end}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
|
<div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="/admin/reviews{{if ne .Draft.Kind ""}}/{{.Draft.Kind}}{{end}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b> · 来源:{{if .Draft.SourceURL}}<a href="{{.Draft.SourceURL}}" target="_blank" rel="noopener">{{.Draft.SourceURL}}</a>{{else}}—{{end}}</p>
|
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p>
|
||||||
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
|
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
|
||||||
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
|
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
|
||||||
<button type="submit">通过并发布</button>
|
<button type="submit">通过并发布</button>
|
||||||
@ -1386,26 +1418,29 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
|
|||||||
form.retry{margin:0}
|
form.retry{margin:0}
|
||||||
button.retry{font:12px system-ui;padding:4px 10px;border:1px solid #c0392b;color:#c0392b;background:#fff;border-radius:6px;cursor:pointer}
|
button.retry{font:12px system-ui;padding:4px 10px;border:1px solid #c0392b;color:#c0392b;background:#fff;border-radius:6px;cursor:pointer}
|
||||||
button.retry:hover{background:#c0392b;color:#fff}
|
button.retry:hover{background:#c0392b;color:#fff}
|
||||||
|
.pager{display:flex;justify-content:flex-end;gap:12px;align-items:center;margin-top:16px}
|
||||||
|
.pager a{padding:7px 14px;border:1px solid #ccc;border-radius:6px;color:#111;text-decoration:none}
|
||||||
|
.pager a.off{color:#bbb;border-color:#eee;pointer-events:none}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bar"><span>管理后台 · 爬虫入库任务</span><span><a href="/admin/">返回首页</a> · <a href="/admin/logout">退出</a></span></div>
|
<div class="bar"><span>管理后台 · 爬虫入库任务</span><span><a href="/admin/">返回首页</a> · <a href="/admin/logout">退出</a></span></div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<h1>爬虫入库任务(最近 {{len .Jobs}} 条)</h1>
|
<h1>爬虫入库任务</h1>
|
||||||
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。</p>
|
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>来源 URL</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
|
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Jobs}}<tr>
|
{{range .Jobs}}<tr>
|
||||||
<td><code>{{.ID}}</code></td>
|
<td><code>{{.ID}}</code></td>
|
||||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
<td>{{.Attempts}}</td>
|
<td>{{.Attempts}}</td>
|
||||||
<td class="break"><code>{{.SourceURL}}</code></td>
|
|
||||||
<td class="err break">{{.LastError}}</td>
|
<td class="err break">{{.LastError}}</td>
|
||||||
<td class="muted">{{.UpdatedAt}}</td>
|
<td class="muted">{{.UpdatedAt}}</td>
|
||||||
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
|
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
</tr>{{end}}
|
</tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
|
|
||||||
const tplError = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>错误</title></head>
|
const tplError = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>错误</title></head>
|
||||||
|
|||||||
@ -2,17 +2,17 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/middleware"
|
"fashionapi/internal/middleware"
|
||||||
|
"fashionapi/internal/pkg/pagination"
|
||||||
"fashionapi/internal/pkg/response"
|
"fashionapi/internal/pkg/response"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FavoriteHandler 收藏接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。
|
// FavoriteHandler 收藏接口(需 Bearer 鉴权,主挂载于 /api/v1/me,legacy /api/v1/auth 仅兼容旧前端)。
|
||||||
type FavoriteHandler struct {
|
type FavoriteHandler struct {
|
||||||
favs service.FavoriteService
|
favs service.FavoriteService
|
||||||
}
|
}
|
||||||
@ -33,20 +33,13 @@ func (h *FavoriteHandler) List(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
p := pagination.Parse(c, "page", "per_page", 1, 24, 100)
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 24
|
|
||||||
}
|
|
||||||
kind := c.Query("kind")
|
kind := c.Query("kind")
|
||||||
if kind != "" && kind != dto.FavoriteKindGallery && kind != dto.FavoriteKindImage {
|
if kind != "" && kind != dto.FavoriteKindGallery && kind != dto.FavoriteKindImage {
|
||||||
response.Error(c, http.StatusBadRequest, "invalid kind")
|
response.Error(c, http.StatusBadRequest, "invalid kind")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, total, err := h.favs.ListPaged(c.Request.Context(), userID, page, perPage, kind)
|
items, total, err := h.favs.ListPaged(c.Request.Context(), userID, p.Page, p.PageSize, kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
@ -54,7 +47,7 @@ func (h *FavoriteHandler) List(c *gin.Context) {
|
|||||||
response.Data(c, http.StatusOK, gin.H{
|
response.Data(c, http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
"last_page": response.LastPage(total, perPage),
|
"last_page": response.LastPage(total, p.PageSize),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +95,7 @@ func (h *FavoriteHandler) Remove(c *gin.Context) {
|
|||||||
|
|
||||||
// Check 批量查询给定 ids 中哪些已收藏(列表页打勾用,与收藏总量解耦)。
|
// Check 批量查询给定 ids 中哪些已收藏(列表页打勾用,与收藏总量解耦)。
|
||||||
//
|
//
|
||||||
// POST /api/v1/me/favorites/check
|
// POST /api/v1/me/favorites/checks
|
||||||
// body: { ids: ["r=...","s=..."] }
|
// body: { ids: ["r=...","s=..."] }
|
||||||
// 成功:200 { data: ["r=..."] }(仅返回已收藏的 id)
|
// 成功:200 { data: ["r=..."] }(仅返回已收藏的 id)
|
||||||
func (h *FavoriteHandler) Check(c *gin.Context) {
|
func (h *FavoriteHandler) Check(c *gin.Context) {
|
||||||
|
|||||||
@ -58,34 +58,6 @@ func queryBool(c *gin.Context, key string, def bool) bool {
|
|||||||
return v == "1" || v == "true"
|
return v == "1" || v == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseUint32CSV 解析逗号分隔的正整数列表(如 brand_ids=1,2,3),忽略非法项。
|
|
||||||
func parseUint32CSV(s string) []uint32 {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []uint32
|
|
||||||
for _, part := range strings.Split(s, ",") {
|
|
||||||
if n, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && n > 0 {
|
|
||||||
out = append(out, uint32(n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseIntSlice 把字符串数组解析为整型数组,忽略非法项。
|
|
||||||
func parseIntSlice(in []string) []int {
|
|
||||||
if len(in) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []int
|
|
||||||
for _, v := range in {
|
|
||||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。
|
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。
|
||||||
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
|
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
|
||||||
func parseLocale(c *gin.Context) string {
|
func parseLocale(c *gin.Context) string {
|
||||||
|
|||||||
@ -2,17 +2,17 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/middleware"
|
"fashionapi/internal/middleware"
|
||||||
|
"fashionapi/internal/pkg/pagination"
|
||||||
"fashionapi/internal/pkg/response"
|
"fashionapi/internal/pkg/response"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HistoryHandler 浏览历史接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。
|
// HistoryHandler 浏览历史接口(需 Bearer 鉴权,主挂载于 /api/v1/me,legacy /api/v1/auth 仅兼容旧前端)。
|
||||||
type HistoryHandler struct {
|
type HistoryHandler struct {
|
||||||
hist service.HistoryService
|
hist service.HistoryService
|
||||||
}
|
}
|
||||||
@ -32,15 +32,8 @@ func (h *HistoryHandler) List(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
p := pagination.Parse(c, "page", "per_page", 1, 24, 100)
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24"))
|
items, total, err := h.hist.ListPaged(c.Request.Context(), userID, p.Page, p.PageSize)
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 24
|
|
||||||
}
|
|
||||||
items, total, err := h.hist.ListPaged(c.Request.Context(), userID, page, perPage)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
@ -48,7 +41,7 @@ func (h *HistoryHandler) List(c *gin.Context) {
|
|||||||
response.Data(c, http.StatusOK, gin.H{
|
response.Data(c, http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
"last_page": response.LastPage(total, perPage),
|
"last_page": response.LastPage(total, p.PageSize),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
@ -64,39 +63,11 @@ func (h *IngestHandler) CrawlBrands(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"brands": brands})
|
c.JSON(http.StatusOK, gin.H{"brands": brands})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrawlExists 图集预检接口(POST /admin/internal/crawl/exists,复用 IngestAuth HMAC 验签)。
|
|
||||||
//
|
|
||||||
// 爬虫在抓详情页之前批量问「这些 source_url 是否已爬取过」,跳过已存在的图集,
|
|
||||||
// 省掉「抓详情页 → 提取图片 URL → 上报 → worker 判重后丢弃」这一整套无效动作。
|
|
||||||
// 判定条件与 worker 判重一致,因此跳过是安全的:不会漏抓,也不会重复抓。
|
|
||||||
func (h *IngestHandler) CrawlExists(c *gin.Context) {
|
|
||||||
var req dto.CrawlExistsRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exists, err := h.ingest.ExistsSourceURLs(c.Request.Context(), req.SourceURLs)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existing := make([]string, 0, len(exists))
|
|
||||||
for u := range exists {
|
|
||||||
existing = append(existing, u)
|
|
||||||
}
|
|
||||||
// 排序保证输出稳定,便于日志比对与测试断言。
|
|
||||||
sort.Strings(existing)
|
|
||||||
c.JSON(http.StatusOK, dto.CrawlExistsResponse{
|
|
||||||
Existing: existing,
|
|
||||||
ExistingCount: len(existing),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isClientErr(err error) bool {
|
func isClientErr(err error) bool {
|
||||||
// service 层 NewError 带 http 状态码;这里简单按消息前缀判断,避免暴露内部类型。
|
// service 层 NewError 带 http 状态码;这里简单按消息前缀判断,避免暴露内部类型。
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
return len(msg) > 0 && (msg == "source_url required" || msg == "brand_uid required")
|
return len(msg) > 0 && msg == "brand_uid required"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/middleware"
|
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/pkg/response"
|
"fashionapi/internal/pkg/response"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
@ -31,7 +30,7 @@ func NewStreetSnapHandler(svc service.StreetSnapService) *StreetSnapHandler {
|
|||||||
// 查询参数:
|
// 查询参数:
|
||||||
// - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条)
|
// - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条)
|
||||||
// - year 年份(整数),0 表示不过滤
|
// - year 年份(整数),0 表示不过滤
|
||||||
// - city 地区/城市(精确匹配 city 列),'' 表示不过滤
|
// - city 地区/城市(精确匹配 city 列),” 表示不过滤
|
||||||
// - sort image_count(默认,按图片数降序)/ year_desc / year_asc
|
// - sort image_count(默认,按图片数降序)/ year_desc / year_asc
|
||||||
// - locale cn / en
|
// - locale cn / en
|
||||||
//
|
//
|
||||||
@ -79,7 +78,7 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), middleware.IsVIP(middleware.TierFrom(c)))
|
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -20,15 +20,6 @@ const (
|
|||||||
ContextTier = "tier"
|
ContextTier = "tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TierFree / TierVIP 用户等级枚举值。
|
|
||||||
const (
|
|
||||||
TierFree = "free"
|
|
||||||
TierVIP = "vip"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsVIP 判断等级是否为 VIP。
|
|
||||||
func IsVIP(tier string) bool { return tier == TierVIP }
|
|
||||||
|
|
||||||
// bearerPrefix Authorization 头的令牌前缀。
|
// bearerPrefix Authorization 头的令牌前缀。
|
||||||
const bearerPrefix = "Bearer "
|
const bearerPrefix = "Bearer "
|
||||||
|
|
||||||
@ -105,13 +96,3 @@ func UserIDFrom(c *gin.Context) (uint32, bool) {
|
|||||||
}
|
}
|
||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TierFrom 从 Context 取出当前用户等级;未登录(无 tier)回落 free。
|
|
||||||
func TierFrom(c *gin.Context) string {
|
|
||||||
if v, exists := c.Get(ContextTier); exists {
|
|
||||||
if t, ok := v.(string); ok && t != "" {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TierFree
|
|
||||||
}
|
|
||||||
|
|||||||
@ -13,7 +13,6 @@ type IngestJob struct {
|
|||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收
|
LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收
|
||||||
SourceURL string `gorm:"column:source_url" json:"source_url"`
|
|
||||||
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"` // pending|processing|done|failed
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"` // pending|processing|done|failed
|
||||||
Attempts uint8 `gorm:"column:attempts;not null;default:0" json:"attempts"`
|
Attempts uint8 `gorm:"column:attempts;not null;default:0" json:"attempts"`
|
||||||
Payload string `gorm:"column:payload;type:text" json:"payload"` // 原始 JSON(RunwayIngest 或 MediaCleanupPayload)
|
Payload string `gorm:"column:payload;type:text" json:"payload"` // 原始 JSON(RunwayIngest 或 MediaCleanupPayload)
|
||||||
|
|||||||
@ -17,7 +17,6 @@ type BrandRunway struct {
|
|||||||
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
||||||
Year uint16 `gorm:"column:year" json:"year"`
|
Year uint16 `gorm:"column:year" json:"year"`
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
Cover string `gorm:"column:cover" json:"cover"`
|
||||||
SourceURL string `gorm:"column:source_url" json:"source_url"`
|
|
||||||
|
|
||||||
// 分类字段:由 title 解析回填(历史数据迁移一次性写入,现已由外部 SQL 迁移维护)
|
// 分类字段:由 title 解析回填(历史数据迁移一次性写入,现已由外部 SQL 迁移维护)
|
||||||
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
||||||
@ -45,7 +44,6 @@ type RunwayRow struct {
|
|||||||
Season string `gorm:"column:season"`
|
Season string `gorm:"column:season"`
|
||||||
SeasonCode string `gorm:"column:season_code"`
|
SeasonCode string `gorm:"column:season_code"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at"`
|
CreatedAt uint32 `gorm:"column:created_at"`
|
||||||
SourceURL string `gorm:"column:source_url"`
|
|
||||||
BrandNameEn string `gorm:"column:brand_name_en"`
|
BrandNameEn string `gorm:"column:brand_name_en"`
|
||||||
BrandNameCn string `gorm:"column:brand_name_cn"`
|
BrandNameCn string `gorm:"column:brand_name_cn"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ type BrandRunwayDraft struct {
|
|||||||
CollectionType string `gorm:"column:collection_type" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
CollectionType string `gorm:"column:collection_type" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
||||||
SeasonCode string `gorm:"column:season_code" json:"season_code"` // SS26/FW25/RES26/PF25
|
SeasonCode string `gorm:"column:season_code" json:"season_code"` // SS26/FW25/RES26/PF25
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
Cover string `gorm:"column:cover" json:"cover"`
|
||||||
SourceURL string `gorm:"column:source_url" json:"source_url"`
|
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||||
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
||||||
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
||||||
@ -38,6 +37,8 @@ type BrandRunwayDraftImage struct {
|
|||||||
Image string `gorm:"column:image" json:"image"`
|
Image string `gorm:"column:image" json:"image"`
|
||||||
Name string `gorm:"column:name" json:"name"`
|
Name string `gorm:"column:name" json:"name"`
|
||||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
|
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
|
||||||
|
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号
|
||||||
|
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|||||||
@ -11,6 +11,8 @@ type BrandRunwayImage struct {
|
|||||||
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
||||||
Name string `gorm:"column:name" json:"name"`
|
Name string `gorm:"column:name" json:"name"`
|
||||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
|
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
|
||||||
|
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
|
||||||
|
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名。
|
// TableName 指定表名。
|
||||||
|
|||||||
@ -10,7 +10,6 @@ type StreetSnap struct {
|
|||||||
Year uint16 `gorm:"column:year" json:"year"`
|
Year uint16 `gorm:"column:year" json:"year"`
|
||||||
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
|
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
Cover string `gorm:"column:cover" json:"cover"`
|
||||||
SourceURL string `gorm:"column:source_url" json:"source_url"`
|
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||||
|
|||||||
@ -14,7 +14,6 @@ type StreetSnapDraft struct {
|
|||||||
Year uint16 `gorm:"column:year" json:"year"`
|
Year uint16 `gorm:"column:year" json:"year"`
|
||||||
City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类
|
City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
Cover string `gorm:"column:cover" json:"cover"`
|
||||||
SourceURL string `gorm:"column:source_url" json:"source_url"`
|
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||||
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
||||||
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
||||||
|
|||||||
@ -1,109 +1,71 @@
|
|||||||
// Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。
|
// Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。
|
||||||
//
|
//
|
||||||
// 存储约定(见迁移 007):库里只存「七牛 key」(如 runway/abc.jpg)或「本地相对路径」
|
// 存储约定(见迁移 007):库里只存「对象 key」(如 runway/abc.jpg)或「本地相对路径」
|
||||||
// (如 /uploads/runway/x.jpg),绝不存完整 URL。这样做两个好处:
|
// (如 /uploads/runway/x.jpg),绝不存完整 URL。这样做两个好处:
|
||||||
// 1. 换 CDN 域名只改配置,无需迁移全表;
|
// 1. 换 CDN 域名只改配置,无需迁移全表;
|
||||||
// 2. 做「普通 / 高清」分级时无需从 URL 反向解析 key。
|
// 2. 做「普通 / 高清」分级时无需从 URL 反向解析 key。
|
||||||
//
|
//
|
||||||
// 分级与安全(付费墙):
|
// 质量模型(2026-09-11 调整):VIP 与免费用户现在看到同一质量,高清样式名为 `high`,
|
||||||
// - 高清(hd=true,仅 VIP):私有空间下载签名 URL(base_url/key-StyleVip,如 key-vip),限时有效;
|
// 由缤纷云 CoreIX 公开样式提供(base_url/key!style:high),匿名可访问、免 token。
|
||||||
// 免费/未登录用户拿不到 VIP 样式 key,即使从别处得到 key,因无签名也会被七牛拒绝(403)。
|
// 旧「免费低清 / VIP 原图预签名」付费墙已取消,故 Composer 不再区分 tier。
|
||||||
// - 普通(hd=false,所有人):base_url/key-StyleNormal(如 key-free)的私有空间签名 URL,长过期(供 SSG 静态页与 CDN 缓存)。
|
// - 列表缩略图:另走 `thumb` 公开样式(ComposeThumb),仅用于列表/卡片等小尺寸场景,省流量。
|
||||||
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)。
|
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)。
|
||||||
// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套七牛样式,签名无意义)。
|
// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套样式)。
|
||||||
//
|
|
||||||
// 七牛私有空间下载签名:对 "base_url/key?e=<deadline>" 做 HMAC-SHA1(SK),urlsafe base64 后拼
|
|
||||||
// "AK:sign" 作为 token。签名覆盖完整 path(含 -StyleNormal / -StyleVip 后缀),七牛校验通过才返回资源。
|
|
||||||
// 前置:bucket 须在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名。
|
|
||||||
package imgurl
|
package imgurl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Composer 图片 URL 拼装器。
|
// Composer 图片 URL 拼装器。
|
||||||
type Composer struct {
|
type Composer struct {
|
||||||
// BaseURL 七牛 bucket 绑定域名,如 https://cdn.toom-studio.com(末尾无斜杠)。
|
// BaseURL 对外访问域名,如 https://toomstudio.s3.bitiful.net(末尾无斜杠)。
|
||||||
BaseURL string
|
BaseURL string
|
||||||
// StyleNormal 普通图七牛命名样式名(对应七牛控制台创建的样式),如 "free"。
|
// StyleDisplay 全量展示图 CoreIX 样式名(如 "high",对应控制台建的公开样式);
|
||||||
// 访问即 base_url/key-StyleNormal。务必与控制台样式名一致(如 free 或 free.webp)。
|
// 若值含 "=" 则视为查询串(如 "w=1080&q=80&fmt=webp"),拼成 base_url/key?查询串。
|
||||||
StyleNormal string
|
StyleDisplay string
|
||||||
// StyleVip VIP 高清七牛命名样式名(对应七牛控制台创建的样式),如 "vip"。
|
// StyleThumb 列表缩略图 CoreIX 样式名(如 "thumb");仅用于列表/卡片等小尺寸场景。
|
||||||
// 访问即 base_url/key-StyleVip。VIP 用户走此样式;务必与控制台样式名一致。
|
// 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致)。
|
||||||
StyleVip string
|
StyleThumb string
|
||||||
// LocalBase 本地兜底图片前缀;通常空——本地 /uploads 路径由前端 toAbs 拼 host。
|
|
||||||
LocalBase string
|
|
||||||
// AK/SK 七牛 AccessKey/SecretKey,用于私有空间下载签名。未配置时回落为公开 URL。
|
|
||||||
AK string
|
|
||||||
SK string
|
|
||||||
// SignTTLNormal 普通/压缩图(含命名样式 -free)签名过期秒;长过期供 SSG 静态页与 CDN 缓存。
|
|
||||||
SignTTLNormal int64
|
|
||||||
// SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。
|
|
||||||
SignTTLHD int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 构造 Composer。styleNormal 缺省回落 "free";styleVip 缺省回落 "vip";两个 TTL 缺省回落 31536000 / 3600。
|
// New 构造 Composer。
|
||||||
func New(baseURL, styleNormal, styleVip, localBase, ak, sk string, ttlNormal, ttlHD int64) *Composer {
|
// - styleDisplay 缺省回落 "high"(高清公开样式,VIP 与免费用户同质量);
|
||||||
if styleNormal == "" {
|
// - styleThumb 缺省回落 styleDisplay(未单独配置缩略图样式时,列表与详情同图)。
|
||||||
styleNormal = "free"
|
func New(baseURL, styleDisplay, styleThumb string) *Composer {
|
||||||
|
if styleDisplay == "" {
|
||||||
|
styleDisplay = "high"
|
||||||
}
|
}
|
||||||
if styleVip == "" {
|
if styleThumb == "" {
|
||||||
styleVip = "vip"
|
styleThumb = styleDisplay
|
||||||
}
|
|
||||||
if ttlNormal <= 0 {
|
|
||||||
ttlNormal = 31536000
|
|
||||||
}
|
|
||||||
if ttlHD <= 0 {
|
|
||||||
ttlHD = 3600
|
|
||||||
}
|
}
|
||||||
return &Composer{
|
return &Composer{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
StyleNormal: styleNormal,
|
StyleDisplay: styleDisplay,
|
||||||
StyleVip: styleVip,
|
StyleThumb: styleThumb,
|
||||||
LocalBase: strings.TrimRight(localBase, "/"),
|
|
||||||
AK: ak,
|
|
||||||
SK: sk,
|
|
||||||
SignTTLNormal: ttlNormal,
|
|
||||||
SignTTLHD: ttlHD,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sign 生成七牛私有空间下载签名 URL。
|
// Compose 把存储值拼成可访问 URL(详情/大图场景,统一走 StyleDisplay)。
|
||||||
//
|
|
||||||
// rawKey 是待访问的对象路径(可能含 -StyleNormal 后缀)。签名字符串为
|
|
||||||
// "base_url/rawKey?e=<deadline>",对其做 HMAC-SHA1(SK) 后 urlsafe base64 得到 token。
|
|
||||||
//
|
|
||||||
// 无 AK/SK 或 BaseURL 时回落为公开 URL(兼容未配置七牛的开发态:公开空间会忽略 token 直接返回)。
|
|
||||||
func (c *Composer) sign(rawKey string, ttl int64) string {
|
|
||||||
if c.BaseURL == "" {
|
|
||||||
return rawKey
|
|
||||||
}
|
|
||||||
if c.AK == "" || c.SK == "" {
|
|
||||||
return c.BaseURL + "/" + rawKey
|
|
||||||
}
|
|
||||||
deadline := time.Now().Unix() + ttl
|
|
||||||
toSign := fmt.Sprintf("%s/%s?e=%d", c.BaseURL, rawKey, deadline)
|
|
||||||
mac := hmac.New(sha1.New, []byte(c.SK))
|
|
||||||
mac.Write([]byte(toSign))
|
|
||||||
enc := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
return fmt.Sprintf("%s&token=%s:%s", toSign, c.AK, enc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose 把存储值拼成可访问 URL。
|
|
||||||
//
|
//
|
||||||
// - stored 为空:返回 ""
|
// - stored 为空:返回 ""
|
||||||
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)
|
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)
|
||||||
// - 已是完整 http(s) URL(历史遗留 / 外链图,如 vogue):hd 原样返回;
|
// - 已是完整 http(s) URL(历史遗留 / 外链图):原样返回(无法套样式)
|
||||||
// normal 尝试从 base_url 还原 key 再套样式签名,还原失败原样返回(外链无法套样式)
|
// - key:base_url/key!style:high(或查询串模式)
|
||||||
// - 七牛 key:hd → 原图签名 URL(短过期);normal → 普通图(命名样式)签名 URL(长过期)
|
func (c *Composer) Compose(stored string) string {
|
||||||
//
|
return c.compose(stored, c.StyleDisplay)
|
||||||
// 这样免费/未登录用户永远拿不到「不带签名的原图 URL」;VIP 拿到的是限时签名原图 URL。
|
}
|
||||||
func (c *Composer) Compose(stored string, hd bool) string {
|
|
||||||
|
// ComposeThumb 把存储值拼成「列表缩略图」URL(卡片 / 列表封面 / 图条等小尺寸场景)。
|
||||||
|
// 与 Compose 共用同一套兜底逻辑,仅样式名换成 StyleThumb(未配置时回落 StyleDisplay)。
|
||||||
|
// 注意:仅适用于同一 base_url 下的对象 key;外链与 /uploads 路径不做处理(无法套样式)。
|
||||||
|
func (c *Composer) ComposeThumb(stored string) string {
|
||||||
|
return c.compose(stored, c.StyleThumb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compose 应用指定样式拼装 URL。
|
||||||
|
func (c *Composer) compose(stored, style string) string {
|
||||||
stored = strings.TrimSpace(stored)
|
stored = strings.TrimSpace(stored)
|
||||||
if stored == "" {
|
if stored == "" {
|
||||||
return ""
|
return ""
|
||||||
@ -112,22 +74,22 @@ func (c *Composer) Compose(stored string, hd bool) string {
|
|||||||
if strings.HasPrefix(stored, "/uploads/") {
|
if strings.HasPrefix(stored, "/uploads/") {
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
// 已是完整 URL(历史遗留 / 外链图)。
|
// 已是完整 URL(历史遗留 / 外链图):原样返回(无法套样式)。
|
||||||
if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
|
if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
|
||||||
if hd {
|
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
// 同域七牛 URL:剥掉 base_url 还原 key,再套普通样式签名。
|
// 对象 key:按指定样式拼装(命名样式 key!style:xxx 或查询串 key?w=..&q=..)。
|
||||||
if c.BaseURL != "" {
|
return c.styledURL(stored, style)
|
||||||
if key, ok := strings.CutPrefix(stored, c.BaseURL+"/"); ok {
|
|
||||||
return c.sign(key+"-"+c.StyleNormal, c.SignTTLNormal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// styledURL 按样式名生成地址:命名样式(key!style:high)或查询串(key?w=..&q=..)两种模式。
|
||||||
|
func (c *Composer) styledURL(key, style string) string {
|
||||||
|
if strings.Contains(style, "=") {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(style, "?") {
|
||||||
|
sep = "&"
|
||||||
}
|
}
|
||||||
return stored // 外链图无法套样式,原样返回
|
return fmt.Sprintf("%s/%s%s%s", c.BaseURL, key, sep, style)
|
||||||
}
|
}
|
||||||
// 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。
|
return fmt.Sprintf("%s/%s!style:%s", c.BaseURL, key, style)
|
||||||
if hd {
|
|
||||||
return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD)
|
|
||||||
}
|
|
||||||
return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,29 +5,78 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestVipStyle 验证:免费(free)用 -free 命名样式、VIP 用 -vip 命名样式,且均带私有空间签名。
|
// TestDisplayStyle 验证新模型(2026-09-11):VIP 与免费用户同质量,统一走展示样式。
|
||||||
func TestVipStyle(t *testing.T) {
|
// - 命名样式(默认 high):base_url/key!style:high
|
||||||
c := New("http://toom-studio.23cm.cn", "free", "vip", "", "AK_TEST", "SK_TEST", 31536000, 3600)
|
// - 查询串模式(style 含 =):base_url/key?w=..&q=..
|
||||||
|
// - /uploads/ 与 http(s) 原样返回
|
||||||
|
func TestDisplayStyle(t *testing.T) {
|
||||||
|
c := New("https://mybucket.s3.bitiful.net", "high", "thumb", "")
|
||||||
|
got := c.Compose("runway/abc123.jpg")
|
||||||
|
want := "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("命名样式拼装错误: 期望 %s 实际 %s", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
free := c.Compose("runway/abc123.jpg", false)
|
// 缺省回落 high
|
||||||
vip := c.Compose("runway/abc123.jpg", true)
|
c0 := New("https://mybucket.s3.bitiful.net", "", "", "")
|
||||||
|
if c0.Compose("runway/abc123.jpg") != "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" {
|
||||||
|
t.Fatal("style 缺省应回落 high")
|
||||||
|
}
|
||||||
|
|
||||||
t.Logf("free URL = %s", free)
|
// 查询串模式
|
||||||
t.Logf("vip URL = %s", vip)
|
cq := New("https://mybucket.s3.bitiful.net", "w=1080&q=80&fmt=webp", "", "")
|
||||||
|
gq := cq.Compose("runway/abc123.jpg")
|
||||||
|
wq := "https://mybucket.s3.bitiful.net/runway/abc123.jpg?w=1080&q=80&fmt=webp"
|
||||||
|
if gq != wq {
|
||||||
|
t.Fatalf("查询串模式拼装错误: 期望 %s 实际 %s", wq, gq)
|
||||||
|
}
|
||||||
|
|
||||||
if free == "" || vip == "" {
|
// 本地兜底与外链原样返回
|
||||||
t.Fatal("URL 不应为空")
|
if c.Compose("/uploads/runway/x.jpg") != "/uploads/runway/x.jpg" {
|
||||||
|
t.Fatal("/uploads/ 应原样返回")
|
||||||
}
|
}
|
||||||
if !strings.Contains(free, "-free") {
|
if c.Compose("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" {
|
||||||
t.Fatalf("免费图应拼 -free 命名样式,实际: %s", free)
|
t.Fatal("http(s) 应原样返回")
|
||||||
|
}
|
||||||
|
if c.Compose("") != "" {
|
||||||
|
t.Fatal("空串应返回空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComposeThumb 验证列表缩略图拼装:走 StyleThumb,兜底逻辑与 Compose 一致。
|
||||||
|
func TestComposeThumb(t *testing.T) {
|
||||||
|
c := New("https://mybucket.s3.bitiful.net", "high", "thumb", "")
|
||||||
|
got := c.ComposeThumb("runway/abc123.jpg")
|
||||||
|
want := "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:thumb"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("缩略图样式拼装错误: 期望 %s 实际 %s", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未配置 styleThumb 时应回落展示样式,不得拼出空样式(key!style:)
|
||||||
|
c0 := New("https://mybucket.s3.bitiful.net", "high", "", "")
|
||||||
|
if got := c0.ComposeThumb("runway/abc123.jpg"); got != "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" {
|
||||||
|
t.Fatalf("styleThumb 缺省应回落 styleDisplay,实际 %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:本地路径 / 外链 / 空串
|
||||||
|
if c.ComposeThumb("/uploads/runway/x.jpg") != "/uploads/runway/x.jpg" {
|
||||||
|
t.Fatal("/uploads/ 应原样返回")
|
||||||
|
}
|
||||||
|
if c.ComposeThumb("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" {
|
||||||
|
t.Fatal("http(s) 应原样返回")
|
||||||
|
}
|
||||||
|
if c.ComposeThumb("") != "" {
|
||||||
|
t.Fatal("空串应返回空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDisplayStyleURLSafe 确认展示 URL 不含任何签名/过期参数(匿名可访问)。
|
||||||
|
func TestDisplayStyleURLSafe(t *testing.T) {
|
||||||
|
c := New("https://mybucket.s3.bitiful.net", "high", "thumb", "")
|
||||||
|
u := c.Compose("runway/abc123.jpg")
|
||||||
|
for _, bad := range []string{"X-Amz", "sign", "e="} {
|
||||||
|
if strings.Contains(u, bad) {
|
||||||
|
t.Fatalf("展示 URL 不应含签名参数 %q,实际: %s", bad, u)
|
||||||
}
|
}
|
||||||
if !strings.Contains(vip, "-vip") {
|
|
||||||
t.Fatalf("VIP 图应拼 -vip 命名样式,实际: %s", vip)
|
|
||||||
}
|
|
||||||
if !strings.Contains(free, "token=") || !strings.Contains(vip, "token=") {
|
|
||||||
t.Fatal("两种图都必须带私有空间下载签名 token=")
|
|
||||||
}
|
|
||||||
if strings.Contains(vip, "-free") {
|
|
||||||
t.Fatal("VIP 图不应混入 -free")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,11 +41,6 @@ func NewManager(secret string, expireHours int) *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注:Generate(签发令牌)已随注册 / 登录接口一并移除——当前服务没有任何签发入口,
|
|
||||||
// 只保留 Parse 供 middleware.Auth 校验既有令牌。
|
|
||||||
// 将来接入登录时恢复:构造 jwtlib.MapClaims{"uid","username","email","exp"},
|
|
||||||
// 再用 jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret) 签名即可。
|
|
||||||
|
|
||||||
// Generate 签发令牌(登录成功后调用)。
|
// Generate 签发令牌(登录成功后调用)。
|
||||||
//
|
//
|
||||||
// 构造与 Parse 对称的 Claims(uid / username / email / tier / exp),用 HS256 签名。
|
// 构造与 Parse 对称的 Claims(uid / username / email / tier / exp),用 HS256 签名。
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
// Package response 统一 HTTP 响应结构。
|
// Package response 统一 HTTP 响应结构。
|
||||||
//
|
//
|
||||||
// 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名:
|
// 响应形状严格沿用原项目约定,前端依赖这些字段名:
|
||||||
// - 列表:{ data, last_page } // last_page 供前端真分页(页码导航)使用;total/per_page/current_page 前端无消费点,已移除以精简
|
// - 公开列表(SSG):Page 返回 { data, last_page },last_page 供前端页码导航;
|
||||||
// - 详情:{ data }
|
// 运行期不回传 total / current_page / per_page(这些由 SSG 在构建期消费),故此处精简。
|
||||||
|
// - 「我的资源」列表(history/favorite):经 Data 返回 { items, total, last_page },
|
||||||
|
// total 供前端显示总数(如「共 N 条」)。
|
||||||
|
// - 详情:{ data }(可选 preview / image_total 门禁标记,见 DataPreview)
|
||||||
// - 错误:{ error }
|
// - 错误:{ error }
|
||||||
//
|
//
|
||||||
// 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。
|
// 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。
|
||||||
package response
|
package response
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fashionapi/internal/pkg/pagination"
|
||||||
|
)
|
||||||
|
|
||||||
// Data 返回单个对象:{ "data": v }。
|
// Data 返回单个对象:{ "data": v }。
|
||||||
func Data(c *gin.Context, status int, v any) {
|
func Data(c *gin.Context, status int, v any) {
|
||||||
@ -30,10 +37,11 @@ func DataPreview(c *gin.Context, status int, v any, preview bool, imageTotal int
|
|||||||
c.JSON(status, m)
|
c.JSON(status, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page 返回分页列表:{ data, last_page }。
|
// Page 返回分页列表:{ data, last_page },用于 SSG 公开列表(前端仅消费 data 与 last_page)。
|
||||||
//
|
//
|
||||||
// 只保留前端真正消费的字段:data(本页数据)与 last_page(总页数,供页码导航)。
|
// 运行期不回传 total / current_page / per_page——这些由 SSG 在构建期消费,调用方无需关心;
|
||||||
// total / current_page / per_page 前端从无读取,移除以精简协议;last_page 仍由后端用 total 与固定 size 计算。
|
// last_page 由后端用 total 与固定 size 计算。
|
||||||
|
// 注意:history/favorite 这类「我的资源」列表需返回 total,请用 response.Data 自行构造。
|
||||||
func Page(c *gin.Context, status int, data any, total int64, size int) {
|
func Page(c *gin.Context, status int, data any, total int64, size int) {
|
||||||
c.JSON(status, gin.H{
|
c.JSON(status, gin.H{
|
||||||
"data": data,
|
"data": data,
|
||||||
@ -41,21 +49,6 @@ func Page(c *gin.Context, status int, data any, total int64, size int) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PagePreview 同 Page,但可附加 preview 标记(未登录截断预览时 preview=true)。
|
|
||||||
//
|
|
||||||
// 列表接口对未登录请求只返回预览上限(PreviewLimit)条数据,并打 preview=true,
|
|
||||||
// 前端据此弹出「登录查看全部」门禁;已登录或总量本就不超上限时不打标记。
|
|
||||||
func PagePreview(c *gin.Context, status int, data any, total int64, size int, preview bool) {
|
|
||||||
m := gin.H{
|
|
||||||
"data": data,
|
|
||||||
"last_page": LastPage(total, size),
|
|
||||||
}
|
|
||||||
if preview {
|
|
||||||
m["preview"] = true
|
|
||||||
}
|
|
||||||
c.JSON(status, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error 返回错误:{ "error": msg }。
|
// Error 返回错误:{ "error": msg }。
|
||||||
func Error(c *gin.Context, status int, msg string) {
|
func Error(c *gin.Context, status int, msg string) {
|
||||||
c.JSON(status, gin.H{"error": msg})
|
c.JSON(status, gin.H{"error": msg})
|
||||||
@ -67,16 +60,7 @@ func AbortError(c *gin.Context, status int, msg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。
|
// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。
|
||||||
|
// 实现委托给 pagination.LastPage,避免分页数学在多处重复。
|
||||||
func LastPage(total int64, size int) int {
|
func LastPage(total int64, size int) int {
|
||||||
if size <= 0 {
|
return pagination.LastPage(total, size)
|
||||||
return 1
|
|
||||||
}
|
|
||||||
last := int(total) / size
|
|
||||||
if int(total)%size > 0 {
|
|
||||||
last++
|
|
||||||
}
|
|
||||||
if last == 0 {
|
|
||||||
last = 1
|
|
||||||
}
|
|
||||||
return last
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
// Package season 走秀季节 / 年份 → season_code 的推导与解析辅助。
|
// Package season 走秀季节 / 年份 → season_code 的推导与解析辅助。
|
||||||
//
|
//
|
||||||
// season_code 是对外与排序共用的紧凑编码:SS26 / FW25 / RES26 / PF25,
|
// season_code 是对外与排序共用的紧凑编码:SS26 / FW25 / RES26 / PF25,
|
||||||
// 规则:前缀由 collection_type(rtw/menswear→SS/FW;resort→RES;pre_fall→PF)
|
// 规则:前缀由 collection_type 决定(resort→RES;pre_fall→PF;couture→FW;
|
||||||
// 或 season(spring→SS / fall→FW)决定,后缀为两位年份。
|
// menswear→FW);rtw 无独立前缀,落入 season 分支(spring→SS / fall→FW),
|
||||||
|
// 后缀为两位年份。
|
||||||
// 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。
|
// 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。
|
||||||
package season
|
package season
|
||||||
|
|
||||||
@ -46,8 +47,6 @@ func prefixOf(collectionType, season string) string {
|
|||||||
return "FW"
|
return "FW"
|
||||||
case ColMenswear:
|
case ColMenswear:
|
||||||
return "FW"
|
return "FW"
|
||||||
case ColRTW:
|
|
||||||
return "SS"
|
|
||||||
}
|
}
|
||||||
switch season {
|
switch season {
|
||||||
case SeasonFall:
|
case SeasonFall:
|
||||||
|
|||||||
@ -1,196 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QiniuUploader 通过七牛云表单直传(标准库实现,无第三方 SDK 依赖)。
|
|
||||||
//
|
|
||||||
// 上传凭证算法(与官方 SDK 一致):
|
|
||||||
//
|
|
||||||
// putPolicy = {"scope":bucket,"deadline":unix+3600,"insertOnly":1}
|
|
||||||
// encPolicy = base64.URLEncoding(putPolicy) // urlsafe,保留 '=' 填充
|
|
||||||
// sign = HMAC-SHA1(SK, encPolicy)
|
|
||||||
// encSign = base64.URLEncoding(sign) // 同样保留填充
|
|
||||||
// token = AK + ":" + encSign + ":" + encPolicy
|
|
||||||
// 注意:必须用 base64.URLEncoding(带 '=' 填充),不能用 RawURLEncoding,否则服务端报 "bad token"。
|
|
||||||
//
|
|
||||||
// 然后以 multipart/form-data POST 到对应区域的上传域名,表单含 token / key / file。
|
|
||||||
type QiniuUploader struct {
|
|
||||||
ak, sk, bucket, baseURL string
|
|
||||||
upHost string
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewQiniuUploader 构造七牛上传器。
|
|
||||||
// - zone: z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡
|
|
||||||
// - baseURL: bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL;
|
|
||||||
// 为空时仅返回对象 key(调用方需另行拼接域名)。
|
|
||||||
func NewQiniuUploader(ak, sk, bucket, zone, baseURL string) *QiniuUploader {
|
|
||||||
return &QiniuUploader{
|
|
||||||
ak: ak,
|
|
||||||
sk: sk,
|
|
||||||
bucket: bucket,
|
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
upHost: upHostByZone(zone),
|
|
||||||
client: &http.Client{Timeout: 60 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// upHostByZone 把区域映射为七牛上传域名。
|
|
||||||
func upHostByZone(zone string) string {
|
|
||||||
switch zone {
|
|
||||||
case "z0":
|
|
||||||
return "https://up-z0.qiniup.com"
|
|
||||||
case "z1":
|
|
||||||
return "https://up-z1.qiniup.com"
|
|
||||||
case "z2":
|
|
||||||
return "https://up-z2.qiniup.com"
|
|
||||||
case "na0":
|
|
||||||
return "https://up-na0.qiniup.com"
|
|
||||||
case "as0":
|
|
||||||
return "https://up-as0.qiniup.com"
|
|
||||||
default:
|
|
||||||
return "https://up-z1.qiniup.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled 任一凭证缺失即视为未启用。
|
|
||||||
func (q *QiniuUploader) Enabled() bool {
|
|
||||||
return q.ak != "" && q.sk != "" && q.bucket != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeToken 生成七牛上传凭证。
|
|
||||||
func (q *QiniuUploader) makeToken() (string, error) {
|
|
||||||
putPolicy := map[string]any{
|
|
||||||
"scope": q.bucket,
|
|
||||||
"deadline": time.Now().Add(time.Hour).Unix(),
|
|
||||||
"insertOnly": 1,
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(putPolicy)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// 注意:七牛 token 使用 urlsafe base64 但保留 '=' 填充(base64.URLEncoding),
|
|
||||||
// 与官方 SDK 一致;不能用 RawURLEncoding,否则服务端校验签名失败报 "bad token"。
|
|
||||||
encPolicy := base64.URLEncoding.EncodeToString(b)
|
|
||||||
mac := hmac.New(sha1.New, []byte(q.sk))
|
|
||||||
mac.Write([]byte(encPolicy))
|
|
||||||
encSign := base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
return q.ak + ":" + encSign + ":" + encPolicy, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload 把 data 直传到七牛 bucket 的 key,返回可访问 URL。
|
|
||||||
func (q *QiniuUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
|
|
||||||
token, err := q.makeToken()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
mw := multipart.NewWriter(&buf)
|
|
||||||
if err := mw.WriteField("token", token); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := mw.WriteField("key", key); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
fw, err := mw.CreateFormFile("file", path.Base(key))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(data); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := mw.Close(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.upHost, &buf)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
||||||
|
|
||||||
resp, err := q.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("qiniu upload failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成功返回 {"hash":"...","key":"..."};失败返回 {"error":"..."}
|
|
||||||
var out struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &out); err == nil && out.Error != "" {
|
|
||||||
return "", fmt.Errorf("qiniu error: %s", out.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if q.baseURL != "" {
|
|
||||||
return q.baseURL + "/" + strings.TrimLeft(key, "/"), nil
|
|
||||||
}
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rsHost 七牛管理 API 域名(全局,不分区域)。删除对象走此端点。
|
|
||||||
const rsHost = "rs.qiniu.com"
|
|
||||||
|
|
||||||
// Delete 删除七牛 bucket 中的对象(按 key)。仅当引用计数归零时由调用方触发,
|
|
||||||
// 避免误删仍被其他图集引用的文件(七牛 key 为 sha1 内容寻址,同名图跨图集共享 key)。
|
|
||||||
//
|
|
||||||
// 管理凭证(QBox 方案,与官方 SDK 一致):
|
|
||||||
//
|
|
||||||
// entry = base64.URLEncoding(bucket + ":" + key) // 带 '=' 填充
|
|
||||||
// path = "/delete/" + entry
|
|
||||||
// signing = path + "\n" // 仅 Path + 换行,不含 Method/Host/Content-Type
|
|
||||||
// sign = HMAC-SHA1(SK, signing)
|
|
||||||
// token = AK + ":" + base64.URLEncoding(sign)
|
|
||||||
// Authorization: QBox <token> // 注意前缀是 QBox(下载凭证才是 Qiniu)
|
|
||||||
func (q *QiniuUploader) Delete(ctx context.Context, key string) error {
|
|
||||||
if !q.Enabled() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// 七牛管理凭证(QBox)签名串仅为 Path + "\n",前缀 "QBox";与下载凭证(imgurl.sign,前缀 "Qiniu")不同。
|
|
||||||
entry := base64.URLEncoding.EncodeToString([]byte(q.bucket + ":" + key))
|
|
||||||
path := "/delete/" + entry
|
|
||||||
signing := path + "\n"
|
|
||||||
mac := hmac.New(sha1.New, []byte(q.sk))
|
|
||||||
mac.Write([]byte(signing))
|
|
||||||
token := q.ak + ":" + base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
|
|
||||||
url := "https://" + rsHost + path
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "QBox "+token)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
resp, err := q.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("qiniu delete failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fashionapi/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestQiniuPurgeRoundTrip 真实验证七牛删除能力(管理凭证签名是否正确):
|
|
||||||
// 上传一个临时 key → 删除(期望 200)→ 再删一次(期望 612 资源不存在,证明已被删)。
|
|
||||||
// 若管理 token 签名错误,七牛会返回 401(invalid token),测试即失败。
|
|
||||||
// 仅在七牛启用且能连通时运行;否则跳过。
|
|
||||||
func TestQiniuPurgeRoundTrip(t *testing.T) {
|
|
||||||
cfg, err := config.Load("../../../configs/config.yml")
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("加载配置失败,跳过: %v", err)
|
|
||||||
}
|
|
||||||
if !cfg.Qiniu.Enabled {
|
|
||||||
t.Skip("七牛未启用,跳过删除验证")
|
|
||||||
}
|
|
||||||
|
|
||||||
up := NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "")
|
|
||||||
key := fmt.Sprintf("runway/_purge_verify_%d.bin", time.Now().UnixNano())
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
// 1) 上传临时对象(任意字节即可,七牛不校验内容格式)
|
|
||||||
if _, err := up.Upload(ctx, key, []byte("purge-verify-test-content"), "application/octet-stream"); err != nil {
|
|
||||||
t.Fatalf("上传临时对象失败(无法继续验证删除): %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("已上传临时 key=%s", key)
|
|
||||||
|
|
||||||
// 2) 删除(期望 200 / nil)
|
|
||||||
if err := up.Delete(ctx, key); err != nil {
|
|
||||||
// 401 表示管理 token 签名错误,是本次验证的核心目标
|
|
||||||
if strings.Contains(err.Error(), "401") {
|
|
||||||
t.Fatalf("七牛删除返回 401,管理凭证签名错误: %v", err)
|
|
||||||
}
|
|
||||||
t.Fatalf("首次删除失败: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("首次删除成功(200),key=%s", key)
|
|
||||||
|
|
||||||
// 3) 再删一次(期望 612 资源不存在,证明确实被删除)
|
|
||||||
if err := up.Delete(ctx, key); err != nil {
|
|
||||||
if !strings.Contains(err.Error(), "612") {
|
|
||||||
t.Fatalf("二次删除期望 612(资源不存在),实际: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("二次删除返回 612,确认对象已删除: %s", key)
|
|
||||||
} else {
|
|
||||||
t.Fatalf("二次删除应返回 612(资源不存在),却返回 200,说明首次删除未生效")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,9 @@
|
|||||||
//
|
//
|
||||||
// 两种实现:
|
// 两种实现:
|
||||||
// - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。
|
// - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。
|
||||||
// - QiniuUploader:直传七牛云对象存储(标准库实现,无第三方 SDK 依赖)。
|
// - S4Uploader:直传缤纷云 S4 对象存储(兼容 S3,基于 aws-sdk-go-v2)。
|
||||||
//
|
//
|
||||||
// 入库 worker 主用 Qiniu(若启用),单张失败时兜底写本地,避免图片完全丢失。
|
// 入库 worker 主用 S4(若启用),单张失败时兜底写本地,避免图片完全丢失。
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// Uploader 抽象图片上传。
|
// Uploader 抽象图片上传。
|
||||||
// Upload 返回写入数据库的图片访问地址:
|
// Upload 返回写入数据库的图片访问地址:
|
||||||
// - Qiniu 返回完整 https URL(base_url + key)
|
// - S4 返回对象 key(base_url 由 imgurl.Composer 在渲染时拼)
|
||||||
// - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径
|
// - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径
|
||||||
type Uploader interface {
|
type Uploader interface {
|
||||||
// Upload 上传 data 到 key 指定的对象,返回访问 URL。
|
// Upload 上传 data 到 key 指定的对象,返回访问 URL。
|
||||||
@ -26,7 +26,7 @@ type Uploader interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deleter 抽象图片删除(清理孤儿文件用)。
|
// Deleter 抽象图片删除(清理孤儿文件用)。
|
||||||
// QiniuUploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中
|
// S4Uploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中
|
||||||
// 引用计数归零时才由调用方触发,避免误删仍被引用的文件。
|
// 引用计数归零时才由调用方触发,避免误删仍被引用的文件。
|
||||||
type Deleter interface {
|
type Deleter interface {
|
||||||
// Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。
|
// Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
// Package textutil 提供文本处理辅助函数。
|
|
||||||
package textutil
|
|
||||||
|
|
||||||
// Summarize 按 rune 截断字符串,避免中文被按字节切断;超出部分追加省略号。
|
|
||||||
//
|
|
||||||
// 行为与原项目 handlers.summarize 完全一致(含省略号字符 U+2026)。
|
|
||||||
func Summarize(s string, n int) string {
|
|
||||||
runes := []rune(s)
|
|
||||||
if len(runes) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return string(runes[:n]) + "…"
|
|
||||||
}
|
|
||||||
@ -16,7 +16,7 @@ import (
|
|||||||
type ArticleRepository interface {
|
type ArticleRepository interface {
|
||||||
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
||||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code/source_url 等管理字段。
|
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
||||||
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||||||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||||
@ -62,12 +62,11 @@ const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runw
|
|||||||
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||||
|
|
||||||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||||||
// collection_type / season_code / source_url 等管理字段(前端列表不展示这些)。
|
// collection_type / season_code 等管理字段(前端列表不展示这些)。
|
||||||
const articleAdminColumns = `brand_runway.id, brand_runway.brand_id,
|
const articleAdminColumns = `brand_runway.id, brand_runway.brand_id,
|
||||||
brand_runway.title_en, brand_runway.title_cn,
|
brand_runway.title_en, brand_runway.title_cn,
|
||||||
brand_runway.cover, brand_runway.image_count, brand_runway.year,
|
brand_runway.cover, brand_runway.image_count, brand_runway.year,
|
||||||
brand_runway.collection_type, brand_runway.season, brand_runway.season_code,
|
brand_runway.collection_type, brand_runway.season, brand_runway.season_code,
|
||||||
brand_runway.source_url,
|
|
||||||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||||
|
|
||||||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||||
@ -232,7 +231,7 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
|||||||
var imgs []model.BrandRunwayImage
|
var imgs []model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Select("runway_id, image, name, sort_order").
|
Select("runway_id, image, name, sort_order").
|
||||||
Where("runway_id IN ? AND is_deleted = 0", ids).
|
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -11,7 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
||||||
// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images,按 source_url 去重)。
|
// (ingest_nces)+ 走秀正式表写入(brand_runway / 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 表,领取靠 MySQL 的 FOR UPDATE SKIP LOCKED
|
||||||
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
||||||
@ -27,37 +31,23 @@ type IngestRepository interface {
|
|||||||
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
||||||
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
||||||
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
||||||
// RunwayIDBySourceURL 按来源链接查是否已存在走秀;返回 (id, found)。
|
// RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表;
|
||||||
RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
// 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。
|
||||||
// CreateRunway 插入走秀正式行,返回自增主键。
|
RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
||||||
CreateRunway(ctx context.Context, r *model.BrandRunway) (uint32, error)
|
|
||||||
// CreateRunwayImages 批量插入走秀图片行。
|
|
||||||
CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error
|
|
||||||
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
|
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
|
||||||
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
|
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
|
||||||
// CreateRunwayDraftImages 批量插入草稿图片行。
|
// CreateRunwayDraftImages 批量插入草稿图片行。
|
||||||
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
|
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
|
||||||
// DraftIDBySourceURL 按来源链接查是否已有 pending 草稿;返回 (id, found)。
|
// StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。
|
||||||
DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error)
|
||||||
// StreetSnapIDBySourceURL 按来源链接查是否已存在街拍正式表;返回 (id, found)。
|
|
||||||
StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
|
||||||
// DraftStreetIDBySourceURL 按来源链接查是否已有 pending 街拍草稿;返回 (id, found)。
|
|
||||||
DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
|
||||||
// RunwayIDByUnique 按实体键(品牌+季节码+系列)查是否已存在正式走秀;返回 (id, found)。
|
|
||||||
// 多来源爬同品牌同季时应合并为一条,故去重键是实体而非 source_url。
|
|
||||||
RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
|
||||||
// DraftRunwayIDByUnique 按实体键查是否已有 pending 走秀草稿;返回 (id, found)。
|
|
||||||
DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
|
||||||
// StreetSnapIDByUnique 按实体键(城市+年份)查是否已存在街拍正式表;返回 (id, found)。
|
|
||||||
StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error)
|
|
||||||
// DraftStreetSnapIDByUnique 按实体键查是否已有 pending 街拍草稿;返回 (id, found)。
|
|
||||||
DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error)
|
|
||||||
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
|
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
|
||||||
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
|
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
|
||||||
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
|
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
|
||||||
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
|
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
|
||||||
// ListJobs 按 id 倒序列出最近的入库任务(用于后台监控页)。
|
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||||
ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error)
|
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||||
|
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||||
|
CountJobs(ctx context.Context) (int64, error)
|
||||||
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
|
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
|
||||||
RetryJob(ctx context.Context, id uint32) error
|
RetryJob(ctx context.Context, id uint32) error
|
||||||
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。
|
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。
|
||||||
@ -66,14 +56,6 @@ type IngestRepository interface {
|
|||||||
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
|
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
|
||||||
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
|
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
|
||||||
ScheduleRetry(ctx context.Context, id uint32, errMsg string) error
|
ScheduleRetry(ctx context.Context, id uint32, errMsg string) error
|
||||||
// ExistsSourceURLs 批量判断一批 source_url 是否已爬取过,返回命中集合(true = 已存在,无需再抓)。
|
|
||||||
//
|
|
||||||
// 语义必须与 worker 判重(processRunway / processStreet)严格一致,否则会出现
|
|
||||||
// 「预检说没有、worker 又判重命中」的重复抓取,或「预检说有、实际已被拒/已删」的漏抓:
|
|
||||||
// - 正式表(brand_runway / street_snap):source_url 命中且 is_deleted = 0
|
|
||||||
// - 草稿表(brand_runway_draft / street_snap_draft):status = pending 且 is_deleted = 0
|
|
||||||
// —— 被审核拒绝(rejected)的草稿不算已存在,允许重新抓取。
|
|
||||||
ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingestRepository struct {
|
type ingestRepository struct {
|
||||||
@ -254,17 +236,15 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。
|
||||||
if sourceURL == "" {
|
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
var row struct {
|
var row struct {
|
||||||
ID uint32 `gorm:"column:id"`
|
ID uint32 `gorm:"column:id"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Select("id").
|
Select("id").
|
||||||
Where("source_url = ? AND is_deleted = 0", sourceURL).
|
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -276,26 +256,6 @@ func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL st
|
|||||||
return row.ID, true, nil
|
return row.ID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunway(ctx context.Context, rw *model.BrandRunway) (uint32, error) {
|
|
||||||
now := uint32(time.Now().Unix())
|
|
||||||
rw.CreatedAt = now
|
|
||||||
rw.UpdatedAt = now
|
|
||||||
if rw.IsDeleted == 0 {
|
|
||||||
rw.IsDeleted = 0
|
|
||||||
}
|
|
||||||
if err := r.db.WithContext(ctx).Create(rw).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return rw.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error {
|
|
||||||
if len(imgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
|
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
d.CreatedAt = now
|
d.CreatedAt = now
|
||||||
@ -321,117 +281,8 @@ func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []m
|
|||||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。
|
||||||
if sourceURL == "" {
|
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BrandRunwayDraft{}).
|
|
||||||
Select("id").
|
|
||||||
Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending).
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
return row.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ingestRepository) StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
|
||||||
if sourceURL == "" {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.StreetSnap{}).
|
|
||||||
Select("id").
|
|
||||||
Where("source_url = ? AND is_deleted = 0", sourceURL).
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
return row.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ingestRepository) DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
|
||||||
if sourceURL == "" {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.StreetSnapDraft{}).
|
|
||||||
Select("id").
|
|
||||||
Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending).
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
return row.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunwayIDByUnique 按实体键(品牌+季节码+系列)查正式走秀,多来源同实体合并为一条。
|
|
||||||
func (r *ingestRepository) RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BrandRunway{}).
|
|
||||||
Select("id").
|
|
||||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
return row.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DraftRunwayIDByUnique 按实体键查 pending 走秀草稿。
|
|
||||||
func (r *ingestRepository) DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BrandRunwayDraft{}).
|
|
||||||
Select("id").
|
|
||||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0", brandID, seasonCode, collectionType, model.DraftStatusPending).
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
return row.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreetSnapIDByUnique 按实体键(城市+年份)查街拍正式表;城市为空则不去重(避免空城市互并)。
|
|
||||||
func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
|
||||||
if city == "" {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
var row struct {
|
var row struct {
|
||||||
ID uint32 `gorm:"column:id"`
|
ID uint32 `gorm:"column:id"`
|
||||||
}
|
}
|
||||||
@ -450,29 +301,6 @@ func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string
|
|||||||
return row.ID, true, nil
|
return row.ID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftStreetSnapIDByUnique 按实体键查 pending 街拍草稿;城市为空则不去重。
|
|
||||||
func (r *ingestRepository) DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
|
||||||
if city == "" {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.StreetSnapDraft{}).
|
|
||||||
Select("id").
|
|
||||||
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0", city, year, model.DraftStatusPending).
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
return row.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
|
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
d.CreatedAt = now
|
d.CreatedAt = now
|
||||||
@ -498,18 +326,30 @@ func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs
|
|||||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListJobs 按 id 倒序列出最近的入库任务(后台监控页用)。
|
// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。
|
||||||
func (r *ingestRepository) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) {
|
func (r *ingestRepository) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error) {
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 200
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
}
|
}
|
||||||
var jobs []model.IngestJob
|
var jobs []model.IngestJob
|
||||||
if err := r.db.WithContext(ctx).Order("id DESC").Limit(limit).Find(&jobs).Error; err != nil {
|
if err := r.db.WithContext(ctx).Order("id DESC").Offset(offset).Limit(limit).Find(&jobs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountJobs 返回 ingest_jobs 总条数(后台监控页分页用)。
|
||||||
|
func (r *ingestRepository) CountJobs(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
if err := r.db.WithContext(ctx).Model(&model.IngestJob{}).Count(&n).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RetryJob 把一条 failed 任务重置回 pending,清掉 last_error / locked_at / next_attempt_at,
|
// RetryJob 把一条 failed 任务重置回 pending,清掉 last_error / locked_at / next_attempt_at,
|
||||||
// 让 worker(每 3s 扫一次 pending)立即重新拉起处理。仅对 failed 生效,其它状态原样不动。
|
// 让 worker(每 3s 扫一次 pending)立即重新拉起处理。仅对 failed 生效,其它状态原样不动。
|
||||||
func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||||
@ -553,70 +393,3 @@ func indexOf(s, sub string) int {
|
|||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExistsSourceURLs 批量判断 source_url 是否已爬取过(预检接口用)。
|
|
||||||
//
|
|
||||||
// 走秀与街拍的正式表、草稿表各查一次 IN,命中即标记。与 worker 判重共用同一套条件,
|
|
||||||
// 保证「爬虫预检跳过」与「worker 判重跳过」判定结果一致。
|
|
||||||
func (r *ingestRepository) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) {
|
|
||||||
out := make(map[string]bool)
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去重 + 去空:同一批 URL 可能重复出现,避免无谓的返回行与 SQL 长度。
|
|
||||||
uniq := make([]string, 0, len(urls))
|
|
||||||
seen := make(map[string]struct{}, len(urls))
|
|
||||||
for _, u := range urls {
|
|
||||||
if u == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[u]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[u] = struct{}{}
|
|
||||||
uniq = append(uniq, u)
|
|
||||||
}
|
|
||||||
if len(uniq) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
// 单次请求上限,避免超长 IN 拖慢数据库(超出部分按「未抓过」处理,最多多抓几个,不会漏判已存在的)。
|
|
||||||
const maxBatch = 500
|
|
||||||
if len(uniq) > maxBatch {
|
|
||||||
uniq = uniq[:maxBatch]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 四张表:正式表只看未删除,草稿表只认 pending(被拒草稿允许重抓)。
|
|
||||||
queries := []struct {
|
|
||||||
dest any
|
|
||||||
where string
|
|
||||||
args []any
|
|
||||||
}{
|
|
||||||
{dest: &model.BrandRunway{}, where: "source_url IN ? AND is_deleted = 0"},
|
|
||||||
{dest: &model.StreetSnap{}, where: "source_url IN ? AND is_deleted = 0"},
|
|
||||||
{dest: &model.BrandRunwayDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}},
|
|
||||||
{dest: &model.StreetSnapDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}},
|
|
||||||
}
|
|
||||||
|
|
||||||
type hit struct {
|
|
||||||
SourceURL string `gorm:"column:source_url"`
|
|
||||||
}
|
|
||||||
for i := range queries {
|
|
||||||
var rows []hit
|
|
||||||
q := r.db.WithContext(ctx).
|
|
||||||
Model(queries[i].dest).
|
|
||||||
Select("source_url").
|
|
||||||
Where(queries[i].where, append([]any{uniq}, queries[i].args...)...)
|
|
||||||
if err := q.Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
out[row.SourceURL] = true
|
|
||||||
}
|
|
||||||
// 全部命中就无需再查后面的表。
|
|
||||||
if len(out) == len(uniq) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ type ReviewRepository interface {
|
|||||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
|
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
|
||||||
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||||
// SaveRunwayFromDraft 晋升:按 source_url 幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
|
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
|
||||||
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||||
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。
|
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。
|
||||||
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
|
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
|
||||||
@ -42,8 +42,6 @@ type ReviewRepository interface {
|
|||||||
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||||
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||||
// CountPending 统计 pending 草稿数(合并 runway + street,仪表盘卡片用)。
|
|
||||||
CountPending(ctx context.Context) (int64, error)
|
|
||||||
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
|
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
|
||||||
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
|
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
|
||||||
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
|
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
|
||||||
@ -150,7 +148,7 @@ func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveRunwayFromDraft 晋升草稿到正式表:
|
// SaveRunwayFromDraft 晋升草稿到正式表:
|
||||||
// 1. 按 source_url 查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
|
// 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
|
||||||
// 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建;
|
// 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建;
|
||||||
// 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建;
|
// 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建;
|
||||||
// 4. 返回正式表主键。
|
// 4. 返回正式表主键。
|
||||||
@ -176,10 +174,19 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
|||||||
}
|
}
|
||||||
imgs := unionRunwayDraftImages(curImgs, sibImgs)
|
imgs := unionRunwayDraftImages(curImgs, sibImgs)
|
||||||
|
|
||||||
|
// image_count 仅计主图(is_detail=0):对外列表卡片「N 张」与详情默认画廊都只展示主图,
|
||||||
|
// 细节图作为主图的附属按需展开,不计入主图数。
|
||||||
|
mainCount := 0
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.IsDetail == 0 {
|
||||||
|
mainCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
var runwayID uint32
|
var runwayID uint32
|
||||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// 1) 按 source_url 查找既有正式走秀
|
// 1) 按实体键查找既有正式走秀
|
||||||
var existing model.BrandRunway
|
var existing model.BrandRunway
|
||||||
if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType).
|
if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType).
|
||||||
Limit(1).Find(&existing).Error; eErr != nil {
|
Limit(1).Find(&existing).Error; eErr != nil {
|
||||||
@ -196,8 +203,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
|||||||
"collection_type": draft.CollectionType,
|
"collection_type": draft.CollectionType,
|
||||||
"season_code": draft.SeasonCode,
|
"season_code": draft.SeasonCode,
|
||||||
"cover": draft.Cover,
|
"cover": draft.Cover,
|
||||||
"source_url": draft.SourceURL,
|
"image_count": uint16(mainCount),
|
||||||
"image_count": uint16(len(imgs)),
|
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,8 +225,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
|||||||
CollectionType: draft.CollectionType,
|
CollectionType: draft.CollectionType,
|
||||||
SeasonCode: draft.SeasonCode,
|
SeasonCode: draft.SeasonCode,
|
||||||
Cover: draft.Cover,
|
Cover: draft.Cover,
|
||||||
SourceURL: draft.SourceURL,
|
ImageCount: uint16(mainCount),
|
||||||
ImageCount: uint16(len(imgs)),
|
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@ -245,6 +250,8 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
|||||||
Image: im.Image,
|
Image: im.Image,
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
SortOrder: uint32(i + 1),
|
SortOrder: uint32(i + 1),
|
||||||
|
LookIndex: im.LookIndex,
|
||||||
|
IsDetail: im.IsDetail,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
})
|
})
|
||||||
@ -261,23 +268,6 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
|||||||
return runwayID, nil
|
return runwayID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) CountPending(ctx context.Context) (int64, error) {
|
|
||||||
var n1, n2 int64
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BrandRunwayDraft{}).
|
|
||||||
Where("status = ? AND is_deleted = 0", model.DraftStatusPending).
|
|
||||||
Count(&n1).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.StreetSnapDraft{}).
|
|
||||||
Where("status = ? AND is_deleted = 0", model.DraftStatusPending).
|
|
||||||
Count(&n2).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return n1 + n2, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
|
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
|
||||||
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
|
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
@ -377,7 +367,7 @@ func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32,
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按 source_url 幂等 upsert,替换图片)。
|
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按实体键 city+year 幂等 upsert,替换图片)。
|
||||||
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
|
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
|
||||||
// 在事务内完成,保证草稿与正式表一致性。
|
// 在事务内完成,保证草稿与正式表一致性。
|
||||||
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
|
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
|
||||||
@ -412,7 +402,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
|
|||||||
"year": draft.Year,
|
"year": draft.Year,
|
||||||
"city": draft.City,
|
"city": draft.City,
|
||||||
"cover": draft.Cover,
|
"cover": draft.Cover,
|
||||||
"source_url": draft.SourceURL,
|
|
||||||
"image_count": uint16(len(imgs)),
|
"image_count": uint16(len(imgs)),
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
@ -428,7 +417,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
|
|||||||
Year: draft.Year,
|
Year: draft.Year,
|
||||||
City: draft.City,
|
City: draft.City,
|
||||||
Cover: draft.Cover,
|
Cover: draft.Cover,
|
||||||
SourceURL: draft.SourceURL,
|
|
||||||
ImageCount: uint16(len(imgs)),
|
ImageCount: uint16(len(imgs)),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
|
|||||||
@ -56,8 +56,6 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
internal.Use(middleware.IngestAuth(*opt.IngestAuth))
|
internal.Use(middleware.IngestAuth(*opt.IngestAuth))
|
||||||
internal.POST("/ingest", opt.Ingest.Submit)
|
internal.POST("/ingest", opt.Ingest.Submit)
|
||||||
internal.GET("/crawl/brands", opt.Ingest.CrawlBrands)
|
internal.GET("/crawl/brands", opt.Ingest.CrawlBrands)
|
||||||
// 图集预检:爬虫抓详情页前批量问「这些 source_url 是否已爬取过」,跳过已存在的。
|
|
||||||
internal.POST("/crawl/exists", opt.Ingest.CrawlExists)
|
|
||||||
} else {
|
} else {
|
||||||
r.POST("/admin/internal/ingest", func(c *gin.Context) {
|
r.POST("/admin/internal/ingest", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingest disabled"})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingest disabled"})
|
||||||
|
|||||||
@ -99,16 +99,14 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
|
|||||||
CollectionType: "rtw",
|
CollectionType: "rtw",
|
||||||
SeasonCode: "SS24",
|
SeasonCode: "SS24",
|
||||||
ImageCount: 12,
|
ImageCount: 12,
|
||||||
SourceURL: "https://www.vogue.com/fashion-shows",
|
|
||||||
}}, 1, nil
|
}}, 1, nil
|
||||||
}
|
}
|
||||||
func (f *fakeArticle) Detail(ctx context.Context, id, locale string, hd bool) (*dto.PublicArticleDetail, error) {
|
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
return &dto.PublicArticleDetail{
|
return &dto.PublicArticleDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
BrandUID: "001DESke",
|
BrandUID: "001DESke",
|
||||||
Title: "Acne Studios Spring 2024",
|
Title: "Acne Studios Spring 2024",
|
||||||
BrandName: "Acne Studios",
|
BrandName: "Acne Studios",
|
||||||
SourceURL: "https://www.vogue.com/fashion-shows",
|
|
||||||
Images: []dto.PublicArticleImage{
|
Images: []dto.PublicArticleImage{
|
||||||
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
||||||
{ID: "i2", Image: "/uploads/runway/2.jpg", Name: "Look 2"},
|
{ID: "i2", Image: "/uploads/runway/2.jpg", Name: "Look 2"},
|
||||||
@ -142,7 +140,7 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub
|
|||||||
ImageCount: 8,
|
ImageCount: 8,
|
||||||
}}, 1, nil
|
}}, 1, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStreet) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
|
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
return &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
Title: "Paris Fashion Week Street",
|
Title: "Paris Fashion Week Street",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -23,7 +24,7 @@ type MediaCleanupEnqueuer interface {
|
|||||||
// ArticleService 文章业务接口。
|
// ArticleService 文章业务接口。
|
||||||
type ArticleService interface {
|
type ArticleService interface {
|
||||||
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
||||||
Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error)
|
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
||||||
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
||||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||||
@ -49,9 +50,9 @@ type articleService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewArticleService 创建文章服务。
|
// NewArticleService 创建文章服务。
|
||||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量);
|
||||||
// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删);
|
// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删);
|
||||||
// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
||||||
func NewArticleService(articles repository.ArticleRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) ArticleService {
|
func NewArticleService(articles repository.ArticleRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) ArticleService {
|
||||||
return &articleService{articles: articles, media: media, img: img, deleter: deleter, cleaner: cleaner}
|
return &articleService{articles: articles, media: media, img: img, deleter: deleter, cleaner: cleaner}
|
||||||
}
|
}
|
||||||
@ -73,7 +74,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
|||||||
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
||||||
BrandUID: hashid.Encode(r.BrandID),
|
BrandUID: hashid.Encode(r.BrandID),
|
||||||
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
||||||
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
|
||||||
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
|
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
})
|
})
|
||||||
@ -88,7 +89,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
|
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
|
||||||
// 来源链接的管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
||||||
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
|
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
|
||||||
q.Normalize()
|
q.Normalize()
|
||||||
rows, total, err := s.articles.ListAdmin(ctx, q)
|
rows, total, err := s.articles.ListAdmin(ctx, q)
|
||||||
@ -107,7 +108,6 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
|
|||||||
CollectionType: r.CollectionType,
|
CollectionType: r.CollectionType,
|
||||||
SeasonCode: r.SeasonCode,
|
SeasonCode: r.SeasonCode,
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
SourceURL: r.SourceURL,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return list, total, nil
|
return list, total, nil
|
||||||
@ -125,8 +125,9 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
// 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立);
|
||||||
imgs := s.toPublicImages(raw, false)
|
// 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||||
|
imgs := s.toThumbImages(raw)
|
||||||
imgs = excludeCover(imgs, list[i].Cover)
|
imgs = excludeCover(imgs, list[i].Cover)
|
||||||
if len(imgs) > limit {
|
if len(imgs) > limit {
|
||||||
imgs = imgs[:limit]
|
imgs = imgs[:limit]
|
||||||
@ -152,8 +153,8 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
||||||
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||||
func (s *articleService) Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) {
|
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
row, err := s.articles.FindByID(ctx, id)
|
row, err := s.articles.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
@ -167,16 +168,40 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string, h
|
|||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 默认仅主图(is_detail=0)进 Images;细节图按 look_index 分组进 Details,
|
||||||
|
// 供前端「细节入口」按需展开。这样既满足「默认只展示主图」,又不丢失细节图数据。
|
||||||
|
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||||
|
byLook := make(map[uint32][]model.BrandRunwayImage)
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.IsDetail == 0 {
|
||||||
|
mains = append(mains, im)
|
||||||
|
} else {
|
||||||
|
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details := make([]dto.RunwayLookDetails, 0, len(byLook))
|
||||||
|
lookIdxs := make([]uint32, 0, len(byLook))
|
||||||
|
for k := range byLook {
|
||||||
|
lookIdxs = append(lookIdxs, k)
|
||||||
|
}
|
||||||
|
sort.Slice(lookIdxs, func(i, j int) bool { return lookIdxs[i] < lookIdxs[j] })
|
||||||
|
for _, k := range lookIdxs {
|
||||||
|
details = append(details, dto.RunwayLookDetails{
|
||||||
|
LookIndex: k,
|
||||||
|
Images: s.toPublicImages(byLook[k]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return &dto.PublicArticleDetail{
|
return &dto.PublicArticleDetail{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID),
|
UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID),
|
||||||
BrandUID: hashid.Encode(row.BrandID),
|
BrandUID: hashid.Encode(row.BrandID),
|
||||||
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
||||||
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
||||||
Cover: s.img.Compose(row.Cover, hd),
|
Cover: s.img.Compose(row.Cover),
|
||||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||||
SourceURL: row.SourceURL,
|
Images: s.toPublicImages(mains),
|
||||||
Images: s.toPublicImages(imgs, hd),
|
Details: details,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,11 +266,12 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
|
||||||
// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||||
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
|
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
|
||||||
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条与右下角
|
||||||
func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool) []dto.PublicArticleImage {
|
// 细节缩略图等小尺寸场景使用——那些位置只显示几十到一百多像素,用大图纯属浪费带宽。
|
||||||
|
// 注意:列表用的 toThumbImages 不填 Thumb(列表的 Image 本身就是缩略图)。
|
||||||
|
func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
|
||||||
if len(imgs) == 0 {
|
if len(imgs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -253,8 +279,30 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool)
|
|||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
out = append(out, dto.PublicArticleImage{
|
out = append(out, dto.PublicArticleImage{
|
||||||
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
||||||
Image: s.img.Compose(im.Image, hd),
|
Image: s.img.Compose(im.Image),
|
||||||
|
Thumb: s.img.ComposeThumb(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
|
IsDetail: im.IsDetail,
|
||||||
|
LookIndex: im.LookIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toThumbImages 与 toPublicImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
|
||||||
|
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicImages(展示样式)。
|
||||||
|
func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
out = append(out, dto.PublicArticleImage{
|
||||||
|
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
||||||
|
Image: s.img.ComposeThumb(im.Image),
|
||||||
|
Name: im.Name,
|
||||||
|
IsDetail: im.IsDetail,
|
||||||
|
LookIndex: im.LookIndex,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import (
|
|||||||
// - Login 签发两者;access 用于日常请求,refresh 仅在续期时使用。
|
// - Login 签发两者;access 用于日常请求,refresh 仅在续期时使用。
|
||||||
// - Refresh 用 refresh token 换发新 access(refresh 复用、不轮换,足够内部工具)。
|
// - Refresh 用 refresh token 换发新 access(refresh 复用、不轮换,足够内部工具)。
|
||||||
// - Logout 吊销当前 refresh;RevokeAllByRefresh 吊销该用户全部 refresh(踢下线)。
|
// - Logout 吊销当前 refresh;RevokeAllByRefresh 吊销该用户全部 refresh(踢下线)。
|
||||||
|
//
|
||||||
// 由于 refresh 落库,服务端得以单方面作废会话——这是纯无状态 JWT 做不到的。
|
// 由于 refresh 落库,服务端得以单方面作废会话——这是纯无状态 JWT 做不到的。
|
||||||
type AuthService interface {
|
type AuthService interface {
|
||||||
Me(ctx context.Context, userID uint32) (*dto.UserPayload, error)
|
Me(ctx context.Context, userID uint32) (*dto.UserPayload, error)
|
||||||
|
|||||||
@ -27,7 +27,6 @@ func NewError(status int, msg string) *Error {
|
|||||||
|
|
||||||
func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, msg) }
|
func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, msg) }
|
||||||
func unauthorized(msg string) *Error { return NewError(http.StatusUnauthorized, msg) }
|
func unauthorized(msg string) *Error { return NewError(http.StatusUnauthorized, msg) }
|
||||||
func conflict(msg string) *Error { return NewError(http.StatusConflict, msg) }
|
|
||||||
func internalErr(msg string) *Error { return NewError(http.StatusInternalServerError, msg) }
|
func internalErr(msg string) *Error { return NewError(http.StatusInternalServerError, msg) }
|
||||||
|
|
||||||
// ErrArticleNotFound 文章不存在,文案与原项目一致。
|
// ErrArticleNotFound 文章不存在,文案与原项目一致。
|
||||||
|
|||||||
@ -55,7 +55,7 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa
|
|||||||
ArticleUID: hashid.Encode(b.ArticleID),
|
ArticleUID: hashid.Encode(b.ArticleID),
|
||||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||||
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
||||||
Cover: s.img.Compose(b.Cover, false), // 首页封面统一普通图
|
Cover: s.img.Compose(b.Cover), // 首页热门卡片为大图展示,用高清样式(high)
|
||||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,32 +62,28 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error())
|
return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error())
|
||||||
}
|
}
|
||||||
if payload.SourceURL == "" {
|
|
||||||
return 0, NewError(http.StatusBadRequest, "source_url required")
|
|
||||||
}
|
|
||||||
// runway 必须带品牌;street 不关联品牌,brand_uid 可选
|
// runway 必须带品牌;street 不关联品牌,brand_uid 可选
|
||||||
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
||||||
return 0, NewError(http.StatusBadRequest, "brand_uid required")
|
return 0, NewError(http.StatusBadRequest, "brand_uid required")
|
||||||
}
|
}
|
||||||
job := &model.IngestJob{SourceURL: payload.SourceURL, Payload: string(raw), Kind: model.IngestKindCrawl}
|
job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl}
|
||||||
if err := s.repo.Enqueue(ctx, job); err != nil {
|
if err := s.repo.Enqueue(ctx, job); err != nil {
|
||||||
return 0, internalErr(err.Error())
|
return 0, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
return job.ID, nil
|
return job.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExistsSourceURLs 批量判断 source_url 是否已爬取过(爬虫抓取前的预检)。
|
// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
|
||||||
//
|
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
|
||||||
// 返回命中集合:key 为已存在的 source_url,value 恒为 true。爬虫拿到结果后应跳过这些图集,
|
jobs, err := s.repo.ListJobs(ctx, offset, limit)
|
||||||
// 不必再抓详情页、提取图片 URL 并上报——这一整套动作在判重命中时本来也会被 worker 丢弃。
|
if err != nil {
|
||||||
// 判定条件与 worker 判重完全一致(正式表未删除 / 草稿表 pending),因此不会漏抓也不会重复抓。
|
return nil, 0, err
|
||||||
func (s *IngestService) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) {
|
|
||||||
return s.repo.ExistsSourceURLs(ctx, urls)
|
|
||||||
}
|
}
|
||||||
|
total, err := s.repo.CountJobs(ctx)
|
||||||
// ListJobs 列出最近的入库任务(后台监控页用)。
|
if err != nil {
|
||||||
func (s *IngestService) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) {
|
return nil, 0, err
|
||||||
return s.repo.ListJobs(ctx, limit)
|
}
|
||||||
|
return jobs, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。
|
// RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。
|
||||||
@ -225,24 +221,43 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同品牌同季时各建一条草稿,
|
// 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过,
|
||||||
// 晋升阶段(SaveRunwayFromDraft)按实体键(品牌+季节码+系列)合并为正式表一条并聚合图片。
|
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
|
||||||
if _, found, err := s.repo.RunwayIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, found, err := s.repo.DraftIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 补 season_code(vogue.go 历史漏填的 bug,统一在此兜底)
|
|
||||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||||
|
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found {
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 4) 下载图片并上传到存储(七牛优先,失败兜底本地)
|
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
|
||||||
cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "runway")
|
|
||||||
if len(p.Images) > 0 && imgFailed {
|
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||||
|
var cover string
|
||||||
|
var draftImages []model.BrandRunwayDraftImage
|
||||||
|
var keys []string
|
||||||
|
var imgFailed bool
|
||||||
|
var imageCount uint16
|
||||||
|
if len(p.Looks) > 0 {
|
||||||
|
cover, draftImages, keys, imgFailed = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||||
|
imageCount = uint16(len(p.Looks))
|
||||||
|
} else {
|
||||||
|
var imgs []string
|
||||||
|
cover, imgs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway")
|
||||||
|
imageCount = uint16(len(imgs))
|
||||||
|
for i, img := range imgs {
|
||||||
|
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
||||||
|
Image: img,
|
||||||
|
Name: fmt.Sprintf("Look %d", i+1),
|
||||||
|
SortOrder: uint32(i + 1),
|
||||||
|
LookIndex: uint32(i + 1),
|
||||||
|
IsDetail: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if imgFailed {
|
||||||
|
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||||
s.cleanupUploads(ctx, keys)
|
s.cleanupUploads(ctx, keys)
|
||||||
s.failOrRetry(ctx, job.ID, "image download failed")
|
s.failOrRetry(ctx, job.ID, "image download failed")
|
||||||
@ -262,8 +277,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
CollectionType: p.CollectionType,
|
CollectionType: p.CollectionType,
|
||||||
SeasonCode: seasonCode,
|
SeasonCode: seasonCode,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
SourceURL: p.SourceURL,
|
ImageCount: imageCount,
|
||||||
ImageCount: uint16(len(imgs)),
|
|
||||||
Status: model.DraftStatusPending,
|
Status: model.DraftStatusPending,
|
||||||
}
|
}
|
||||||
id, err := s.repo.CreateRunwayDraft(ctx, draft)
|
id, err := s.repo.CreateRunwayDraft(ctx, draft)
|
||||||
@ -271,31 +285,75 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows := make([]model.BrandRunwayDraftImage, 0, len(imgs))
|
for i := range draftImages {
|
||||||
for i, img := range imgs {
|
draftImages[i].DraftID = id
|
||||||
rows = append(rows, model.BrandRunwayDraftImage{
|
|
||||||
DraftID: id,
|
|
||||||
Image: img,
|
|
||||||
Name: fmt.Sprintf("Look %d", i+1),
|
|
||||||
SortOrder: uint32(i + 1),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if err := s.repo.CreateRunwayDraftImages(ctx, rows); err != nil {
|
if err := s.repo.CreateRunwayDraftImages(ctx, draftImages); err != nil {
|
||||||
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落草稿的
|
||||||
|
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||||
|
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||||
|
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
||||||
|
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool) {
|
||||||
|
cover := ""
|
||||||
|
rows := make([]model.BrandRunwayDraftImage, 0)
|
||||||
|
keys := make([]string, 0)
|
||||||
|
failed := false
|
||||||
|
order := 0
|
||||||
|
for li, look := range looks {
|
||||||
|
lookIdx := li + 1
|
||||||
|
if look.Main != "" {
|
||||||
|
order++
|
||||||
|
url, key, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||||
|
if err != nil {
|
||||||
|
failed = true
|
||||||
|
} else {
|
||||||
|
if cover == "" {
|
||||||
|
cover = url
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
rows = append(rows, model.BrandRunwayDraftImage{
|
||||||
|
Image: url,
|
||||||
|
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||||
|
SortOrder: uint32(order),
|
||||||
|
LookIndex: uint32(lookIdx),
|
||||||
|
IsDetail: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for di, d := range look.Details {
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
order++
|
||||||
|
url, key, err := s.downloadOne(ctx, d, order, prefix)
|
||||||
|
if err != nil {
|
||||||
|
failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cover, rows, keys, failed
|
||||||
|
}
|
||||||
|
|
||||||
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
|
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
|
||||||
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||||
// 1) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同城市同年时各建一条草稿,
|
// 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
|
||||||
// 晋升阶段(SaveStreetSnapFromDraft)按实体键(城市+年份)合并为正式表一条并聚合图片。
|
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
|
||||||
if _, found, err := s.repo.StreetSnapIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found {
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, found, err := s.repo.DraftStreetIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -317,7 +375,6 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
Year: p.Year,
|
Year: p.Year,
|
||||||
City: p.City,
|
City: p.City,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
SourceURL: p.SourceURL,
|
|
||||||
ImageCount: uint16(len(imgs)),
|
ImageCount: uint16(len(imgs)),
|
||||||
Status: model.DraftStatusPending,
|
Status: model.DraftStatusPending,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,6 @@ type DraftCard struct {
|
|||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
ImageCount uint16 `json:"image_count"`
|
ImageCount uint16 `json:"image_count"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SourceURL string `json:"source_url"`
|
|
||||||
CreatedAt uint32 `json:"created_at"`
|
CreatedAt uint32 `json:"created_at"`
|
||||||
UpdatedAt uint32 `json:"updated_at"`
|
UpdatedAt uint32 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@ -59,7 +58,6 @@ type DraftDetailView struct {
|
|||||||
SeasonCode string `json:"season_code"`
|
SeasonCode string `json:"season_code"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
SourceURL string `json:"source_url"`
|
|
||||||
ImageCount uint16 `json:"image_count"`
|
ImageCount uint16 `json:"image_count"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Images []DraftImageRef `json:"images"`
|
Images []DraftImageRef `json:"images"`
|
||||||
@ -102,7 +100,6 @@ type ReviewService interface {
|
|||||||
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
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)
|
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
||||||
CountPending(ctx context.Context) (int64, error)
|
|
||||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。
|
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。
|
||||||
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||||
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||||
@ -219,18 +216,6 @@ func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, revi
|
|||||||
return m.Reject(ctx, id, reviewer, reason)
|
return m.Reject(ctx, id, reviewer, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) CountPending(ctx context.Context) (int64, error) {
|
|
||||||
var total int64
|
|
||||||
for _, k := range s.order {
|
|
||||||
n, err := s.modules[k].CountPending(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
total += n
|
|
||||||
}
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||||
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
||||||
var all int64
|
var all int64
|
||||||
@ -315,8 +300,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
Season: d.Season,
|
Season: d.Season,
|
||||||
CollectionType: d.CollectionType,
|
CollectionType: d.CollectionType,
|
||||||
SeasonCode: d.SeasonCode,
|
SeasonCode: d.SeasonCode,
|
||||||
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
|
Cover: m.img.Compose(d.Cover), // 后台预览看展示图
|
||||||
SourceURL: d.SourceURL,
|
|
||||||
ImageCount: d.ImageCount,
|
ImageCount: d.ImageCount,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
Fields: []EditField{
|
Fields: []EditField{
|
||||||
@ -331,7 +315,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder})
|
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
@ -392,8 +376,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
Title: d.Title,
|
Title: d.Title,
|
||||||
Year: d.Year,
|
Year: d.Year,
|
||||||
City: d.City,
|
City: d.City,
|
||||||
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
|
Cover: m.img.Compose(d.Cover), // 后台预览看展示图
|
||||||
SourceURL: d.SourceURL,
|
|
||||||
ImageCount: d.ImageCount,
|
ImageCount: d.ImageCount,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
Fields: []EditField{
|
Fields: []EditField{
|
||||||
@ -403,7 +386,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder})
|
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
@ -448,10 +431,9 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
|
|||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: d.TitleEn,
|
Title: d.TitleEn,
|
||||||
Subtitle: sub,
|
Subtitle: sub,
|
||||||
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
|
Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图
|
||||||
ImageCount: d.ImageCount,
|
ImageCount: d.ImageCount,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
SourceURL: d.SourceURL,
|
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
})
|
})
|
||||||
@ -471,10 +453,9 @@ func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
|
|||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: d.Title,
|
Title: d.Title,
|
||||||
Subtitle: sub,
|
Subtitle: sub,
|
||||||
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
|
Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图
|
||||||
ImageCount: d.ImageCount,
|
ImageCount: d.ImageCount,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
SourceURL: d.SourceURL,
|
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import (
|
|||||||
// StreetSnapService 街拍业务接口。
|
// StreetSnapService 街拍业务接口。
|
||||||
type StreetSnapService interface {
|
type StreetSnapService interface {
|
||||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||||
Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error)
|
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||||
@ -43,9 +43,9 @@ type streetSnapService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewStreetSnapService 创建街拍服务。
|
// NewStreetSnapService 创建街拍服务。
|
||||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量);
|
||||||
// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删);
|
// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删);
|
||||||
// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
||||||
func NewStreetSnapService(repo repository.StreetSnapRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) StreetSnapService {
|
func NewStreetSnapService(repo repository.StreetSnapRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) StreetSnapService {
|
||||||
return &streetSnapService{repo: repo, media: media, img: img, deleter: deleter, cleaner: cleaner}
|
return &streetSnapService{repo: repo, media: media, img: img, deleter: deleter, cleaner: cleaner}
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -93,8 +93,9 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
// 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立);
|
||||||
imgs := s.toPublicSnapImages(raw, false)
|
// 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||||
|
imgs := s.toThumbSnapImages(raw)
|
||||||
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
||||||
if len(imgs) > limit {
|
if len(imgs) > limit {
|
||||||
imgs = imgs[:limit]
|
imgs = imgs[:limit]
|
||||||
@ -118,9 +119,8 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询街拍详情,含完整图片集。
|
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
|
||||||
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
|
|
||||||
snap, err := s.repo.FindByID(ctx, id)
|
snap, err := s.repo.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
@ -137,8 +137,8 @@ func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dt
|
|||||||
return &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
||||||
Title: snap.Title,
|
Title: snap.Title,
|
||||||
Cover: s.img.Compose(snap.Cover, hd),
|
Cover: s.img.Compose(snap.Cover),
|
||||||
Images: s.toPublicSnapImages(imgs, hd),
|
Images: s.toPublicSnapImages(imgs),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,7 +154,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
|||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图
|
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -163,8 +163,9 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
|||||||
|
|
||||||
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||||
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
||||||
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等
|
||||||
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []dto.PublicArticleImage {
|
// 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。
|
||||||
|
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||||
if len(imgs) == 0 {
|
if len(imgs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -172,7 +173,25 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd
|
|||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
out = append(out, dto.PublicArticleImage{
|
out = append(out, dto.PublicArticleImage{
|
||||||
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||||||
Image: s.img.Compose(im.Image, hd),
|
Image: s.img.Compose(im.Image),
|
||||||
|
Thumb: s.img.ComposeThumb(im.Image),
|
||||||
|
Name: im.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
|
||||||
|
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。
|
||||||
|
func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
out = append(out, dto.PublicArticleImage{
|
||||||
|
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||||||
|
Image: s.img.ComposeThumb(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user