diff --git a/README.md b/README.md index 7e49a94..ec10fd4 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ go build -o bin/migrate ./cmd/migrate ### 5.3 文章详情 `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 品牌列表 diff --git a/cmd/server/main.go b/cmd/server/main.go index e61025e..28c239d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,6 +31,16 @@ import ( "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() { configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml") flag.Parse() @@ -82,16 +92,21 @@ func main() { histRepo = repository.NewHistoryRepository(db) ingestRepo = repository.NewIngestRepository(db) reviewRepo = repository.NewReviewRepository(db) - // 跨表图片引用计数(删除图集时判定七牛孤儿文件用) + // 跨表图片引用计数(删除图集时判定 S4 孤儿文件用) mediaRepo = repository.NewMediaRepository(db) jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours) - // 图片 URL 拼装器:库里只存七牛 key,渲染时按 tier 拼 base_url + 样式。 - 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) + // S4 上传器(凭证齐全才真正启用;否则为零值,图片回落本地兜底)。 + s4Up = storage.NewS4Uploader(cfg.S4.AK, cfg.S4.SK, cfg.S4.Bucket, cfg.S4.Endpoint, cfg.S4.BaseURL, cfg.S4.Region) - // 图片存储:七牛云优先(若启用),失败兜底本地 uploads。 - // localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用七牛时两者均为七牛实现。 + // 图片 URL 拼装器:库里只存 S4 key,渲染时拼 base_url + 展示样式(VIP 与免费同质量)。 + // 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) imgUp storage.Uploader = localUp imgDel storage.Deleter = localUp @@ -108,23 +123,21 @@ func main() { ) // 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表) - // 图片存储:七牛云优先(若启用),失败兜底本地 uploads。 - // imgUp / imgDel 已在上方 var 块声明,这里仅在启用七牛时切换为七牛实现。 - if cfg.Qiniu.Enabled { - // 注意:上传器不传 base_url(末参传空),Upload 只返回七牛 key; - // 完整可访问 URL 由 imgComposer 在读取时按 tier 拼 base_url + 样式, - // 这样既方便换 CDN 域名,又能做普通/高清分级(见 internal/pkg/imgurl)。 - qu := storage.NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "") - imgUp = qu - imgDel = qu - log.Printf("✓ 图片存储: 七牛云 bucket=%s zone=%s(库只存 key,渲染时拼 base_url=%s)", cfg.Qiniu.Bucket, cfg.Qiniu.Zone, cfg.Qiniu.BaseURL) + // 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。 + // imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。 + if cfg.S4.Enabled && s4Up != nil && s4Up.Enabled() { + // Upload 只返回 S4 key(不拼 base_url),完整可访问 URL 由 imgComposer 在读取时按 tier 拼; + // free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。 + imgUp = s4Up + imgDel = s4Up + log.Printf("✓ 图片存储: 缤纷云 S4 bucket=%s base=%s(库只存 key,渲染时拼 base_url)", cfg.S4.Bucket, s4Up.PublicBaseURL()) } else { log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix) } - // 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(七牛/本地删除器)。 - // 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理七牛」异步入队。 + // 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。 + // 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。 ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp) - // 图集服务:注入 ingestSvc 作为「清理七牛孤儿图」的入队器,使下架/删除异步触发存储清理。 + // 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。 articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc) snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc) diff --git a/configs/config.yml b/configs/config.yml index 3e29169..d01e107 100644 --- a/configs/config.yml +++ b/configs/config.yml @@ -79,34 +79,31 @@ client_sign: # 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。 # 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。 -# - enabled: false 时 ingest 路由整体不挂载(请求返回 503,等价于端点不存在)。 # - 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、写正式表。 ingest: - enabled: true # env: INGEST_ENABLED(本地开发启用,接收爬虫上报;生产用环境变量注入随机 INGEST_SECRET) secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致) ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL -# 七牛云对象存储(爬虫入库图片上传目标) -# - enabled: true 时,worker 把下载的走秀/街拍图直传七牛 bucket,库里存完整 https URL -# - zone 必须匹配 bucket 创建区域,否则上传失败:z1=华北(你选择的区域) -# - base_url 为 bucket 绑定的公开访问域名,用于拼出图片 URL(此处用七牛测试域名) -# - AK/SK 亦可用环境变量 QINIU_AK / QINIU_SK 覆盖(生产建议用环境变量注入) -qiniu: +# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标) +# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼) +# - bucket:存储桶名(须为「公开桶」,图片经 CoreIX 公开样式对外提供) +# - endpoint:S3 接入点,默认 https://s3.bitiful.net;region 任意(如 cn-east-1) +# - base_url:对外访问域名;留空自动推导 https://.s3.bitiful.net(务必与 endpoint 同 host) +# - 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 - ak: "7GLHrN7BqrI9JnWZ9Pki2q5rqPazhIFroo19a-Av" - sk: "gmg6PLgg666Cme-gsyTlsBLhshDv-6_zsEmW4jRY" - bucket: "toom-studio" - zone: "z1" # env: QINIU_ZONE - base_url: "http://toom-studio.23cm.cn" # env: QINIU_BASE_URL(自定义域名;HTTPS 证书在七牛控制台配好后改 https 即可,无需改代码) - style_normal: "free" # env: QINIU_STYLE_NORMAL(免费图七牛命名样式名,须与控制台一致;如 free / free.webp) - style_vip: "vip" # env: QINIU_STYLE_VIP(VIP 高清七牛命名样式名,须与控制台一致;如 vip / vip.webp) - local_base: "" # env: QINIU_LOCAL_BASE(本地兜底前缀,通常空) - # 私有空间下载签名过期时间(秒): - # - 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 + ak: "ur2YaIFZvySmjTIgGT1fxhAW" + sk: "JUlfcQ4HNgO5eIzj9u7qaeQomqssvtI" + bucket: "toomstudio" # 你的缤纷云 S4 桶名 + endpoint: "https://s3.bitiful.net" + region: "cn-east-1" + base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net + style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp + style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display diff --git a/go.mod b/go.mod index c4396c5..940fcb1 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,24 @@ require ( require ( 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/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect diff --git a/go.sum b/go.sum index b4cd994..e51d97f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,41 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= diff --git a/internal/config/config.go b/internal/config/config.go index 703d8e9..87a0f66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,8 +24,8 @@ type Config struct { ClientSign ClientSignConfig `yaml:"client_sign"` // Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。 Ingest IngestConfig `yaml:"ingest"` - // Qiniu 七牛云对象存储配置(爬虫入库图片上传目标)。 - Qiniu QiniuConfig `yaml:"qiniu"` + // S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。 + S4 S4Config `yaml:"s4"` // loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。 // 小写不导出,yml 无法覆盖它。 @@ -120,40 +120,37 @@ type CORSConfig struct { // 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。 // 密钥必须走环境变量 INGEST_SECRET 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。 type IngestConfig struct { - // Enabled 开关:false 时 ingest 接口返回 503(尚未配置密钥),便于灰度。 - Enabled bool `yaml:"enabled"` // Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。 Secret string `yaml:"secret"` // TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。 TTLSeconds int `yaml:"ttl_seconds"` } -// QiniuConfig 七牛云对象存储配置(爬虫入库图片上传目标)。 +// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。 // -// worker 下载走秀/街拍图片后,若 Enabled=true 则直传七牛 bucket, +// worker 下载走秀/街拍图片后,若 Enabled=true 则直传 S4 bucket, // 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。 -// - AK/SK 须与七牛控制台一致;生产经环境变量 QINIU_AK / QINIU_SK 注入。 -// - Zone 必须匹配 bucket 创建区域,否则上传失败:z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡。 -// - BaseURL 为 bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL。 -// - StyleNormal 普通图七牛命名样式名(见 imgurl 包):详情页对免费/未登录用户拼 -// base_url/key-StyleNormal;须与七牛控制台创建的样式名完全一致。 -type QiniuConfig struct { - Enabled bool `yaml:"enabled"` - AK string `yaml:"ak"` - SK string `yaml:"sk"` - Bucket string `yaml:"bucket"` - Zone string `yaml:"zone"` - BaseURL string `yaml:"base_url"` - // StyleNormal 普通图七牛命名样式名(如 free 或 free.webp)。 - StyleNormal string `yaml:"style_normal"` - // 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"` +// +// 图片质量模型(见 internal/pkg/imgurl,2026-09-11 调整):VIP 与免费用户同质量, +// 统一走 CoreIX 公开样式 StyleDisplay(如 high,key!style:high 匿名可访问);付费墙已取消。 +// - AK/SK 为缤纷云子账户密钥(须有 PutObject/DeleteObject/GetObject 权限); +// 生产经环境变量 S4_AK / S4_SK 注入。 +// - Endpoint 默认 https://s3.bitiful.net;Region 任意(如 cn-east-1)。 +// - BaseURL 为对外访问域名;留空自动推导 https://.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"` + AK string `yaml:"ak"` + SK string `yaml:"sk"` + Bucket string `yaml:"bucket"` + Endpoint string `yaml:"endpoint"` + Region string `yaml:"region"` + BaseURL string `yaml:"base_url"` + StyleDisplay string `yaml:"style_display"` + StyleThumb string `yaml:"style_thumb"` } // ClientSignConfig 公开接口「前端 JS 签名」配置。 @@ -221,19 +218,16 @@ func defaultConfig() *Config { TTLSeconds: 30, }, Ingest: IngestConfig{ - Enabled: false, Secret: "", TTLSeconds: 300, }, - Qiniu: QiniuConfig{ - Enabled: false, - Zone: "z1", - BaseURL: "", - StyleNormal: "free", - StyleVip: "vip", - LocalBase: "", - SignTTLNormal: 31536000, - SignTTLHD: 3600, + S4: S4Config{ + Enabled: false, + Endpoint: "https://s3.bitiful.net", + Region: "cn-east-1", + BaseURL: "", + StyleDisplay: "high", + StyleThumb: "thumb", }, } } @@ -320,21 +314,20 @@ func (c *Config) applyEnv() { envStr("UPLOAD_DIR", &c.Upload.Dir) envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix) - envBool("INGEST_ENABLED", &c.Ingest.Enabled) envStr("INGEST_SECRET", &c.Ingest.Secret) envInt("INGEST_TTL", &c.Ingest.TTLSeconds) - envBool("QINIU_ENABLED", &c.Qiniu.Enabled) - envStr("QINIU_AK", &c.Qiniu.AK) - envStr("QINIU_SK", &c.Qiniu.SK) - envStr("QINIU_BUCKET", &c.Qiniu.Bucket) - envStr("QINIU_ZONE", &c.Qiniu.Zone) - envStr("QINIU_BASE_URL", &c.Qiniu.BaseURL) - envStr("QINIU_STYLE_NORMAL", &c.Qiniu.StyleNormal) - envStr("QINIU_STYLE_VIP", &c.Qiniu.StyleVip) - envStr("QINIU_LOCAL_BASE", &c.Qiniu.LocalBase) - envInt64("QINIU_SIGN_TTL_NORMAL", &c.Qiniu.SignTTLNormal) - envInt64("QINIU_SIGN_TTL_HD", &c.Qiniu.SignTTLHD) + // 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换 + // (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。 + envBoolFirst(&c.S4.Enabled, "S4_ENABLED", "QINIU_ENABLED") + envStrFirst(&c.S4.AK, "S4_AK", "QINIU_AK") + envStrFirst(&c.S4.SK, "S4_SK", "QINIU_SK") + envStrFirst(&c.S4.Bucket, "S4_BUCKET", "QINIU_BUCKET") + envStr("S4_ENDPOINT", &c.S4.Endpoint) + envStr("S4_REGION", &c.S4.Region) + envStrFirst(&c.S4.BaseURL, "S4_BASE_URL", "QINIU_BASE_URL") + envStrFirst(&c.S4.StyleDisplay, "S4_STYLE_DISPLAY", "QINIU_STYLE_NORMAL") + envStr("S4_STYLE_THUMB", &c.S4.StyleThumb) } // normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。 @@ -391,17 +384,29 @@ func envStr(key string, dst *string) { } } -func envInt(key string, dst *int) { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - if n, err := strconv.Atoi(v); err == nil { - *dst = n +// envStrFirst 按顺序取第一个非空的环境变量写入 dst(前者优先),用于平滑切换旧变量名。 +func envStrFirst(dst *string, keys ...string) { + for _, k := range keys { + 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 n, err := strconv.ParseInt(v, 10, 64); err == nil { + if n, err := strconv.Atoi(v); err == nil { *dst = n } } diff --git a/internal/dto/article.go b/internal/dto/article.go index 772683e..103f1c6 100644 --- a/internal/dto/article.go +++ b/internal/dto/article.go @@ -66,10 +66,26 @@ func (q ArticleQuery) Offset() int { return (q.Page - 1) * q.Size } // // ID 是图片自身的编码 id(hashid,i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张; // 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。 +// +// Image 与 Thumb 的关系: +// - Image 是「该接口场景下的主用地址」——详情接口给展示样式(high),列表接口直接给缩略图(thumb, +// 列表只需小图,不给大图以免浪费带宽)。 +// - Thumb 恒为缩略图地址(thumb 样式),**仅详情接口填充**:详情返回的是展示样式大图, +// 但大图灯箱左栏缩略图条 / 右下角细节缩略图只要几十到一百多像素,用大图纯属浪费, +// 故额外给出 Thumb 供这些「小尺寸场景」使用。列表接口不填(其 Image 本身就是缩略图)。 type PublicArticleImage struct { - ID string `json:"id"` - Image string `json:"image"` - Name string `json:"name"` + ID string `json:"id"` + Image string `json:"image"` + Thumb string `json:"thumb,omitempty"` + 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 对外展示用的精简文章结构(走秀列表)。 @@ -90,7 +106,8 @@ type PublicArticle struct { // PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。 // -// 与列表一致,只返回前端渲染所需的字段;image_count / source_url / 各类时间季节元数据前端均不展示,已移除。 +// 与列表一致,只返回前端渲染所需的字段;后台管理用的额外元数据(季节 / 年份 / 系列等) +// 见 AdminRunway,由后台列表接口单独返回。 type PublicArticleDetail struct { UID string `json:"id"` // 编码后的文章 id(无序串) BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串) @@ -98,8 +115,8 @@ type PublicArticleDetail struct { Description string `json:"description"` Cover string `json:"cover"` BrandName string `json:"brand_name"` - SourceURL string `json:"source_url"` // 采集来源链接(后台溯源用,对外无害) - Images []PublicArticleImage `json:"images"` + Images []PublicArticleImage `json:"images"` // 仅主图(look 图),默认展示 + Details []RunwayLookDetails `json:"details"` // 按主图分组的细节图(「细节入口」展开用);无细节时为空 } // AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段, @@ -114,5 +131,4 @@ type AdminRunway struct { CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25 ImageCount uint16 `json:"image_count"` - SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源用) } diff --git a/internal/dto/crawl.go b/internal/dto/crawl.go index 479b496..aab095c 100644 --- a/internal/dto/crawl.go +++ b/internal/dto/crawl.go @@ -7,17 +7,3 @@ type CrawlBrand struct { BrandUID string `json:"brand_uid"` 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"` // 命中数量,便于爬虫打日志观察命中率 -} diff --git a/internal/dto/ingest.go b/internal/dto/ingest.go index 90bbbcf..74cdd8d 100644 --- a/internal/dto/ingest.go +++ b/internal/dto/ingest.go @@ -8,13 +8,12 @@ const ( // RunwayIngest 爬虫上报的单场载荷(ingest_jobs.payload 的 JSON 结构)。 // -// 设计:爬虫只传「元数据 + 图片 source_url 列表」,不传图、不直写业务表; +// 设计:爬虫只传「元数据 + 图片 URL 列表」,不传图、不直写业务表; // 下载与存储上传由后台 worker 完成。 // // 通用字段: -// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway) -// - SourceURL: 采集来源链接(去重 / 溯源,必填) -// - Images: 图片原始 URL 列表(worker 下载) +// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway) +// - Images: 图片原始 URL 列表(worker 下载) // // runway 专属:BrandUID(品牌 hashid 编码,必填)、TitleEn/TitleCn、Description*、Year、Season、CollectionType。 // street 专属:City(地区/城市)、TitleEn(单标题)。 @@ -29,6 +28,15 @@ type RunwayIngest struct { Season string `json:"season"` // spring / fall CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall City string `json:"city"` // 地区/城市(street 用) - SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源,必填) - Images []string `json:"images"` // 图片原始 URL 列表(worker 下载) + Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用) + // Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。 + // 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。 + Looks []RunwayLook `json:"looks,omitempty"` +} + +// RunwayLook 一场秀中的一个 look:1 张主图 + 0..N 张细节图。 +// 主图即对外默认展示的走秀图;细节图挂靠到对应主图,由「细节入口」按需展开。 +type RunwayLook struct { + Main string `json:"main"` // 主图(look)原始 URL + Details []string `json:"details"` // 细节图原始 URL 列表 } diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go index 8d31d01..cea03b7 100644 --- a/internal/handler/article_handler.go +++ b/internal/handler/article_handler.go @@ -94,7 +94,7 @@ func (h *ArticleHandler) Detail(c *gin.Context) { 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 { fail(c, err) return diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index bc1b504..530d08b 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -2,6 +2,7 @@ package handler import ( "bytes" + "fmt" "html/template" "net/http" "strconv" @@ -10,6 +11,7 @@ import ( "fashionapi/internal/dto" "fashionapi/internal/middleware" "fashionapi/internal/pkg/hashid" + "fashionapi/internal/pkg/pagination" "fashionapi/internal/service" "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} } +// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。 +// 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文, +// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。 +var pagerTmpl = template.Must(template.New("base").Parse(`{{define "pager"}}
+{{if .HasPrev}}上一页{{else}}上一页{{end}} +{{if .Label}}{{.Label}}{{end}} +{{if .HasNext}}下一页{{else}}下一页{{end}} +
{{end}}`)) + // renderHTML 用单文件模板字符串渲染 HTML(不依赖外部模板文件,保持自包含)。 +// 在解析页面正文前先 Clone 含 {{define "pager"}} 的基类模板,使各列表页可复用翻页片段。 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 { c.String(http.StatusInternalServerError, "template error: "+err.Error()) return @@ -136,20 +153,23 @@ func (h *BackstageHandler) Brands(c *gin.Context) { return } const size = 30 - page := q.Page - if page < 1 { - page = 1 + p := pagination.Params{Page: q.Page, PageSize: size} + if p.Page < 1 { + p.Page = 1 } + meta := p.NewMeta(total) renderHTML(c, http.StatusOK, tplBrands, gin.H{ - "Items": items, - "Total": total, - "Page": page, - "PrevPage": page - 1, - "NextPage": page + 1, - "HasPrev": page > 1, - "HasNext": int64(page*size) < total, - "Keyword": q.Keyword, - "Letter": q.Letter, + "Items": items, + "Total": total, + "Keyword": q.Keyword, + "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()}) return } - page := q.Page - if page < 1 { - page = 1 + const size = 24 + p := pagination.Params{Page: q.Page, PageSize: size} + if p.Page < 1 { + p.Page = 1 } + meta := p.NewMeta(total) renderHTML(c, http.StatusOK, tplRunways, gin.H{ "Items": items, "Total": total, - "Page": page, - "PrevPage": page - 1, - "NextPage": page + 1, - "HasPrev": page > 1, - "HasNext": int64(page*24) < total, "Collection": q.Collection, "Season": q.Season, "Year": q.Year, "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}) 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 { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return @@ -264,21 +288,25 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) { renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) return } - page := q.Page - if page < 1 { - page = 1 + const size = 24 + p := pagination.Params{Page: q.Page, PageSize: size} + if p.Page < 1 { + p.Page = 1 } + meta := p.NewMeta(total) renderHTML(c, http.StatusOK, tplStreetSnaps, gin.H{ - "Items": items, - "Total": total, - "Page": page, - "PrevPage": page - 1, - "NextPage": page + 1, - "HasPrev": page > 1, - "HasNext": int64(page*24) < total, - "Year": q.Year, - "City": q.City, - "Sort": q.Sort, + "Items": items, + "Total": total, + "Year": q.Year, + "City": q.City, + "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}) 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 { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return @@ -334,18 +362,22 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) { if kind != "" && kind != "all" { listBase = "/" + kind } + p := pagination.Params{Page: page, PageSize: 20} + meta := p.NewMeta(total) renderHTML(c, http.StatusOK, tplReviewList, gin.H{ "Drafts": drafts, "Total": total, - "Page": page, "Status": status, "Kind": kind, "Kinds": kinds, "ListBase": listBase, - "HasPrev": page > 1, - "HasNext": int64(page*20) < total, - "PrevPage": page - 1, - "NextPage": page + 1, + "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 页", meta.Page), + }, }) } @@ -449,14 +481,16 @@ type ingestJobView struct { ID uint32 Status string Attempts uint8 - SourceURL string LastError string UpdatedAt string } -// IngestJobs 列出最近的入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。 +// IngestJobs 分页列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。 +// 支持 ?page(从 1 起)与 ?pageSize(默认 50,上限 200);其余查询串原样忽略。 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 { renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) return @@ -471,12 +505,27 @@ func (h *BackstageHandler) IngestJobs(c *gin.Context) { ID: j.ID, Status: j.Status, Attempts: j.Attempts, - SourceURL: j.SourceURL, LastError: j.LastError, 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 会在下一个周期重新处理。 @@ -945,11 +994,7 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px} {{range .Items}}{{.UID}}{{.NameEn}}{{.NameCn}}编辑{{end}} -
-{{if .HasPrev}}上一页{{else}}上一页{{end}} -第 {{.Page}} 页 · 共 {{.Total}} 条 -{{if .HasNext}}下一页{{else}}下一页{{end}} -
+{{template "pager" .Pager}} ` const tplRunways = ` @@ -1008,11 +1053,7 @@ a.view{color:#111;text-decoration:underline} {{range .Items}}{{.UID}}{{.BrandName}}{{.Title}}{{.CollectionType}}{{.Season}}{{.Year}}{{.SeasonCode}}{{.ImageCount}}查看{{end}} -
-{{if .HasPrev}}上一页{{else}}上一页{{end}} -第 {{.Page}} 页 · 共 {{.Total}} 条 -{{if .HasNext}}下一页{{else}}下一页{{end}} -
+{{template "pager" .Pager}} ` const tplRunwayDetail = ` @@ -1037,7 +1078,6 @@ h1{font-size:22px;margin:8px 0 2px}

编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}

{{.Article.Title}}

-

来源:{{if .Article.SourceURL}}{{.Article.SourceURL}}{{else}}—{{end}}

{{range .Article.Images}}
{{.Name}}
{{end}}
@@ -1087,11 +1127,7 @@ a.view{color:#111;text-decoration:underline} {{range .Items}}{{.UID}}{{.Title}}{{.ImageCount}}查看{{end}} -
-{{if .HasPrev}}上一页{{else}}上一页{{end}} -第 {{.Page}} 页 · 共 {{.Total}} 条 -{{if .HasNext}}下一页{{else}}下一页{{end}} -
+{{template "pager" .Pager}}
` const tplStreetSnapDetail = ` @@ -1204,11 +1240,7 @@ a.view{color:#111;text-decoration:underline} {{end}} -
-{{if .HasPrev}}上一页{{else}}上一页{{end}} -第 {{.Page}} 页 -{{if .HasNext}}下一页{{else}}下一页{{end}} -
+{{template "pager" .Pager}} ` const tplReviewDetail = ` @@ -1238,7 +1270,7 @@ button.reject{background:#c0392b}
管理后台 · 草稿审核 #{{.Draft.ID}}返回列表 · 退出
-

类型 {{.Draft.Kind}}{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 {{.Draft.Status}} · 来源:{{if .Draft.SourceURL}}{{.Draft.SourceURL}}{{else}}—{{end}}

+

类型 {{.Draft.Kind}}{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 {{.Draft.Status}}

{{range .Draft.Fields}}
{{if eq .Type "textarea"}}{{else}}{{end}}
{{end}} @@ -1386,26 +1418,29 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px} 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: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}
管理后台 · 爬虫入库任务返回首页 · 退出
-

爬虫入库任务(最近 {{len .Jobs}} 条)

-

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。

+

爬虫入库任务

+

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。

- + {{range .Jobs}} -{{end}}
ID状态重试来源 URL错误信息更新时间操作
ID状态重试错误信息更新时间操作
{{.ID}} {{.Status}} {{.Attempts}}{{.SourceURL}} {{.LastError}} {{.UpdatedAt}} {{if eq .Status "failed"}}{{else}}—{{end}}
+{{template "pager" .Pager}}
` const tplError = `错误 diff --git a/internal/handler/favorite_handler.go b/internal/handler/favorite_handler.go index 2e7f1f2..b5bbbe6 100644 --- a/internal/handler/favorite_handler.go +++ b/internal/handler/favorite_handler.go @@ -2,17 +2,17 @@ package handler import ( "net/http" - "strconv" "fashionapi/internal/dto" "fashionapi/internal/middleware" + "fashionapi/internal/pkg/pagination" "fashionapi/internal/pkg/response" "fashionapi/internal/service" "github.com/gin-gonic/gin" ) -// FavoriteHandler 收藏接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。 +// FavoriteHandler 收藏接口(需 Bearer 鉴权,主挂载于 /api/v1/me,legacy /api/v1/auth 仅兼容旧前端)。 type FavoriteHandler struct { favs service.FavoriteService } @@ -33,20 +33,13 @@ func (h *FavoriteHandler) List(c *gin.Context) { response.Error(c, http.StatusUnauthorized, "unauthorized") return } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24")) - if page < 1 { - page = 1 - } - if perPage < 1 || perPage > 100 { - perPage = 24 - } + p := pagination.Parse(c, "page", "per_page", 1, 24, 100) kind := c.Query("kind") if kind != "" && kind != dto.FavoriteKindGallery && kind != dto.FavoriteKindImage { response.Error(c, http.StatusBadRequest, "invalid kind") 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 { fail(c, err) return @@ -54,7 +47,7 @@ func (h *FavoriteHandler) List(c *gin.Context) { response.Data(c, http.StatusOK, gin.H{ "items": items, "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 中哪些已收藏(列表页打勾用,与收藏总量解耦)。 // -// POST /api/v1/me/favorites/check +// POST /api/v1/me/favorites/checks // body: { ids: ["r=...","s=..."] } // 成功:200 { data: ["r=..."] }(仅返回已收藏的 id) func (h *FavoriteHandler) Check(c *gin.Context) { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 13d00b7..78b7766 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -58,34 +58,6 @@ func queryBool(c *gin.Context, key string, def bool) bool { 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。 // 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。 func parseLocale(c *gin.Context) string { diff --git a/internal/handler/history_handler.go b/internal/handler/history_handler.go index 4653b7c..7b8cd7e 100644 --- a/internal/handler/history_handler.go +++ b/internal/handler/history_handler.go @@ -2,17 +2,17 @@ package handler import ( "net/http" - "strconv" "fashionapi/internal/dto" "fashionapi/internal/middleware" + "fashionapi/internal/pkg/pagination" "fashionapi/internal/pkg/response" "fashionapi/internal/service" "github.com/gin-gonic/gin" ) -// HistoryHandler 浏览历史接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。 +// HistoryHandler 浏览历史接口(需 Bearer 鉴权,主挂载于 /api/v1/me,legacy /api/v1/auth 仅兼容旧前端)。 type HistoryHandler struct { hist service.HistoryService } @@ -32,15 +32,8 @@ func (h *HistoryHandler) List(c *gin.Context) { response.Error(c, http.StatusUnauthorized, "unauthorized") return } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24")) - if page < 1 { - page = 1 - } - if perPage < 1 || perPage > 100 { - perPage = 24 - } - items, total, err := h.hist.ListPaged(c.Request.Context(), userID, page, perPage) + p := pagination.Parse(c, "page", "per_page", 1, 24, 100) + items, total, err := h.hist.ListPaged(c.Request.Context(), userID, p.Page, p.PageSize) if err != nil { fail(c, err) return @@ -48,7 +41,7 @@ func (h *HistoryHandler) List(c *gin.Context) { response.Data(c, http.StatusOK, gin.H{ "items": items, "total": total, - "last_page": response.LastPage(total, perPage), + "last_page": response.LastPage(total, p.PageSize), }) } diff --git a/internal/handler/ingest_handler.go b/internal/handler/ingest_handler.go index 400de65..88c5df0 100644 --- a/internal/handler/ingest_handler.go +++ b/internal/handler/ingest_handler.go @@ -3,7 +3,6 @@ package handler import ( "errors" "net/http" - "sort" "strconv" "fashionapi/internal/dto" @@ -64,39 +63,11 @@ func (h *IngestHandler) CrawlBrands(c *gin.Context) { 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 { // service 层 NewError 带 http 状态码;这里简单按消息前缀判断,避免暴露内部类型。 if err == nil { return false } msg := err.Error() - return len(msg) > 0 && (msg == "source_url required" || msg == "brand_uid required") + return len(msg) > 0 && msg == "brand_uid required" } diff --git a/internal/handler/street_snap_handler.go b/internal/handler/street_snap_handler.go index f30cc06..fd4b8c2 100644 --- a/internal/handler/street_snap_handler.go +++ b/internal/handler/street_snap_handler.go @@ -6,7 +6,6 @@ import ( "strings" "fashionapi/internal/dto" - "fashionapi/internal/middleware" "fashionapi/internal/pkg/hashid" "fashionapi/internal/pkg/response" "fashionapi/internal/service" @@ -31,7 +30,7 @@ func NewStreetSnapHandler(svc service.StreetSnapService) *StreetSnapHandler { // 查询参数: // - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条) // - year 年份(整数),0 表示不过滤 -// - city 地区/城市(精确匹配 city 列),'' 表示不过滤 +// - city 地区/城市(精确匹配 city 列),” 表示不过滤 // - sort image_count(默认,按图片数降序)/ year_desc / year_asc // - locale cn / en // @@ -79,7 +78,7 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) { 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 { fail(c, err) return diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index fdd5121..a7a341b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -20,15 +20,6 @@ const ( ContextTier = "tier" ) -// TierFree / TierVIP 用户等级枚举值。 -const ( - TierFree = "free" - TierVIP = "vip" -) - -// IsVIP 判断等级是否为 VIP。 -func IsVIP(tier string) bool { return tier == TierVIP } - // bearerPrefix Authorization 头的令牌前缀。 const bearerPrefix = "Bearer " @@ -105,13 +96,3 @@ func UserIDFrom(c *gin.Context) (uint32, bool) { } 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 -} diff --git a/internal/model/ingest_job.go b/internal/model/ingest_job.go index 4dbb2fc..190e7e2 100644 --- a/internal/model/ingest_job.go +++ b/internal/model/ingest_job.go @@ -12,8 +12,7 @@ type IngestJob struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` - LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收 - SourceURL string `gorm:"column:source_url" json:"source_url"` + LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收 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"` Payload string `gorm:"column:payload;type:text" json:"payload"` // 原始 JSON(RunwayIngest 或 MediaCleanupPayload) diff --git a/internal/model/runway.go b/internal/model/runway.go index 3217c24..0f095e1 100644 --- a/internal/model/runway.go +++ b/internal/model/runway.go @@ -17,7 +17,6 @@ type BrandRunway struct { BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` Year uint16 `gorm:"column:year" json:"year"` Cover string `gorm:"column:cover" json:"cover"` - SourceURL string `gorm:"column:source_url" json:"source_url"` // 分类字段:由 title 解析回填(历史数据迁移一次性写入,现已由外部 SQL 迁移维护) 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"` SeasonCode string `gorm:"column:season_code"` CreatedAt uint32 `gorm:"column:created_at"` - SourceURL string `gorm:"column:source_url"` BrandNameEn string `gorm:"column:brand_name_en"` BrandNameCn string `gorm:"column:brand_name_cn"` } diff --git a/internal/model/runway_draft.go b/internal/model/runway_draft.go index 3fbaaf9..aa5ef17 100644 --- a/internal/model/runway_draft.go +++ b/internal/model/runway_draft.go @@ -18,7 +18,6 @@ type BrandRunwayDraft struct { 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 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"` Status string `gorm:"column:status" json:"status"` // pending | approved | rejected Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号) @@ -38,6 +37,8 @@ type BrandRunwayDraftImage struct { Image string `gorm:"column:image" json:"image"` Name string `gorm:"column:name" json:"name"` 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"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` diff --git a/internal/model/runway_image.go b/internal/model/runway_image.go index fe35afa..430d4f1 100644 --- a/internal/model/runway_image.go +++ b/internal/model/runway_image.go @@ -11,6 +11,8 @@ type BrandRunwayImage struct { BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` Name string `gorm:"column:name" json:"name"` 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 指定表名。 diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go index 42bad2a..35e7217 100644 --- a/internal/model/street_snap.go +++ b/internal/model/street_snap.go @@ -10,7 +10,6 @@ type StreetSnap struct { Year uint16 `gorm:"column:year" json:"year"` City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类 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"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` diff --git a/internal/model/street_snap_draft.go b/internal/model/street_snap_draft.go index d90670d..442040e 100644 --- a/internal/model/street_snap_draft.go +++ b/internal/model/street_snap_draft.go @@ -14,7 +14,6 @@ type StreetSnapDraft struct { Year uint16 `gorm:"column:year" json:"year"` City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类 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"` Status string `gorm:"column:status" json:"status"` // pending | approved | rejected Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号) diff --git a/internal/pkg/imgurl/imgurl.go b/internal/pkg/imgurl/imgurl.go index 3c971ee..ce9d879 100644 --- a/internal/pkg/imgurl/imgurl.go +++ b/internal/pkg/imgurl/imgurl.go @@ -1,109 +1,71 @@ // Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。 // -// 存储约定(见迁移 007):库里只存「七牛 key」(如 runway/abc.jpg)或「本地相对路径」 +// 存储约定(见迁移 007):库里只存「对象 key」(如 runway/abc.jpg)或「本地相对路径」 // (如 /uploads/runway/x.jpg),绝不存完整 URL。这样做两个好处: // 1. 换 CDN 域名只改配置,无需迁移全表; // 2. 做「普通 / 高清」分级时无需从 URL 反向解析 key。 // -// 分级与安全(付费墙): -// - 高清(hd=true,仅 VIP):私有空间下载签名 URL(base_url/key-StyleVip,如 key-vip),限时有效; -// 免费/未登录用户拿不到 VIP 样式 key,即使从别处得到 key,因无签名也会被七牛拒绝(403)。 -// - 普通(hd=false,所有人):base_url/key-StyleNormal(如 key-free)的私有空间签名 URL,长过期(供 SSG 静态页与 CDN 缓存)。 +// 质量模型(2026-09-11 调整):VIP 与免费用户现在看到同一质量,高清样式名为 `high`, +// 由缤纷云 CoreIX 公开样式提供(base_url/key!style:high),匿名可访问、免 token。 +// 旧「免费低清 / VIP 原图预签名」付费墙已取消,故 Composer 不再区分 tier。 +// - 列表缩略图:另走 `thumb` 公开样式(ComposeThumb),仅用于列表/卡片等小尺寸场景,省流量。 // - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)。 -// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套七牛样式,签名无意义)。 -// -// 七牛私有空间下载签名:对 "base_url/key?e=" 做 HMAC-SHA1(SK),urlsafe base64 后拼 -// "AK:sign" 作为 token。签名覆盖完整 path(含 -StyleNormal / -StyleVip 后缀),七牛校验通过才返回资源。 -// 前置:bucket 须在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名。 +// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套样式)。 package imgurl import ( - "crypto/hmac" - "crypto/sha1" - "encoding/base64" "fmt" "strings" - "time" ) // Composer 图片 URL 拼装器。 type Composer struct { - // BaseURL 七牛 bucket 绑定域名,如 https://cdn.toom-studio.com(末尾无斜杠)。 + // BaseURL 对外访问域名,如 https://toomstudio.s3.bitiful.net(末尾无斜杠)。 BaseURL string - // StyleNormal 普通图七牛命名样式名(对应七牛控制台创建的样式),如 "free"。 - // 访问即 base_url/key-StyleNormal。务必与控制台样式名一致(如 free 或 free.webp)。 - StyleNormal string - // StyleVip VIP 高清七牛命名样式名(对应七牛控制台创建的样式),如 "vip"。 - // 访问即 base_url/key-StyleVip。VIP 用户走此样式;务必与控制台样式名一致。 - StyleVip 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 + // StyleDisplay 全量展示图 CoreIX 样式名(如 "high",对应控制台建的公开样式); + // 若值含 "=" 则视为查询串(如 "w=1080&q=80&fmt=webp"),拼成 base_url/key?查询串。 + StyleDisplay string + // StyleThumb 列表缩略图 CoreIX 样式名(如 "thumb");仅用于列表/卡片等小尺寸场景。 + // 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致)。 + StyleThumb string } -// New 构造 Composer。styleNormal 缺省回落 "free";styleVip 缺省回落 "vip";两个 TTL 缺省回落 31536000 / 3600。 -func New(baseURL, styleNormal, styleVip, localBase, ak, sk string, ttlNormal, ttlHD int64) *Composer { - if styleNormal == "" { - styleNormal = "free" +// New 构造 Composer。 +// - styleDisplay 缺省回落 "high"(高清公开样式,VIP 与免费用户同质量); +// - styleThumb 缺省回落 styleDisplay(未单独配置缩略图样式时,列表与详情同图)。 +func New(baseURL, styleDisplay, styleThumb string) *Composer { + if styleDisplay == "" { + styleDisplay = "high" } - if styleVip == "" { - styleVip = "vip" - } - if ttlNormal <= 0 { - ttlNormal = 31536000 - } - if ttlHD <= 0 { - ttlHD = 3600 + if styleThumb == "" { + styleThumb = styleDisplay } return &Composer{ - BaseURL: strings.TrimRight(baseURL, "/"), - StyleNormal: styleNormal, - StyleVip: styleVip, - LocalBase: strings.TrimRight(localBase, "/"), - AK: ak, - SK: sk, - SignTTLNormal: ttlNormal, - SignTTLHD: ttlHD, + BaseURL: strings.TrimRight(baseURL, "/"), + StyleDisplay: styleDisplay, + StyleThumb: styleThumb, } } -// sign 生成七牛私有空间下载签名 URL。 -// -// rawKey 是待访问的对象路径(可能含 -StyleNormal 后缀)。签名字符串为 -// "base_url/rawKey?e=",对其做 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。 +// Compose 把存储值拼成可访问 URL(详情/大图场景,统一走 StyleDisplay)。 // // - stored 为空:返回 "" // - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host) -// - 已是完整 http(s) URL(历史遗留 / 外链图,如 vogue):hd 原样返回; -// normal 尝试从 base_url 还原 key 再套样式签名,还原失败原样返回(外链无法套样式) -// - 七牛 key:hd → 原图签名 URL(短过期);normal → 普通图(命名样式)签名 URL(长过期) -// -// 这样免费/未登录用户永远拿不到「不带签名的原图 URL」;VIP 拿到的是限时签名原图 URL。 -func (c *Composer) Compose(stored string, hd bool) string { +// - 已是完整 http(s) URL(历史遗留 / 外链图):原样返回(无法套样式) +// - key:base_url/key!style:high(或查询串模式) +func (c *Composer) Compose(stored string) string { + return c.compose(stored, c.StyleDisplay) +} + +// 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) if stored == "" { return "" @@ -112,22 +74,22 @@ func (c *Composer) Compose(stored string, hd bool) string { if strings.HasPrefix(stored, "/uploads/") { return stored } - // 已是完整 URL(历史遗留 / 外链图)。 + // 已是完整 URL(历史遗留 / 外链图):原样返回(无法套样式)。 if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") { - if hd { - return stored - } - // 同域七牛 URL:剥掉 base_url 还原 key,再套普通样式签名。 - if c.BaseURL != "" { - if key, ok := strings.CutPrefix(stored, c.BaseURL+"/"); ok { - return c.sign(key+"-"+c.StyleNormal, c.SignTTLNormal) - } - } - return stored // 外链图无法套样式,原样返回 + return stored } - // 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。 - if hd { - return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD) - } - return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal) + // 对象 key:按指定样式拼装(命名样式 key!style:xxx 或查询串 key?w=..&q=..)。 + return c.styledURL(stored, style) +} + +// styledURL 按样式名生成地址:命名样式(key!style:high)或查询串(key?w=..&q=..)两种模式。 +func (c *Composer) styledURL(key, style string) string { + if strings.Contains(style, "=") { + sep := "?" + if strings.Contains(style, "?") { + sep = "&" + } + return fmt.Sprintf("%s/%s%s%s", c.BaseURL, key, sep, style) + } + return fmt.Sprintf("%s/%s!style:%s", c.BaseURL, key, style) } diff --git a/internal/pkg/imgurl/vip_check_test.go b/internal/pkg/imgurl/vip_check_test.go index 8338e53..4d29360 100644 --- a/internal/pkg/imgurl/vip_check_test.go +++ b/internal/pkg/imgurl/vip_check_test.go @@ -5,29 +5,78 @@ import ( "testing" ) -// TestVipStyle 验证:免费(free)用 -free 命名样式、VIP 用 -vip 命名样式,且均带私有空间签名。 -func TestVipStyle(t *testing.T) { - c := New("http://toom-studio.23cm.cn", "free", "vip", "", "AK_TEST", "SK_TEST", 31536000, 3600) +// TestDisplayStyle 验证新模型(2026-09-11):VIP 与免费用户同质量,统一走展示样式。 +// - 命名样式(默认 high):base_url/key!style:high +// - 查询串模式(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) - vip := c.Compose("runway/abc123.jpg", true) + // 缺省回落 high + 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") { - t.Fatalf("免费图应拼 -free 命名样式,实际: %s", free) + if c.Compose("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" { + t.Fatal("http(s) 应原样返回") } - 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") + 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) + } } } diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go index 4ee710e..8244565 100644 --- a/internal/pkg/jwt/jwt.go +++ b/internal/pkg/jwt/jwt.go @@ -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 签发令牌(登录成功后调用)。 // // 构造与 Parse 对称的 Claims(uid / username / email / tier / exp),用 HS256 签名。 diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go index 51d5c32..8db969f 100644 --- a/internal/pkg/response/response.go +++ b/internal/pkg/response/response.go @@ -1,14 +1,21 @@ // Package response 统一 HTTP 响应结构。 // -// 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名: -// - 列表:{ data, last_page } // last_page 供前端真分页(页码导航)使用;total/per_page/current_page 前端无消费点,已移除以精简 -// - 详情:{ data } +// 响应形状严格沿用原项目约定,前端依赖这些字段名: +// - 公开列表(SSG):Page 返回 { data, last_page },last_page 供前端页码导航; +// 运行期不回传 total / current_page / per_page(这些由 SSG 在构建期消费),故此处精简。 +// - 「我的资源」列表(history/favorite):经 Data 返回 { items, total, last_page }, +// total 供前端显示总数(如「共 N 条」)。 +// - 详情:{ data }(可选 preview / image_total 门禁标记,见 DataPreview) // - 错误:{ error } // // 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。 package response -import "github.com/gin-gonic/gin" +import ( + "github.com/gin-gonic/gin" + + "fashionapi/internal/pkg/pagination" +) // Data 返回单个对象:{ "data": v }。 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) } -// Page 返回分页列表:{ data, last_page }。 +// Page 返回分页列表:{ data, last_page },用于 SSG 公开列表(前端仅消费 data 与 last_page)。 // -// 只保留前端真正消费的字段:data(本页数据)与 last_page(总页数,供页码导航)。 -// total / current_page / per_page 前端从无读取,移除以精简协议;last_page 仍由后端用 total 与固定 size 计算。 +// 运行期不回传 total / current_page / per_page——这些由 SSG 在构建期消费,调用方无需关心; +// last_page 由后端用 total 与固定 size 计算。 +// 注意:history/favorite 这类「我的资源」列表需返回 total,请用 response.Data 自行构造。 func Page(c *gin.Context, status int, data any, total int64, size int) { c.JSON(status, gin.H{ "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 }。 func Error(c *gin.Context, status int, msg string) { c.JSON(status, gin.H{"error": msg}) @@ -67,16 +60,7 @@ func AbortError(c *gin.Context, status int, msg string) { } // LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。 +// 实现委托给 pagination.LastPage,避免分页数学在多处重复。 func LastPage(total int64, size int) int { - if size <= 0 { - return 1 - } - last := int(total) / size - if int(total)%size > 0 { - last++ - } - if last == 0 { - last = 1 - } - return last + return pagination.LastPage(total, size) } diff --git a/internal/pkg/season/season.go b/internal/pkg/season/season.go index ff4bbbc..f317b4e 100644 --- a/internal/pkg/season/season.go +++ b/internal/pkg/season/season.go @@ -1,8 +1,9 @@ // Package season 走秀季节 / 年份 → season_code 的推导与解析辅助。 // // season_code 是对外与排序共用的紧凑编码:SS26 / FW25 / RES26 / PF25, -// 规则:前缀由 collection_type(rtw/menswear→SS/FW;resort→RES;pre_fall→PF) -// 或 season(spring→SS / fall→FW)决定,后缀为两位年份。 +// 规则:前缀由 collection_type 决定(resort→RES;pre_fall→PF;couture→FW; +// menswear→FW);rtw 无独立前缀,落入 season 分支(spring→SS / fall→FW), +// 后缀为两位年份。 // 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。 package season @@ -46,8 +47,6 @@ func prefixOf(collectionType, season string) string { return "FW" case ColMenswear: return "FW" - case ColRTW: - return "SS" } switch season { case SeasonFall: diff --git a/internal/pkg/storage/qiniu.go b/internal/pkg/storage/qiniu.go deleted file mode 100644 index a78a7a4..0000000 --- a/internal/pkg/storage/qiniu.go +++ /dev/null @@ -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 // 注意前缀是 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 -} diff --git a/internal/pkg/storage/qiniu_purge_verify_test.go b/internal/pkg/storage/qiniu_purge_verify_test.go deleted file mode 100644 index ebef93f..0000000 --- a/internal/pkg/storage/qiniu_purge_verify_test.go +++ /dev/null @@ -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,说明首次删除未生效") - } -} diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index 63f2dcd..60cfd44 100644 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -2,9 +2,9 @@ // // 两种实现: // - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。 -// - QiniuUploader:直传七牛云对象存储(标准库实现,无第三方 SDK 依赖)。 +// - S4Uploader:直传缤纷云 S4 对象存储(兼容 S3,基于 aws-sdk-go-v2)。 // -// 入库 worker 主用 Qiniu(若启用),单张失败时兜底写本地,避免图片完全丢失。 +// 入库 worker 主用 S4(若启用),单张失败时兜底写本地,避免图片完全丢失。 package storage import ( @@ -16,7 +16,7 @@ import ( // Uploader 抽象图片上传。 // Upload 返回写入数据库的图片访问地址: -// - Qiniu 返回完整 https URL(base_url + key) +// - S4 返回对象 key(base_url 由 imgurl.Composer 在渲染时拼) // - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径 type Uploader interface { // Upload 上传 data 到 key 指定的对象,返回访问 URL。 @@ -26,7 +26,7 @@ type Uploader interface { } // Deleter 抽象图片删除(清理孤儿文件用)。 -// QiniuUploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中 +// S4Uploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中 // 引用计数归零时才由调用方触发,避免误删仍被引用的文件。 type Deleter interface { // Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。 diff --git a/internal/pkg/textutil/textutil.go b/internal/pkg/textutil/textutil.go deleted file mode 100644 index 70164ea..0000000 --- a/internal/pkg/textutil/textutil.go +++ /dev/null @@ -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]) + "…" -} diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go index 0c2eedd..e30b95c 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -16,7 +16,7 @@ import ( type ArticleRepository interface { // List 按筛选条件分页查询文章,同时返回符合条件的总数。 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) // FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。 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` // articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year / -// collection_type / season_code / source_url 等管理字段(前端列表不展示这些)。 +// collection_type / season_code 等管理字段(前端列表不展示这些)。 const articleAdminColumns = `brand_runway.id, brand_runway.brand_id, brand_runway.title_en, brand_runway.title_cn, brand_runway.cover, brand_runway.image_count, brand_runway.year, brand_runway.collection_type, brand_runway.season, brand_runway.season_code, - brand_runway.source_url, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` // brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 @@ -232,7 +231,7 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). 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"). Find(&imgs).Error if err != nil { diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go index 23819f3..9bb2245 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -11,7 +11,11 @@ import ( ) // 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 // 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。 @@ -27,37 +31,23 @@ type IngestRepository interface { MarkFailed(ctx context.Context, id uint32, errMsg string) error // ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。 ReserveNonce(ctx context.Context, nonce string) (ok bool, err error) - // RunwayIDBySourceURL 按来源链接查是否已存在走秀;返回 (id, found)。 - RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) - // CreateRunway 插入走秀正式行,返回自增主键。 - CreateRunway(ctx context.Context, r *model.BrandRunway) (uint32, error) - // CreateRunwayImages 批量插入走秀图片行。 - CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error + // RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表; + // 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。 + RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) // CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。 CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) // CreateRunwayDraftImages 批量插入草稿图片行。 CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error - // DraftIDBySourceURL 按来源链接查是否已有 pending 草稿;返回 (id, found)。 - DraftIDBySourceURL(ctx context.Context, sourceURL string) (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) + // StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。 + StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) // CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。 CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) // CreateStreetSnapDraftImages 批量插入街拍草稿图片行。 CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error - // ListJobs 按 id 倒序列出最近的入库任务(用于后台监控页)。 - ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) + // ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。 + 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(ctx context.Context, id uint32) error // EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。 @@ -66,14 +56,6 @@ type IngestRepository interface { // ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。 // 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。 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 { @@ -254,17 +236,15 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool return true, nil } -func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) { - if sourceURL == "" { - return 0, false, nil - } +// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。 +func (r *ingestRepository) RunwayIDByEntity(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("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). Scan(&row).Error if err != nil { @@ -276,26 +256,6 @@ func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL st 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) { now := uint32(time.Now().Unix()) d.CreatedAt = now @@ -321,117 +281,8 @@ func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []m return r.db.WithContext(ctx).Create(&imgs).Error } -func (r *ingestRepository) DraftIDBySourceURL(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.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 - } +// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。 +func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) { var row struct { ID uint32 `gorm:"column:id"` } @@ -450,29 +301,6 @@ func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string 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) { now := uint32(time.Now().Unix()) d.CreatedAt = now @@ -498,18 +326,30 @@ func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs return r.db.WithContext(ctx).Create(&imgs).Error } -// ListJobs 按 id 倒序列出最近的入库任务(后台监控页用)。 -func (r *ingestRepository) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) { +// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。 +func (r *ingestRepository) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error) { if limit < 1 { - limit = 200 + limit = 50 + } + if offset < 0 { + offset = 0 } 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 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, // 让 worker(每 3s 扫一次 pending)立即重新拉起处理。仅对 failed 生效,其它状态原样不动。 func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error { @@ -553,70 +393,3 @@ func indexOf(s, sub string) int { } 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 -} diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go index e196930..2f6ea10 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -27,7 +27,7 @@ type ReviewRepository interface { UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error // SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。 SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error - // SaveRunwayFromDraft 晋升:按 source_url 幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。 + // SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。 SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) // 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。 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 SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) - // CountPending 统计 pending 草稿数(合并 runway + street,仪表盘卡片用)。 - CountPending(ctx context.Context) (int64, error) // CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。 // 供各 ReviewModule 复用,新增模块无需再单独写计数方法。 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 晋升草稿到正式表: -// 1. 按 source_url 查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行); +// 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行); // 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建; // 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建; // 4. 返回正式表主键。 @@ -176,10 +174,19 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint } 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()) var runwayID uint32 err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 1) 按 source_url 查找既有正式走秀 + // 1) 按实体键查找既有正式走秀 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). Limit(1).Find(&existing).Error; eErr != nil { @@ -196,8 +203,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint "collection_type": draft.CollectionType, "season_code": draft.SeasonCode, "cover": draft.Cover, - "source_url": draft.SourceURL, - "image_count": uint16(len(imgs)), + "image_count": uint16(mainCount), "updated_at": now, } @@ -219,8 +225,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint CollectionType: draft.CollectionType, SeasonCode: draft.SeasonCode, Cover: draft.Cover, - SourceURL: draft.SourceURL, - ImageCount: uint16(len(imgs)), + ImageCount: uint16(mainCount), CreatedAt: now, UpdatedAt: now, } @@ -245,6 +250,8 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint Image: im.Image, Name: im.Name, SortOrder: uint32(i + 1), + LookIndex: im.LookIndex, + IsDetail: im.IsDetail, CreatedAt: now, UpdatedAt: now, }) @@ -261,23 +268,6 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint 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 复用。 func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) { var n int64 @@ -377,7 +367,7 @@ func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, }).Error } -// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按 source_url 幂等 upsert,替换图片)。 +// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按实体键 city+year 幂等 upsert,替换图片)。 // 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。 // 在事务内完成,保证草稿与正式表一致性。 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, "city": draft.City, "cover": draft.Cover, - "source_url": draft.SourceURL, "image_count": uint16(len(imgs)), "updated_at": now, } @@ -428,7 +417,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID Year: draft.Year, City: draft.City, Cover: draft.Cover, - SourceURL: draft.SourceURL, ImageCount: uint16(len(imgs)), CreatedAt: now, UpdatedAt: now, diff --git a/internal/router/backstage.go b/internal/router/backstage.go index 5891136..9fb0d93 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -56,8 +56,6 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { internal.Use(middleware.IngestAuth(*opt.IngestAuth)) internal.POST("/ingest", opt.Ingest.Submit) internal.GET("/crawl/brands", opt.Ingest.CrawlBrands) - // 图集预检:爬虫抓详情页前批量问「这些 source_url 是否已爬取过」,跳过已存在的。 - internal.POST("/crawl/exists", opt.Ingest.CrawlExists) } else { r.POST("/admin/internal/ingest", func(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingest disabled"}) diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index b392e24..ecec4a5 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -99,16 +99,14 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto. CollectionType: "rtw", SeasonCode: "SS24", ImageCount: 12, - SourceURL: "https://www.vogue.com/fashion-shows", }}, 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{ UID: id, BrandUID: "001DESke", Title: "Acne Studios Spring 2024", BrandName: "Acne Studios", - SourceURL: "https://www.vogue.com/fashion-shows", Images: []dto.PublicArticleImage{ {ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"}, {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, }}, 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{ UID: id, Title: "Paris Fashion Week Street", diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 4c44d89..97d8d4e 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -5,6 +5,7 @@ import ( "errors" "log" "net/http" + "sort" "fashionapi/internal/dto" "fashionapi/internal/model" @@ -23,7 +24,7 @@ type MediaCleanupEnqueuer interface { // ArticleService 文章业务接口。 type ArticleService interface { 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(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) // GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。 @@ -49,9 +50,9 @@ type articleService struct { } // NewArticleService 创建文章服务。 -// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清); -// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删); -// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 +// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量); +// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删); +// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 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} } @@ -73,7 +74,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID), BrandUID: hashid.Encode(r.BrandID), 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"), // 品牌名强制优先英文,保持英文观感 ImageCount: r.ImageCount, }) @@ -88,7 +89,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu } // AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / -// 来源链接的管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 +// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) { q.Normalize() 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, SeasonCode: r.SeasonCode, ImageCount: r.ImageCount, - SourceURL: r.SourceURL, }) } return list, total, nil @@ -125,8 +125,9 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti if !ok { continue } - // 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 - imgs := s.toPublicImages(raw, false) + // 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立); + // 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 + imgs := s.toThumbImages(raw) imgs = excludeCover(imgs, list[i].Cover) if len(imgs) > limit { imgs = imgs[:limit] @@ -152,8 +153,8 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic } // Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言; -// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。 -func (s *articleService) Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) { +// 图片统一走展示样式(VIP 与免费同质量)。 +func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) { row, err := s.articles.FindByID(ctx, id) if err != nil { 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()) } + // 默认仅主图(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{ UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID), BrandUID: hashid.Encode(row.BrandID), Title: pickLocale(row.TitleEn, row.TitleCn, locale), Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), - Cover: s.img.Compose(row.Cover, hd), + Cover: s.img.Compose(row.Cover), // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), - SourceURL: row.SourceURL, - Images: s.toPublicImages(imgs, hd), + Images: s.toPublicImages(mains), + Details: details, }, nil } @@ -241,20 +266,43 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error { return nil } -// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 // ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。 -// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。 -func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool) []dto.PublicArticleImage { +// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条与右下角 +// 细节缩略图等小尺寸场景使用——那些位置只显示几十到一百多像素,用大图纯属浪费带宽。 +// 注意:列表用的 toThumbImages 不填 Thumb(列表的 Image 本身就是缩略图)。 +func (s *articleService) toPublicImages(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.Compose(im.Image, hd), - Name: im.Name, + ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID), + Image: s.img.Compose(im.Image), + Thumb: s.img.ComposeThumb(im.Image), + 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 diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index dc86c7e..d2fe10c 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -21,6 +21,7 @@ import ( // - Login 签发两者;access 用于日常请求,refresh 仅在续期时使用。 // - Refresh 用 refresh token 换发新 access(refresh 复用、不轮换,足够内部工具)。 // - Logout 吊销当前 refresh;RevokeAllByRefresh 吊销该用户全部 refresh(踢下线)。 +// // 由于 refresh 落库,服务端得以单方面作废会话——这是纯无状态 JWT 做不到的。 type AuthService interface { Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) diff --git a/internal/service/errors.go b/internal/service/errors.go index 9c12fb6..580aaeb 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -27,7 +27,6 @@ func NewError(status int, msg string) *Error { func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, 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) } // ErrArticleNotFound 文章不存在,文案与原项目一致。 diff --git a/internal/service/index_service.go b/internal/service/index_service.go index c359377..50e2ea2 100644 --- a/internal/service/index_service.go +++ b/internal/service/index_service.go @@ -55,7 +55,7 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa ArticleUID: hashid.Encode(b.ArticleID), // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 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), }) } diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index fa172cb..a3231e3 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -62,32 +62,28 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u if err != nil { return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error()) } - if payload.SourceURL == "" { - return 0, NewError(http.StatusBadRequest, "source_url required") - } // runway 必须带品牌;street 不关联品牌,brand_uid 可选 if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" { 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 { return 0, internalErr(err.Error()) } return job.ID, nil } -// ExistsSourceURLs 批量判断 source_url 是否已爬取过(爬虫抓取前的预检)。 -// -// 返回命中集合:key 为已存在的 source_url,value 恒为 true。爬虫拿到结果后应跳过这些图集, -// 不必再抓详情页、提取图片 URL 并上报——这一整套动作在判重命中时本来也会被 worker 丢弃。 -// 判定条件与 worker 判重完全一致(正式表未删除 / 草稿表 pending),因此不会漏抓也不会重复抓。 -func (s *IngestService) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) { - return s.repo.ExistsSourceURLs(ctx, urls) -} - -// ListJobs 列出最近的入库任务(后台监控页用)。 -func (s *IngestService) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) { - return s.repo.ListJobs(ctx, limit) +// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。 +func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) { + jobs, err := s.repo.ListJobs(ctx, offset, limit) + if err != nil { + return nil, 0, err + } + total, err := s.repo.CountJobs(ctx) + if err != nil { + return nil, 0, err + } + return jobs, total, nil } // RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。 @@ -225,24 +221,43 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, return } - // 2) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同品牌同季时各建一条草稿, - // 晋升阶段(SaveRunwayFromDraft)按实体键(品牌+季节码+系列)合并为正式表一条并聚合图片。 - 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,统一在此兜底) + // 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过, + // 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression) + // 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。 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) 下载图片并上传到存储(七牛优先,失败兜底本地) - cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "runway") - if len(p.Images) > 0 && imgFailed { - // 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里, + // 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底) + + // 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 等后台手动重试。 s.cleanupUploads(ctx, keys) 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, SeasonCode: seasonCode, Cover: cover, - SourceURL: p.SourceURL, - ImageCount: uint16(len(imgs)), + ImageCount: imageCount, Status: model.DraftStatusPending, } 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()) return } - rows := make([]model.BrandRunwayDraftImage, 0, len(imgs)) - for i, img := range imgs { - rows = append(rows, model.BrandRunwayDraftImage{ - DraftID: id, - Image: img, - Name: fmt.Sprintf("Look %d", i+1), - SortOrder: uint32(i + 1), - }) + for i := range draftImages { + draftImages[i].DraftID = id } - 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()) return } _ = 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(无品牌)。 func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { - // 1) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同城市同年时各建一条草稿, - // 晋升阶段(SaveStreetSnapFromDraft)按实体键(城市+年份)合并为正式表一条并聚合图片。 - if _, found, err := s.repo.StreetSnapIDBySourceURL(ctx, p.SourceURL); err == nil && found { - _ = s.repo.MarkDone(ctx, job.ID) - return - } - if _, found, err := s.repo.DraftStreetIDBySourceURL(ctx, p.SourceURL); err == nil && found { + // 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。 + // 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。 + if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found { _ = s.repo.MarkDone(ctx, job.ID) return } @@ -317,7 +375,6 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, Year: p.Year, City: p.City, Cover: cover, - SourceURL: p.SourceURL, ImageCount: uint16(len(imgs)), Status: model.DraftStatusPending, } diff --git a/internal/service/review_service.go b/internal/service/review_service.go index 6a2784b..d7bceaa 100644 --- a/internal/service/review_service.go +++ b/internal/service/review_service.go @@ -21,7 +21,6 @@ type DraftCard struct { Cover string `json:"cover"` ImageCount uint16 `json:"image_count"` Status string `json:"status"` - SourceURL string `json:"source_url"` CreatedAt uint32 `json:"created_at"` UpdatedAt uint32 `json:"updated_at"` } @@ -59,7 +58,6 @@ type DraftDetailView struct { SeasonCode string `json:"season_code"` City string `json:"city"` Cover string `json:"cover"` - SourceURL string `json:"source_url"` ImageCount uint16 `json:"image_count"` Status string `json:"status"` Images []DraftImageRef `json:"images"` @@ -102,7 +100,6 @@ type ReviewService interface { UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error - CountPending(ctx context.Context) (int64, error) // DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。 DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 @@ -219,18 +216,6 @@ func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, revi 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 { tabs := []KindTab{{Kind: "", Label: "全部"}} var all int64 @@ -315,8 +300,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail Season: d.Season, CollectionType: d.CollectionType, SeasonCode: d.SeasonCode, - Cover: m.img.Compose(d.Cover, true), // 后台预览看高清 - SourceURL: d.SourceURL, + Cover: m.img.Compose(d.Cover), // 后台预览看展示图 ImageCount: d.ImageCount, Status: d.Status, Fields: []EditField{ @@ -331,7 +315,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail }, } 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 } @@ -392,8 +376,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail Title: d.Title, Year: d.Year, City: d.City, - Cover: m.img.Compose(d.Cover, true), // 后台预览看高清 - SourceURL: d.SourceURL, + Cover: m.img.Compose(d.Cover), // 后台预览看展示图 ImageCount: d.ImageCount, Status: d.Status, Fields: []EditField{ @@ -403,7 +386,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail }, } 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 } @@ -448,10 +431,9 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard ID: d.ID, Title: d.TitleEn, Subtitle: sub, - Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清 + Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图 ImageCount: d.ImageCount, Status: d.Status, - SourceURL: d.SourceURL, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, }) @@ -471,10 +453,9 @@ func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard { ID: d.ID, Title: d.Title, Subtitle: sub, - Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清 + Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图 ImageCount: d.ImageCount, Status: d.Status, - SourceURL: d.SourceURL, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, }) diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index a5b4015..ea94c37 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -17,7 +17,7 @@ import ( // StreetSnapService 街拍业务接口。 type StreetSnapService interface { 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(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) // GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。 @@ -43,9 +43,9 @@ type streetSnapService struct { } // NewStreetSnapService 创建街拍服务。 -// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清); -// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删); -// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 +// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量); +// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删); +// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 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} } @@ -69,7 +69,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] list = append(list, dto.PublicStreetSnap{ UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID), Title: r.Title, - Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图 + Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) ImageCount: r.ImageCount, }) } @@ -93,8 +93,9 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS if !ok { continue } - // 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 - imgs := s.toPublicSnapImages(raw, false) + // 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立); + // 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 + imgs := s.toThumbSnapImages(raw) imgs = excludeCoverSnap(imgs, list[i].Cover) if len(imgs) > limit { imgs = imgs[:limit] @@ -118,9 +119,8 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA return kept } -// Detail 查询街拍详情,含完整图片集。 -// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。 -func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) { +// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。 +func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { snap, err := s.repo.FindByID(ctx, id) if err != nil { 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{ UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID), Title: snap.Title, - Cover: s.img.Compose(snap.Cover, hd), - Images: s.toPublicSnapImages(imgs, hd), + Cover: s.img.Compose(snap.Cover), + Images: s.toPublicSnapImages(imgs), }, nil } @@ -154,7 +154,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi list = append(list, dto.PublicStreetSnap{ UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID), Title: r.Title, - Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图 + Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high) ImageCount: r.ImageCount, }) } @@ -163,8 +163,9 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi // toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 // ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。 -// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。 -func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []dto.PublicArticleImage { +// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等 +// 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。 +func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil } @@ -172,7 +173,25 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd for _, im := range imgs { out = append(out, dto.PublicArticleImage{ 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, }) }