This commit is contained in:
toom1996
2026-09-13 00:52:35 +08:00
parent 10d8a96e8c
commit dbee704c95
44 changed files with 755 additions and 1148 deletions

View File

@ -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 品牌列表

View File

@ -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)

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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
} }
} }

View File

@ -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"` // 采集来源链接(去重 / 溯源用)
} }

View File

@ -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"` // 命中数量,便于爬虫打日志观察命中率
}

View File

@ -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 列表
} }

View File

@ -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

View File

@ -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>

View File

@ -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) {

View File

@ -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 {

View File

@ -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),
}) })
} }

View File

@ -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"
} }

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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"`
} }

View File

@ -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"`

View File

@ -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 指定表名。

View File

@ -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"`

View File

@ -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 账号)

View File

@ -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 {
return stored // 外链图无法套样式,原样返回 if strings.Contains(style, "=") {
} sep := "?"
// 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。 if strings.Contains(style, "?") {
if hd { sep = "&"
return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD) }
} return fmt.Sprintf("%s/%s%s%s", c.BaseURL, key, sep, style)
return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal) }
return fmt.Sprintf("%s/%s!style:%s", c.BaseURL, key, style)
} }

View File

@ -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")
} }
} }

View File

@ -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 签名。

View File

@ -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
} }

View File

@ -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:

View File

@ -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
}

View File

@ -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,说明首次删除未生效")
}
}

View File

@ -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 不存在时按成功处理(幂等)。

View File

@ -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]) + "…"
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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,

View File

@ -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"})

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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 文章不存在,文案与原项目一致。

View File

@ -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),
}) })
} }

View File

@ -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)
} if err != nil {
return nil, 0, err
// ListJobs 列出最近的入库任务(后台监控页用)。 }
func (s *IngestService) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) { return jobs, total, nil
return s.repo.ListJobs(ctx, limit)
} }
// 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,
} }

View File

@ -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,
}) })

View File

@ -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,
}) })
} }