update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@ -4,6 +4,10 @@
|
|||||||
/server.exe
|
/server.exe
|
||||||
/migrate
|
/migrate
|
||||||
/migrate.exe
|
/migrate.exe
|
||||||
|
/backend_server.exe
|
||||||
|
/dev_backend.exe
|
||||||
|
*.exe~
|
||||||
|
gotest*.exe
|
||||||
|
|
||||||
# 上传图片(运行时生成,保留目录占位)
|
# 上传图片(运行时生成,保留目录占位)
|
||||||
/uploads/*
|
/uploads/*
|
||||||
|
|||||||
15
Makefile
15
Makefile
@ -1,17 +1,20 @@
|
|||||||
.PHONY: build run migrate test tidy docker clean
|
.PHONY: build run migrate test tidy docker clean
|
||||||
|
|
||||||
# 编译 server 与 migrate 两个二进制到 bin/
|
# 编译 server
|
||||||
build:
|
build:
|
||||||
go build -o bin/server ./cmd/server
|
go build -o bin/server ./cmd/server
|
||||||
go build -o bin/migrate ./cmd/migrate
|
|
||||||
|
|
||||||
# 构建并启动 API 服务(默认读取 configs/config.yml)
|
# 构建并启动 API 服务(默认读取 configs/config.yml)
|
||||||
run: build
|
run: build
|
||||||
./bin/server
|
./bin/server
|
||||||
|
|
||||||
# 执行一次结构迁移
|
# 结构迁移为手动 SQL(无自动运行器):db/migrations/001-007(ingest/review 链)
|
||||||
migrate: build
|
# 与 scripts/sql/002-008(i18n/账号链)内容互补,按文件名顺序用 mysql 客户端执行,例如:
|
||||||
./bin/migrate
|
# mysql db_dev < db/migrations/001_create_street_snap.sql
|
||||||
|
# mysql db_dev < db/migrations/002_ingest.sql
|
||||||
|
# ... 两套链都跑完才是完整 schema
|
||||||
|
migrate:
|
||||||
|
@echo "Migrations are manual SQL. Apply db/migrations/001-007 then scripts/sql/002-008 via a mysql client."
|
||||||
|
|
||||||
# 跑单测
|
# 跑单测
|
||||||
test:
|
test:
|
||||||
@ -27,4 +30,4 @@ docker:
|
|||||||
|
|
||||||
# 清理产物
|
# 清理产物
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin server server.exe migrate migrate.exe
|
rm -rf bin server server.exe
|
||||||
|
|||||||
@ -19,8 +19,11 @@ import (
|
|||||||
"fashionapi/internal/config"
|
"fashionapi/internal/config"
|
||||||
"fashionapi/internal/database"
|
"fashionapi/internal/database"
|
||||||
"fashionapi/internal/handler"
|
"fashionapi/internal/handler"
|
||||||
|
"fashionapi/internal/middleware"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
"fashionapi/internal/pkg/jwt"
|
"fashionapi/internal/pkg/jwt"
|
||||||
|
"fashionapi/internal/pkg/storage"
|
||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
"fashionapi/internal/router"
|
"fashionapi/internal/router"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
@ -77,18 +80,54 @@ func main() {
|
|||||||
refreshRepo = repository.NewRefreshTokenRepository(db)
|
refreshRepo = repository.NewRefreshTokenRepository(db)
|
||||||
favRepo = repository.NewFavoriteRepository(db)
|
favRepo = repository.NewFavoriteRepository(db)
|
||||||
histRepo = repository.NewHistoryRepository(db)
|
histRepo = repository.NewHistoryRepository(db)
|
||||||
|
ingestRepo = repository.NewIngestRepository(db)
|
||||||
|
reviewRepo = repository.NewReviewRepository(db)
|
||||||
|
// 跨表图片引用计数(删除图集时判定七牛孤儿文件用)
|
||||||
|
mediaRepo = repository.NewMediaRepository(db)
|
||||||
|
|
||||||
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||||
|
|
||||||
articleSvc = service.NewArticleService(articleRepo)
|
// 图片 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)
|
||||||
|
|
||||||
|
// 图片存储:七牛云优先(若启用),失败兜底本地 uploads。
|
||||||
|
// localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用七牛时两者均为七牛实现。
|
||||||
|
localUp *storage.LocalUploader = storage.NewLocalUploader(uploadDir, cfg.Upload.URLPrefix)
|
||||||
|
imgUp storage.Uploader = localUp
|
||||||
|
imgDel storage.Deleter = localUp
|
||||||
|
|
||||||
|
articleSvc service.ArticleService // 在 ingestSvc 创建后再装配(需注入清理入队器)
|
||||||
brandSvc = service.NewBrandService(brandRepo)
|
brandSvc = service.NewBrandService(brandRepo)
|
||||||
indexSvc = service.NewIndexService(brandRepo)
|
indexSvc = service.NewIndexService(brandRepo, imgComposer)
|
||||||
snapSvc = service.NewStreetSnapService(snapRepo)
|
snapSvc service.StreetSnapService // 在 ingestSvc 创建后再装配(需注入清理入队器)
|
||||||
authSvc = service.NewAuthService(userRepo, refreshRepo, jwtManager, cfg.JWT.ExpireHours, cfg.JWT.RefreshExpireHours)
|
authSvc = service.NewAuthService(userRepo, refreshRepo, jwtManager, cfg.JWT.ExpireHours, cfg.JWT.RefreshExpireHours)
|
||||||
favSvc = service.NewFavoriteService(favRepo)
|
favSvc = service.NewFavoriteService(favRepo)
|
||||||
histSvc = service.NewHistoryService(histRepo)
|
histSvc = service.NewHistoryService(histRepo)
|
||||||
|
// 审核:草稿列表 / 通过晋升正式表 / 驳回(后台预览始终高清)
|
||||||
|
reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 爬虫入库:入队 + 异步 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)
|
||||||
|
} else {
|
||||||
|
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix)
|
||||||
|
}
|
||||||
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(七牛/本地删除器)。
|
||||||
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理七牛」异步入队。
|
||||||
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
||||||
|
// 图集服务:注入 ingestSvc 作为「清理七牛孤儿图」的入队器,使下架/删除异步触发存储清理。
|
||||||
|
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
|
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
|
|
||||||
engine := router.New(router.Options{
|
engine := router.New(router.Options{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
JWT: jwtManager,
|
JWT: jwtManager,
|
||||||
@ -101,6 +140,11 @@ func main() {
|
|||||||
Health: handler.NewHealthHandler(),
|
Health: handler.NewHealthHandler(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
||||||
|
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc)
|
||||||
|
// 爬虫上报 handler(HMAC 验签入口)
|
||||||
|
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
||||||
|
|
||||||
// 6. 启动对外公开 HTTP 服务(8090),并支持优雅关闭
|
// 6. 启动对外公开 HTTP 服务(8090),并支持优雅关闭
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Server.Port,
|
Addr: ":" + cfg.Server.Port,
|
||||||
@ -133,6 +177,37 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 7.5 启动管理后台(默认 8092):后台 UI + 爬虫 ingest 内部接口。
|
||||||
|
backstageEngine := router.NewBackstage(router.BackstageOptions{
|
||||||
|
Config: cfg,
|
||||||
|
JWT: jwtManager,
|
||||||
|
Backstage: backstageHandler,
|
||||||
|
UploadURLPrefix: cfg.Upload.URLPrefix,
|
||||||
|
UploadDir: uploadDir,
|
||||||
|
Ingest: ingestHandler,
|
||||||
|
IngestAuth: &middleware.IngestAuthConfig{
|
||||||
|
Secret: cfg.Ingest.Secret,
|
||||||
|
TTL: cfg.Ingest.TTLSeconds,
|
||||||
|
Nonces: ingestRepo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7.6 启动爬虫入库 worker(异步处理 ingest_jobs 队列)。
|
||||||
|
// 用独立可取消 ctx,优雅关闭时随主流程一起退出。
|
||||||
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
|
go ingestSvc.Run(workerCtx, 2, 3*time.Second)
|
||||||
|
log.Printf("⚙ 入库 worker 已启动(队列 ingest_jobs)")
|
||||||
|
srvBackstage := &http.Server{
|
||||||
|
Addr: ":" + cfg.Server.BackstagePort,
|
||||||
|
Handler: backstageEngine,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
log.Printf("🛠 管理后台已启动: http://localhost:%s/admin", cfg.Server.BackstagePort)
|
||||||
|
if err := srvBackstage.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("✗ 管理后台启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 收到中断信号后,给正在处理的请求留出完成时间再退出,
|
// 收到中断信号后,给正在处理的请求留出完成时间再退出,
|
||||||
// 避免直接杀进程导致请求被截断、连接池未正常释放。
|
// 避免直接杀进程导致请求被截断、连接池未正常释放。
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
@ -143,11 +218,15 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(),
|
ctx, cancel := context.WithTimeout(context.Background(),
|
||||||
time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
workerCancel() // 通知入库 worker 停止领取新任务
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
log.Printf("! 公开服务优雅关闭超时: %v", err)
|
log.Printf("! 公开服务优雅关闭超时: %v", err)
|
||||||
}
|
}
|
||||||
if err := srvSSG.Shutdown(ctx); err != nil {
|
if err := srvSSG.Shutdown(ctx); err != nil {
|
||||||
log.Printf("! SSG 内部服务优雅关闭超时: %v", err)
|
log.Printf("! SSG 内部服务优雅关闭超时: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := srvBackstage.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("! 管理后台优雅关闭超时: %v", err)
|
||||||
|
}
|
||||||
log.Println("✓ 服务已停止")
|
log.Println("✓ 服务已停止")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,3 +76,37 @@ client_sign:
|
|||||||
enabled: false # env: CLIENT_SIGN_ENABLED(容器部署用环境变量开)
|
enabled: false # env: CLIENT_SIGN_ENABLED(容器部署用环境变量开)
|
||||||
secret: "" # env: CLIENT_SIGN_SECRET
|
secret: "" # env: CLIENT_SIGN_SECRET
|
||||||
ttl_seconds: 30 # 签名时间戳容忍窗口(秒),防重放;env: CLIENT_SIGN_TTL
|
ttl_seconds: 30 # 签名时间戳容忍窗口(秒),防重放;env: CLIENT_SIGN_TTL
|
||||||
|
|
||||||
|
# 爬虫入库管线:暴露 :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)。
|
||||||
|
# - 启用后 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:
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,10 @@ type Config struct {
|
|||||||
Upload UploadConfig `yaml:"upload"`
|
Upload UploadConfig `yaml:"upload"`
|
||||||
CORS CORSConfig `yaml:"cors"`
|
CORS CORSConfig `yaml:"cors"`
|
||||||
ClientSign ClientSignConfig `yaml:"client_sign"`
|
ClientSign ClientSignConfig `yaml:"client_sign"`
|
||||||
|
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
|
||||||
|
Ingest IngestConfig `yaml:"ingest"`
|
||||||
|
// Qiniu 七牛云对象存储配置(爬虫入库图片上传目标)。
|
||||||
|
Qiniu QiniuConfig `yaml:"qiniu"`
|
||||||
|
|
||||||
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
||||||
// 小写不导出,yml 无法覆盖它。
|
// 小写不导出,yml 无法覆盖它。
|
||||||
@ -44,6 +48,10 @@ type ServerConfig struct {
|
|||||||
// 通过环境变量 SSG_TOKEN 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
|
// 通过环境变量 SSG_TOKEN 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
|
||||||
SSGToken string `yaml:"ssg_token"`
|
SSGToken string `yaml:"ssg_token"`
|
||||||
|
|
||||||
|
// BackstagePort 是管理后台(Backstage)端口,承载后台 UI 与(后续)爬虫 ingest 内部接口。
|
||||||
|
// 与对外公开 Port / SSGPort 物理隔离,可独立绑定与限流;为空时兜底 "8092"。
|
||||||
|
BackstagePort string `yaml:"backstage_port"`
|
||||||
|
|
||||||
// HashIDSecret 是「公开 ID 混淆」的部署级盐值。
|
// HashIDSecret 是「公开 ID 混淆」的部署级盐值。
|
||||||
// 公开接口对外只暴露编码后的无序串(如 xK9mP2)而非自增主键,防止爬虫顺序枚举;
|
// 公开接口对外只暴露编码后的无序串(如 xK9mP2)而非自增主键,防止爬虫顺序枚举;
|
||||||
// 该盐值决定编码结果,不同部署应使用不同随机串,避免被反向推导。
|
// 该盐值决定编码结果,不同部署应使用不同随机串,避免被反向推导。
|
||||||
@ -106,6 +114,48 @@ type CORSConfig struct {
|
|||||||
MaxAge int `yaml:"max_age"`
|
MaxAge int `yaml:"max_age"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IngestConfig 爬虫上报接口(:8092 /admin/internal/ingest)的 HMAC 验签配置。
|
||||||
|
//
|
||||||
|
// 爬虫与后台通过共享同一 INGEST_SECRET 对请求做 HMAC-SHA256 签名,
|
||||||
|
// 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。
|
||||||
|
// 密钥必须走环境变量 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 七牛云对象存储配置(爬虫入库图片上传目标)。
|
||||||
|
//
|
||||||
|
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传七牛 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"`
|
||||||
|
}
|
||||||
|
|
||||||
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
||||||
//
|
//
|
||||||
// 给 /runway-looks(列表)与 /brands 两个批量查询接口加一层请求签名校验,
|
// 给 /runway-looks(列表)与 /brands 两个批量查询接口加一层请求签名校验,
|
||||||
@ -134,6 +184,7 @@ func defaultConfig() *Config {
|
|||||||
ShutdownTimeout: 10,
|
ShutdownTimeout: 10,
|
||||||
SSGPort: "8091",
|
SSGPort: "8091",
|
||||||
SSGToken: "",
|
SSGToken: "",
|
||||||
|
BackstagePort: "8092",
|
||||||
HashIDSecret: "",
|
HashIDSecret: "",
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
@ -169,6 +220,21 @@ func defaultConfig() *Config {
|
|||||||
Secret: "",
|
Secret: "",
|
||||||
TTLSeconds: 30,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,6 +294,7 @@ func (c *Config) applyEnv() {
|
|||||||
envStr("GIN_MODE", &c.Server.Mode)
|
envStr("GIN_MODE", &c.Server.Mode)
|
||||||
envStr("SSG_PORT", &c.Server.SSGPort)
|
envStr("SSG_PORT", &c.Server.SSGPort)
|
||||||
envStr("SSG_TOKEN", &c.Server.SSGToken)
|
envStr("SSG_TOKEN", &c.Server.SSGToken)
|
||||||
|
envStr("BACKSTAGE_PORT", &c.Server.BackstagePort)
|
||||||
envStr("HASHID_SECRET", &c.Server.HashIDSecret)
|
envStr("HASHID_SECRET", &c.Server.HashIDSecret)
|
||||||
|
|
||||||
envStr("DB_HOST", &c.Database.Host)
|
envStr("DB_HOST", &c.Database.Host)
|
||||||
@ -252,6 +319,22 @@ func (c *Config) applyEnv() {
|
|||||||
|
|
||||||
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
||||||
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
||||||
|
|
||||||
|
envBool("INGEST_ENABLED", &c.Ingest.Enabled)
|
||||||
|
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
||||||
@ -262,6 +345,9 @@ func (c *Config) normalize() {
|
|||||||
if c.Server.SSGPort == "" {
|
if c.Server.SSGPort == "" {
|
||||||
c.Server.SSGPort = "8091"
|
c.Server.SSGPort = "8091"
|
||||||
}
|
}
|
||||||
|
if c.Server.BackstagePort == "" {
|
||||||
|
c.Server.BackstagePort = "8092"
|
||||||
|
}
|
||||||
switch c.Server.Mode {
|
switch c.Server.Mode {
|
||||||
case "debug", "release", "test":
|
case "debug", "release", "test":
|
||||||
default:
|
default:
|
||||||
@ -313,6 +399,14 @@ func envInt(key string, dst *int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envInt64(key string, dst *int64) {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
*dst = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func envBool(key string, dst *bool) {
|
func envBool(key string, dst *bool) {
|
||||||
if v := strings.TrimSpace(strings.ToLower(os.Getenv(key))); v != "" {
|
if v := strings.TrimSpace(strings.ToLower(os.Getenv(key))); v != "" {
|
||||||
*dst = v == "1" || v == "true" || v == "yes"
|
*dst = v == "1" || v == "true" || v == "yes"
|
||||||
|
|||||||
@ -98,5 +98,21 @@ type PublicArticleDetail struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
BrandName string `json:"brand_name"`
|
BrandName string `json:"brand_name"`
|
||||||
|
SourceURL string `json:"source_url"` // 采集来源链接(后台溯源用,对外无害)
|
||||||
Images []PublicArticleImage `json:"images"`
|
Images []PublicArticleImage `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段,
|
||||||
|
// 供后台按季节 / 年份 / 系列筛选与展示(对外 PublicArticle 刻意不暴露这些元数据)。
|
||||||
|
type AdminRunway struct {
|
||||||
|
UID string `json:"id"` // 编码后的文章 id(无序串)
|
||||||
|
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||||
|
BrandName string `json:"brand_name"` // JOIN brand 得到(优先 en)
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year uint16 `json:"year"` // 年份(如 2024)
|
||||||
|
Season string `json:"season"` // spring / fall
|
||||||
|
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"` // 采集来源链接(去重 / 溯源用)
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ type UserPayload struct {
|
|||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
// Tier 用户等级 free/vip;详情页据其决定返回普通还是高清图。
|
||||||
|
Tier string `json:"tier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 登录 ----------
|
// ---------- 登录 ----------
|
||||||
|
|||||||
@ -94,7 +94,7 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
|
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c), middleware.IsVIP(middleware.TierFrom(c)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -23,7 +23,7 @@ func NewAuthHandler(auth service.AuthService) *AuthHandler {
|
|||||||
|
|
||||||
// Me 当前登录用户,需 Authorization: Bearer <token>。
|
// Me 当前登录用户,需 Authorization: Bearer <token>。
|
||||||
//
|
//
|
||||||
// GET /api/auth/me
|
// GET /api/v1/me(旧别名 /api/v1/auth/me 已废弃)
|
||||||
// 成功:200 { user }
|
// 成功:200 { user }
|
||||||
func (h *AuthHandler) Me(c *gin.Context) {
|
func (h *AuthHandler) Me(c *gin.Context) {
|
||||||
userID, ok := middleware.UserIDFrom(c)
|
userID, ok := middleware.UserIDFrom(c)
|
||||||
@ -86,7 +86,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
|
|
||||||
// Logout 吊销当前 refresh token(单设备登出)。
|
// Logout 吊销当前 refresh token(单设备登出)。
|
||||||
//
|
//
|
||||||
// POST /api/auth/logout
|
// DELETE /api/v1/auth/sessions/current(旧别名 POST /api/v1/auth/logout 已废弃)
|
||||||
// body: { refresh_token }
|
// body: { refresh_token }
|
||||||
// 成功:200 { ok: true }
|
// 成功:200 { ok: true }
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
@ -105,7 +105,7 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
|
|
||||||
// LogoutAll 吊销当前用户全部 refresh token(踢下线 / 全设备登出)。
|
// LogoutAll 吊销当前用户全部 refresh token(踢下线 / 全设备登出)。
|
||||||
//
|
//
|
||||||
// POST /api/auth/logout-all
|
// DELETE /api/v1/auth/sessions(旧别名 POST /api/v1/auth/logout-all 已废弃)
|
||||||
// body: { refresh_token }
|
// body: { refresh_token }
|
||||||
// 成功:200 { revoked: <受影响行数> }
|
// 成功:200 { revoked: <受影响行数> }
|
||||||
func (h *AuthHandler) LogoutAll(c *gin.Context) {
|
func (h *AuthHandler) LogoutAll(c *gin.Context) {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ func NewFavoriteHandler(favs service.FavoriteService) *FavoriteHandler {
|
|||||||
|
|
||||||
// List 当前用户收藏(服务端分页)。
|
// List 当前用户收藏(服务端分页)。
|
||||||
//
|
//
|
||||||
// GET /api/v1/auth/favorites?page=1&per_page=24&kind=(per_page 上限 100)
|
// GET /api/v1/me/favorites?page=1&per_page=24&kind=(per_page 上限 100)
|
||||||
// kind 留空=不限粒度;gallery=只要图集收藏;image=只要单图收藏。
|
// kind 留空=不限粒度;gallery=只要图集收藏;image=只要单图收藏。
|
||||||
// 成功:200 { data: { items:[...], total, last_page } }
|
// 成功:200 { data: { items:[...], total, last_page } }
|
||||||
func (h *FavoriteHandler) List(c *gin.Context) {
|
func (h *FavoriteHandler) List(c *gin.Context) {
|
||||||
@ -60,7 +60,7 @@ func (h *FavoriteHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// Add 新增收藏(幂等:重复添加不报错)。
|
// Add 新增收藏(幂等:重复添加不报错)。
|
||||||
//
|
//
|
||||||
// POST /api/v1/auth/favorites
|
// POST /api/v1/me/favorites
|
||||||
// body: { target_type, target_uid, title?, cover?, brand? }
|
// body: { target_type, target_uid, title?, cover?, brand? }
|
||||||
// 成功:200 { data: <FavoriteItem> }
|
// 成功:200 { data: <FavoriteItem> }
|
||||||
func (h *FavoriteHandler) Add(c *gin.Context) {
|
func (h *FavoriteHandler) Add(c *gin.Context) {
|
||||||
@ -84,7 +84,7 @@ func (h *FavoriteHandler) Add(c *gin.Context) {
|
|||||||
|
|
||||||
// Remove 删除收藏。
|
// Remove 删除收藏。
|
||||||
//
|
//
|
||||||
// DELETE /api/v1/auth/favorites/:target_uid
|
// DELETE /api/v1/me/favorites/:uid
|
||||||
// 成功:200 { data: { ok: true } }
|
// 成功:200 { data: { ok: true } }
|
||||||
func (h *FavoriteHandler) Remove(c *gin.Context) {
|
func (h *FavoriteHandler) Remove(c *gin.Context) {
|
||||||
userID, ok := middleware.UserIDFrom(c)
|
userID, ok := middleware.UserIDFrom(c)
|
||||||
@ -92,7 +92,7 @@ func (h *FavoriteHandler) Remove(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := c.Param("target_uid")
|
uid := c.Param("uid")
|
||||||
if err := h.favs.Remove(c.Request.Context(), userID, uid); err != nil {
|
if err := h.favs.Remove(c.Request.Context(), userID, uid); err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
@ -102,7 +102,7 @@ func (h *FavoriteHandler) Remove(c *gin.Context) {
|
|||||||
|
|
||||||
// Check 批量查询给定 ids 中哪些已收藏(列表页打勾用,与收藏总量解耦)。
|
// Check 批量查询给定 ids 中哪些已收藏(列表页打勾用,与收藏总量解耦)。
|
||||||
//
|
//
|
||||||
// POST /api/v1/auth/favorites/check
|
// POST /api/v1/me/favorites/check
|
||||||
// body: { ids: ["r=...","s=..."] }
|
// body: { ids: ["r=...","s=..."] }
|
||||||
// 成功:200 { data: ["r=..."] }(仅返回已收藏的 id)
|
// 成功:200 { data: ["r=..."] }(仅返回已收藏的 id)
|
||||||
func (h *FavoriteHandler) Check(c *gin.Context) {
|
func (h *FavoriteHandler) Check(c *gin.Context) {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ func NewHistoryHandler(hist service.HistoryService) *HistoryHandler {
|
|||||||
|
|
||||||
// List 当前用户浏览历史(服务端分页)。
|
// List 当前用户浏览历史(服务端分页)。
|
||||||
//
|
//
|
||||||
// GET /api/v1/auth/history?page=1&per_page=24
|
// GET /api/v1/me/history?page=1&per_page=24
|
||||||
// 成功:200 { data: { items:[...], total, last_page } }
|
// 成功:200 { data: { items:[...], total, last_page } }
|
||||||
func (h *HistoryHandler) List(c *gin.Context) {
|
func (h *HistoryHandler) List(c *gin.Context) {
|
||||||
userID, ok := middleware.UserIDFrom(c)
|
userID, ok := middleware.UserIDFrom(c)
|
||||||
@ -54,7 +54,7 @@ func (h *HistoryHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// Record 记录一次浏览(点开文章详情;upsert:同文章刷新时间,不新增行)。
|
// Record 记录一次浏览(点开文章详情;upsert:同文章刷新时间,不新增行)。
|
||||||
//
|
//
|
||||||
// POST /api/v1/auth/history
|
// POST /api/v1/me/history
|
||||||
// body: { target_uid } // 带 r=/s= 前缀的对外编码串
|
// body: { target_uid } // 带 r=/s= 前缀的对外编码串
|
||||||
// 成功:200 { data: { ok: true } }
|
// 成功:200 { data: { ok: true } }
|
||||||
func (h *HistoryHandler) Record(c *gin.Context) {
|
func (h *HistoryHandler) Record(c *gin.Context) {
|
||||||
@ -77,7 +77,7 @@ func (h *HistoryHandler) Record(c *gin.Context) {
|
|||||||
|
|
||||||
// Remove 删除一条历史。
|
// Remove 删除一条历史。
|
||||||
//
|
//
|
||||||
// DELETE /api/v1/auth/history/:target_uid
|
// DELETE /api/v1/me/history/:uid
|
||||||
// 成功:200 { data: { ok: true } }
|
// 成功:200 { data: { ok: true } }
|
||||||
func (h *HistoryHandler) Remove(c *gin.Context) {
|
func (h *HistoryHandler) Remove(c *gin.Context) {
|
||||||
userID, ok := middleware.UserIDFrom(c)
|
userID, ok := middleware.UserIDFrom(c)
|
||||||
@ -85,7 +85,7 @@ func (h *HistoryHandler) Remove(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := c.Param("target_uid")
|
uid := c.Param("uid")
|
||||||
if err := h.hist.Remove(c.Request.Context(), userID, uid); err != nil {
|
if err := h.hist.Remove(c.Request.Context(), userID, uid); err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
@ -95,7 +95,7 @@ func (h *HistoryHandler) Remove(c *gin.Context) {
|
|||||||
|
|
||||||
// Clear 清空当前用户全部历史。
|
// Clear 清空当前用户全部历史。
|
||||||
//
|
//
|
||||||
// DELETE /api/v1/auth/history
|
// DELETE /api/v1/me/history
|
||||||
// 成功:200 { data: { ok: true } }
|
// 成功:200 { data: { ok: true } }
|
||||||
func (h *HistoryHandler) Clear(c *gin.Context) {
|
func (h *HistoryHandler) Clear(c *gin.Context) {
|
||||||
userID, ok := middleware.UserIDFrom(c)
|
userID, ok := middleware.UserIDFrom(c)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/middleware"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/pkg/response"
|
"fashionapi/internal/pkg/response"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
@ -78,7 +79,7 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), middleware.IsVIP(middleware.TierFrom(c)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -16,8 +16,19 @@ import (
|
|||||||
const (
|
const (
|
||||||
ContextUserID = "userID"
|
ContextUserID = "userID"
|
||||||
ContextUsername = "username"
|
ContextUsername = "username"
|
||||||
|
// ContextTier 存放用户等级(free/vip),供详情接口据其拼装普通/高清图。
|
||||||
|
ContextTier = "tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TierFree / TierVIP 用户等级枚举值。
|
||||||
|
const (
|
||||||
|
TierFree = "free"
|
||||||
|
TierVIP = "vip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsVIP 判断等级是否为 VIP。
|
||||||
|
func IsVIP(tier string) bool { return tier == TierVIP }
|
||||||
|
|
||||||
// bearerPrefix Authorization 头的令牌前缀。
|
// bearerPrefix Authorization 头的令牌前缀。
|
||||||
const bearerPrefix = "Bearer "
|
const bearerPrefix = "Bearer "
|
||||||
|
|
||||||
@ -46,6 +57,7 @@ func requireAuth(c *gin.Context, manager *jwt.Manager) {
|
|||||||
|
|
||||||
c.Set(ContextUserID, claims.UserID)
|
c.Set(ContextUserID, claims.UserID)
|
||||||
c.Set(ContextUsername, claims.Username)
|
c.Set(ContextUsername, claims.Username)
|
||||||
|
c.Set(ContextTier, claims.Tier)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,6 +77,7 @@ func PublicFirstPageAuth(manager *jwt.Manager) gin.HandlerFunc {
|
|||||||
if claims, err := manager.Parse(strings.TrimPrefix(header, bearerPrefix)); err == nil {
|
if claims, err := manager.Parse(strings.TrimPrefix(header, bearerPrefix)); err == nil {
|
||||||
c.Set(ContextUserID, claims.UserID)
|
c.Set(ContextUserID, claims.UserID)
|
||||||
c.Set(ContextUsername, claims.Username)
|
c.Set(ContextUsername, claims.Username)
|
||||||
|
c.Set(ContextTier, claims.Tier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 非首页:必须有有效 token,否则 401。
|
// 非首页:必须有有效 token,否则 401。
|
||||||
@ -92,3 +105,13 @@ func UserIDFrom(c *gin.Context) (uint32, bool) {
|
|||||||
}
|
}
|
||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TierFrom 从 Context 取出当前用户等级;未登录(无 tier)回落 free。
|
||||||
|
func TierFrom(c *gin.Context) string {
|
||||||
|
if v, exists := c.Get(ContextTier); exists {
|
||||||
|
if t, ok := v.(string); ok && t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TierFree
|
||||||
|
}
|
||||||
|
|||||||
@ -12,8 +12,6 @@ type RefreshToken struct {
|
|||||||
TokenHash string `gorm:"column:token_hash;size:64;uniqueIndex:uk_token_hash" json:"-"`
|
TokenHash string `gorm:"column:token_hash;size:64;uniqueIndex:uk_token_hash" json:"-"`
|
||||||
ExpiresAt uint32 `gorm:"column:expires_at" json:"-"`
|
ExpiresAt uint32 `gorm:"column:expires_at" json:"-"`
|
||||||
Revoked uint8 `gorm:"column:revoked" json:"-"`
|
Revoked uint8 `gorm:"column:revoked" json:"-"`
|
||||||
UserAgent string `gorm:"column:user_agent;size:255" json:"-"`
|
|
||||||
IP string `gorm:"column:ip;size:64" json:"-"`
|
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"-"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"-"`
|
||||||
|
|
||||||
// 与 GORM 自动时间字段区分:CreatedAt 由业务层显式写入 Unix 秒。
|
// 与 GORM 自动时间字段区分:CreatedAt 由业务层显式写入 Unix 秒。
|
||||||
|
|||||||
@ -11,6 +11,8 @@ type User struct {
|
|||||||
Email string `gorm:"column:email;size:191;uniqueIndex" json:"email"`
|
Email string `gorm:"column:email;size:191;uniqueIndex" json:"email"`
|
||||||
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
|
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||||
|
// Tier 用户等级:free(普通/未登录)/ vip(高级/VIP)。详情页据其决定返回普通还是高清图。
|
||||||
|
Tier string `gorm:"column:tier;size:16" json:"tier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名。
|
// TableName 指定表名。
|
||||||
|
|||||||
@ -19,6 +19,8 @@ type Claims struct {
|
|||||||
UserID uint32
|
UserID uint32
|
||||||
Username string
|
Username string
|
||||||
Email string
|
Email string
|
||||||
|
// Tier 用户等级 free/vip;详情页据其拼装图片 URL(普通/高清)。
|
||||||
|
Tier string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager 令牌签发器。通过构造函数注入密钥与有效期,避免每次调用都重新读取配置
|
// Manager 令牌签发器。通过构造函数注入密钥与有效期,避免每次调用都重新读取配置
|
||||||
@ -46,13 +48,14 @@ func NewManager(secret string, expireHours int) *Manager {
|
|||||||
|
|
||||||
// Generate 签发令牌(登录成功后调用)。
|
// Generate 签发令牌(登录成功后调用)。
|
||||||
//
|
//
|
||||||
// 构造与 Parse 对称的 Claims(uid / username / email / exp),用 HS256 签名。
|
// 构造与 Parse 对称的 Claims(uid / username / email / tier / exp),用 HS256 签名。
|
||||||
// exp 由 Manager 的 expire 决定(默认 7 天,见 NewManager)。
|
// exp 由 Manager 的 expire 决定(默认 7 天,见 NewManager)。
|
||||||
func (m *Manager) Generate(userID uint32, username, email string) (string, error) {
|
func (m *Manager) Generate(userID uint32, username, email, tier string) (string, error) {
|
||||||
claims := jwtlib.MapClaims{
|
claims := jwtlib.MapClaims{
|
||||||
"uid": float64(userID),
|
"uid": float64(userID),
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
|
"tier": tier,
|
||||||
"exp": time.Now().Add(m.expire).Unix(),
|
"exp": time.Now().Add(m.expire).Unix(),
|
||||||
}
|
}
|
||||||
token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
|
token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
|
||||||
@ -87,5 +90,11 @@ func (m *Manager) Parse(tokenStr string) (*Claims, error) {
|
|||||||
if v, ok := raw["email"].(string); ok {
|
if v, ok := raw["email"].(string); ok {
|
||||||
c.Email = v
|
c.Email = v
|
||||||
}
|
}
|
||||||
|
// tier 缺省回落 free(兼容未带 tier 的旧令牌)
|
||||||
|
if v, ok := raw["tier"].(string); ok && v != "" {
|
||||||
|
c.Tier = v
|
||||||
|
} else {
|
||||||
|
c.Tier = "free"
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -14,12 +16,28 @@ import (
|
|||||||
type ArticleRepository interface {
|
type ArticleRepository interface {
|
||||||
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
||||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
|
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code/source_url 等管理字段。
|
||||||
|
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||||||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||||
// ListImages 查询某篇文章的全部图片,按排序值升序。
|
// ListImages 查询某篇文章的全部图片,按排序值升序。
|
||||||
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||||
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
||||||
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
||||||
|
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
||||||
|
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||||
|
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
|
||||||
|
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
|
// DeleteRunwayImage 软删单张走秀图片。
|
||||||
|
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||||
|
// GetRunwayImageKey 取单张走秀图片的七牛 key(删除前清理用);不存在返回空串。
|
||||||
|
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
|
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||||
|
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||||
|
SetRunwayCover(ctx context.Context, runwayID uint32, image string) error
|
||||||
|
// SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
|
||||||
|
SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type articleRepository struct {
|
type articleRepository struct {
|
||||||
@ -43,6 +61,15 @@ const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runw
|
|||||||
brand_runway.description_en, brand_runway.description_cn,
|
brand_runway.description_en, brand_runway.description_cn,
|
||||||
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
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 等管理字段(前端列表不展示这些)。
|
||||||
|
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 保证品牌被软删时文章依然可见。
|
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||||
const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0"
|
const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0"
|
||||||
|
|
||||||
@ -140,6 +167,36 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAdmin 后台管理列表:与 List 共用筛选/排序,但投影列含管理字段。
|
||||||
|
func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) {
|
||||||
|
scope := filterScope(q)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Scopes(scope).
|
||||||
|
Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []model.RunwayRow{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []model.RunwayRow
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Scopes(scope).
|
||||||
|
Select(articleAdminColumns).
|
||||||
|
Joins(brandJoin).
|
||||||
|
Order(orderBy(q.Sort)).
|
||||||
|
Offset(q.Offset()).
|
||||||
|
Limit(q.Size).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return rows, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||||
var row model.RunwayRow
|
var row model.RunwayRow
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
@ -187,4 +244,106 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDs 返回全部未删除文章的 id(升序),供 SSG 构建期枚举详情页路径。
|
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// runwayEditable 后台可编辑的走秀字段白名单(键即列名)。
|
||||||
|
var runwayEditable = map[string]bool{
|
||||||
|
"title_en": true,
|
||||||
|
"title_cn": true,
|
||||||
|
"description_en": true,
|
||||||
|
"description_cn": true,
|
||||||
|
"year": true,
|
||||||
|
"season": true,
|
||||||
|
"collection_type": true,
|
||||||
|
"season_code": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&rw).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return &rw, imgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
|
clean := map[string]any{}
|
||||||
|
for k, v := range fields {
|
||||||
|
if runwayEditable[k] {
|
||||||
|
clean[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clean["updated_at"] = uint32(time.Now().Unix())
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(clean).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||||
|
var img model.BrandRunwayImage
|
||||||
|
err := r.db.WithContext(ctx).Where("id = ?", imageID).First(&img).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return img.Image, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
||||||
|
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
|
||||||
|
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
||||||
|
var imgs []model.BrandRunwayImage
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Select("image").
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
|
Find(&imgs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.Image != "" {
|
||||||
|
keys = append(keys, im.Image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Where("id = ?", runwayID).
|
||||||
|
Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -25,6 +27,18 @@ type BrandRepository interface {
|
|||||||
// 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。
|
// 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。
|
||||||
ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error)
|
ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error)
|
||||||
|
|
||||||
|
// FindByID 按数字主键取单个品牌;不存在返回 ErrNotFound。
|
||||||
|
FindByID(ctx context.Context, id uint32) (*model.Brand, error)
|
||||||
|
// Create 插入品牌,返回自增主键。
|
||||||
|
Create(ctx context.Context, b *model.Brand) (uint32, error)
|
||||||
|
// Update 按数字主键更新品牌中英文名。
|
||||||
|
Update(ctx context.Context, id uint32, nameEn, nameCn string) error
|
||||||
|
|
||||||
|
// CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。
|
||||||
|
// 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。
|
||||||
|
// brandID>0 时仅返回该品牌(对应爬虫 --brand 单品牌调试)。
|
||||||
|
CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error)
|
||||||
|
|
||||||
// PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。
|
// PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。
|
||||||
// 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。
|
// 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。
|
||||||
// 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。
|
// 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。
|
||||||
@ -176,6 +190,55 @@ func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByID 按数字主键取单个品牌;不存在返回 ErrNotFound。
|
||||||
|
func (r *brandRepository) FindByID(ctx context.Context, id uint32) (*model.Brand, error) {
|
||||||
|
var b model.Brand
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("id = ? AND is_deleted = 0", id).
|
||||||
|
First(&b).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 插入品牌,返回自增主键。
|
||||||
|
func (r *brandRepository) Create(ctx context.Context, b *model.Brand) (uint32, error) {
|
||||||
|
if err := r.db.WithContext(ctx).Create(b).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return b.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。
|
||||||
|
func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.Brand{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"name_en": nameEn,
|
||||||
|
"name_cn": nameCn,
|
||||||
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrawlBrands 返回所有可被爬虫抓取的品牌:is_deleted=0 且 name_en 非空(无英文名则无法拼 vogue 抓取 URL)。
|
||||||
|
// 不限制"是否已有走秀档案"——爬虫的职责正是补全尚无档案的品牌。
|
||||||
|
func (r *brandRepository) CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error) {
|
||||||
|
q := r.db.WithContext(ctx).Where("is_deleted = 0 AND name_en <> ''")
|
||||||
|
if brandID > 0 {
|
||||||
|
q = q.Where("id = ?", brandID)
|
||||||
|
}
|
||||||
|
var items []model.Brand
|
||||||
|
if err := q.Order("id ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。
|
// PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。
|
||||||
//
|
//
|
||||||
// 规则:
|
// 规则:
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -22,6 +24,20 @@ type StreetSnapRepository interface {
|
|||||||
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
||||||
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
||||||
Popular(ctx context.Context, limit int) ([]model.StreetSnap, error)
|
Popular(ctx context.Context, limit int) ([]model.StreetSnap, error)
|
||||||
|
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||||
|
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||||
|
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
|
||||||
|
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
|
// DeleteSnapImage 软删单张街拍图片。
|
||||||
|
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||||
|
// GetSnapImageKey 取单张街拍图片的七牛 key(删除前清理用);不存在返回空串。
|
||||||
|
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
|
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||||
|
// SetSnapCover 更新街拍封面。
|
||||||
|
SetSnapCover(ctx context.Context, snapID uint32, image string) error
|
||||||
|
// SetSnapDeleted 上下架:deleted=1 下架,0 上架。
|
||||||
|
SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type streetSnapRepository struct {
|
type streetSnapRepository struct {
|
||||||
@ -143,3 +159,103 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
|
|||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
|
||||||
|
var snapEditable = map[string]bool{
|
||||||
|
"title": true,
|
||||||
|
"year": true,
|
||||||
|
"city": true,
|
||||||
|
"cover": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||||
|
var snap model.StreetSnap
|
||||||
|
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&snap).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return &snap, imgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
|
clean := map[string]any{}
|
||||||
|
for k, v := range fields {
|
||||||
|
if snapEditable[k] {
|
||||||
|
clean[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clean["updated_at"] = uint32(time.Now().Unix())
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnap{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(clean).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||||
|
var img model.StreetSnapImage
|
||||||
|
err := r.db.WithContext(ctx).Where("id = ?", imageID).First(&img).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return img.Image, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
||||||
|
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
|
||||||
|
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
||||||
|
var imgs []model.StreetSnapImage
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Select("image").
|
||||||
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
|
Find(&imgs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.Image != "" {
|
||||||
|
keys = append(keys, im.Image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnapImage{}).
|
||||||
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnap{}).
|
||||||
|
Where("id = ?", snapID).
|
||||||
|
Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *streetSnapRepository) SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnap{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,10 @@ type UserRepository interface {
|
|||||||
FindByID(ctx context.Context, id uint32) (*model.User, error)
|
FindByID(ctx context.Context, id uint32) (*model.User, error)
|
||||||
// FindByAccount 按用户名或邮箱查找(忽略已删除账号),供登录校验。
|
// FindByAccount 按用户名或邮箱查找(忽略已删除账号),供登录校验。
|
||||||
FindByAccount(ctx context.Context, account string) (*model.User, error)
|
FindByAccount(ctx context.Context, account string) (*model.User, error)
|
||||||
|
// SetTier 设置用户等级(free/vip),供后台管理提级 VIP。
|
||||||
|
SetTier(ctx context.Context, id uint32, tier string) error
|
||||||
|
// List 列出全量用户(后台管理用),按 id 升序。
|
||||||
|
List(ctx context.Context) ([]model.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type userRepository struct {
|
type userRepository struct {
|
||||||
@ -44,6 +48,22 @@ func (r *userRepository) FindByAccount(ctx context.Context, account string) (*mo
|
|||||||
return wrapUser(&u, err)
|
return wrapUser(&u, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) SetTier(ctx context.Context, id uint32, tier string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.User{}).
|
||||||
|
Where("id = ? AND is_deleted = ?", id, 0).
|
||||||
|
Update("tier", tier).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) List(ctx context.Context) ([]model.User, error) {
|
||||||
|
var users []model.User
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("is_deleted = ?", 0).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
func wrapUser(u *model.User, err error) (*model.User, error) {
|
func wrapUser(u *model.User, err error) (*model.User, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
// Package router 负责路由注册。
|
// Package router 负责路由注册。
|
||||||
//
|
//
|
||||||
// 所有 URL 的唯一定义处 —— 想知道服务暴露了哪些接口,只看这个文件即可。
|
// 所有 URL 的唯一定义处 —— 想知道服务暴露了哪些接口,只看这个文件即可。
|
||||||
|
//
|
||||||
|
// 命名约定(2026-09-04 收敛):
|
||||||
|
// 1. 路径只允许名词(kebab-case、资源复数化),动作以「名词化子资源」表达
|
||||||
|
// (如 POST /me/favorites/checks 而非 /me/favorites/check)。
|
||||||
|
// 2. 版本前缀统一 /api/v1;健康检查不随版本演进,挂 /api/health。
|
||||||
|
// 3. /auth/* 只放「认证动作」(login/refresh/session);「我的私有资源」统一 /me/*。
|
||||||
|
// 4. 路径参数对外只讲 uid(:uid),不暴露内部字段名(旧 :target_uid 已废弃)。
|
||||||
|
// 5. SSG 构建接口属内部契约,挂 /api/internal/ssg,不复用公开 /api/v1 前缀。
|
||||||
|
// 6. 所有改名均保留旧路径别名(deprecated),便于线上旧前端平滑过渡;确认无调用后可整段删除。
|
||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -28,7 +37,7 @@ type Options struct {
|
|||||||
|
|
||||||
// New 构建 Gin 引擎并注册全部对外路由。
|
// New 构建 Gin 引擎并注册全部对外路由。
|
||||||
//
|
//
|
||||||
// 注意:SSG 内部接口(/api/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上,
|
// 注意:SSG 内部接口(/api/internal/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上,
|
||||||
// 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。
|
// 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。
|
||||||
func New(opt Options) *gin.Engine {
|
func New(opt Options) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
@ -39,65 +48,105 @@ func New(opt Options) *gin.Engine {
|
|||||||
// 前端用 toAbs() 拼上 base 后直接访问,因此这里必须对外提供静态文件。
|
// 前端用 toAbs() 拼上 base 后直接访问,因此这里必须对外提供静态文件。
|
||||||
r.Static(opt.Config.Upload.URLPrefix, opt.Config.Upload.Dir)
|
r.Static(opt.Config.Upload.URLPrefix, opt.Config.Upload.Dir)
|
||||||
|
|
||||||
|
// 健康检查:不随 API 版本演进,容器 / 反向代理探活固定打这个地址。
|
||||||
|
r.GET("/api/health", opt.Health.Check)
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// 健康检查,供容器 / 反向代理探活
|
// deprecated: 旧版探活路径,保留兼容
|
||||||
api.GET("/health", opt.Health.Check)
|
api.GET("/health", opt.Health.Check)
|
||||||
|
|
||||||
// 对外公开接口:只读查询,不暴露任何后台管理字段
|
// 对外公开接口:只读查询,不暴露任何后台管理字段。
|
||||||
|
// public = 无需 Bearer 即可访问;是否需要登录/签名由各子组决定。
|
||||||
public := api.Group("/public")
|
public := api.Group("/public")
|
||||||
|
{
|
||||||
|
// ── 批量列表 ──────────────────────────────────────────────
|
||||||
// ClientSign 仅挂在「列表 / 品牌」两个批量查询接口上:
|
// ClientSign 仅挂在「列表 / 品牌」两个批量查询接口上:
|
||||||
// 这俩是爬虫批量 dump 的主要目标,要求前端 JS 签名可抬高抓取成本。
|
// 这俩是爬虫批量 dump 的主要目标,要求前端 JS 签名可抬高抓取成本。
|
||||||
public.Use(middleware.ClientSign(opt.Config.ClientSign))
|
|
||||||
// 列表接口「首页公开、翻页需登录」:page=1(或缺省)无需 token 直接放行,
|
// 列表接口「首页公开、翻页需登录」:page=1(或缺省)无需 token 直接放行,
|
||||||
// page>1 才要求 Authorization: Bearer <token>,否则 401。
|
// page>1 才要求 Authorization: Bearer <token>,否则 401。
|
||||||
// 这样既让游客/SEO 看到首屏,又保留翻页 / 批量枚举的登录门槛(防爬虫 dump 全量)。
|
// 这样既让游客/SEO 看到首屏,又保留翻页 / 批量枚举的登录门槛(防爬虫 dump 全量)。
|
||||||
// 注意:详情接口 /api/v1/public/*/:id 不在此组内,保持公开——详情页走 SSR
|
list := public.Group("")
|
||||||
// 服务端渲染、无用户 token,且分享链接/SEO 需可访问;列表隐藏即可防批量枚举。
|
list.Use(middleware.ClientSign(opt.Config.ClientSign))
|
||||||
public.Use(middleware.PublicFirstPageAuth(opt.JWT))
|
list.Use(middleware.PublicFirstPageAuth(opt.JWT))
|
||||||
{
|
{
|
||||||
// 走秀档案列表(按品牌过滤走 ?brand_id 查询参数)
|
// 走秀档案列表(按品牌过滤走 ?brand_id 查询参数)
|
||||||
public.GET("/runway-looks", opt.Article.List)
|
list.GET("/runway-looks", opt.Article.List)
|
||||||
// 品牌列表(A-Z 索引 / 搜索)
|
// 品牌列表(A-Z 索引 / 搜索)
|
||||||
public.GET("/brands", opt.Brand.List)
|
list.GET("/brands", opt.Brand.List)
|
||||||
// 街拍列表(批量接口,受 ClientSign 保护,与 runway-looks/brands 同批)
|
// 街拍列表(批量接口,受 ClientSign 保护,与 runway-looks/brands 同批)
|
||||||
public.GET("/street-snaps", opt.StreetSnap.List)
|
list.GET("/street-snaps", opt.StreetSnap.List)
|
||||||
}
|
}
|
||||||
// 走秀详情:被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数,
|
|
||||||
|
// ── 单条详情 ──────────────────────────────────────────────
|
||||||
|
// 详情被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数,
|
||||||
// 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。
|
// 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。
|
||||||
// 挂载 PublicFirstPageAuth 仅用于「解析 Bearer 写入登录态」:游客(无 token)照常放行,
|
// 挂载 PublicFirstPageAuth 仅用于「解析 Bearer 写入登录态」:游客(无 token)照常放行,
|
||||||
// 已登录用户则拿到完整图片集(未登录截断到 PreviewLimit 张)。
|
// 已登录用户则拿到完整图片集(未登录截断到 PreviewLimit 张)。
|
||||||
api.GET("/public/runway-looks/:id", middleware.PublicFirstPageAuth(opt.JWT), opt.Article.Detail)
|
detail := public.Group("")
|
||||||
// 街拍详情:同走秀详情,单条枚举防护由 HashID 承担,不走前端签名;同样挂载 PublicFirstPageAuth 解析登录态。
|
detail.Use(middleware.PublicFirstPageAuth(opt.JWT))
|
||||||
api.GET("/public/street-snaps/:id", middleware.PublicFirstPageAuth(opt.JWT), opt.StreetSnap.Detail)
|
{
|
||||||
|
detail.GET("/runway-looks/:id", opt.Article.Detail)
|
||||||
|
detail.GET("/street-snaps/:id", opt.StreetSnap.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 账号体系:双令牌(access 短命无状态 + refresh 落库可吊销)。
|
// 账号体系:双令牌(access 短命无状态 + refresh 落库可吊销)。
|
||||||
//
|
//
|
||||||
// 登录/刷新/登出均为公开端点(无需 Bearer,否则拿不到 token 或刷新不了)。
|
// 登录/刷新/登出均为公开端点(无需 Bearer,否则拿不到 token 或刷新不了)。
|
||||||
// /me 需 Bearer(middleware.Auth);踢下线(logout-all)由 refresh token 反查用户,亦公开。
|
// /me 需 Bearer(middleware.Auth);踢下线(DELETE /auth/sessions)由 refresh token 反查用户,亦公开。
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
{
|
{
|
||||||
// 登录:公开端点,无需鉴权中间件(否则永远进不来)
|
// 登录:公开端点,无需鉴权中间件(否则永远进不来)
|
||||||
auth.POST("/login", opt.Auth.Login)
|
auth.POST("/login", opt.Auth.Login)
|
||||||
// 续期:用 refresh token 换新的 access token
|
// 续期:用 refresh token 换新的 access token
|
||||||
auth.POST("/refresh", opt.Auth.Refresh)
|
auth.POST("/refresh", opt.Auth.Refresh)
|
||||||
// 单设备登出:吊销当前 refresh token
|
// 会话即 refresh token:吊销单设备 / 全部设备
|
||||||
|
auth.DELETE("/sessions/current", opt.Auth.Logout) // 单设备登出
|
||||||
|
auth.DELETE("/sessions", opt.Auth.LogoutAll) // 全设备登出 / 踢下线
|
||||||
|
|
||||||
|
// deprecated: 旧动词式登出路径,保留兼容(新语义见 DELETE /sessions*)
|
||||||
auth.POST("/logout", opt.Auth.Logout)
|
auth.POST("/logout", opt.Auth.Logout)
|
||||||
// 全设备登出 / 踢下线:吊销该用户全部 refresh token
|
|
||||||
auth.POST("/logout-all", opt.Auth.LogoutAll)
|
auth.POST("/logout-all", opt.Auth.LogoutAll)
|
||||||
// 取当前用户(需 Bearer)
|
// deprecated: 取当前用户请改用 GET /api/v1/me
|
||||||
auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
||||||
// 收藏(需 Bearer):列表 / 新增 / 删除
|
}
|
||||||
auth.GET("/favorites", middleware.Auth(opt.JWT), opt.Favorite.List)
|
|
||||||
auth.POST("/favorites", middleware.Auth(opt.JWT), opt.Favorite.Add)
|
// 我的私有资源(需 Bearer):收藏 / 浏览历史。
|
||||||
auth.DELETE("/favorites/:target_uid", middleware.Auth(opt.JWT), opt.Favorite.Remove)
|
// 与「认证动作 /auth/*」分开:auth 只管进出,资源归属 /me/*,便于以后扩展我的资料、我的设置。
|
||||||
|
// 当前用户:GET /api/v1/me
|
||||||
|
api.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
||||||
|
me := api.Group("/me")
|
||||||
|
me.Use(middleware.Auth(opt.JWT))
|
||||||
|
{
|
||||||
|
// 收藏(列表 / 新增 / 删除 / 批量校验)
|
||||||
|
me.GET("/favorites", opt.Favorite.List)
|
||||||
|
me.POST("/favorites", opt.Favorite.Add)
|
||||||
|
me.DELETE("/favorites/:uid", opt.Favorite.Remove)
|
||||||
// 批量校验:列表页把当前可见 id 发来,问哪些已收藏(与收藏总量解耦,10万也常数级)
|
// 批量校验:列表页把当前可见 id 发来,问哪些已收藏(与收藏总量解耦,10万也常数级)
|
||||||
auth.POST("/favorites/check", middleware.Auth(opt.JWT), opt.Favorite.Check)
|
me.POST("/favorites/checks", opt.Favorite.Check)
|
||||||
// 浏览历史(需 Bearer):分页列表(可按 kind 过滤)/ 记录 / 删除 / 清空
|
|
||||||
auth.GET("/history", middleware.Auth(opt.JWT), opt.History.List)
|
// 浏览历史(分页列表 / 记录 / 删除单条 / 清空)
|
||||||
auth.POST("/history", middleware.Auth(opt.JWT), opt.History.Record)
|
me.GET("/history", opt.History.List)
|
||||||
auth.DELETE("/history/:target_uid", middleware.Auth(opt.JWT), opt.History.Remove)
|
me.POST("/history", opt.History.Record)
|
||||||
auth.DELETE("/history", middleware.Auth(opt.JWT), opt.History.Clear)
|
me.DELETE("/history/:uid", opt.History.Remove)
|
||||||
|
me.DELETE("/history", opt.History.Clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deprecated: 旧「我的资源」路径(/auth/favorites*、/auth/history*),保留兼容旧前端。
|
||||||
|
// 参数名统一 :uid(旧 :target_uid 仅路径段占位,值不变)。
|
||||||
|
legacy := api.Group("/auth")
|
||||||
|
legacy.Use(middleware.Auth(opt.JWT))
|
||||||
|
{
|
||||||
|
legacy.GET("/favorites", opt.Favorite.List)
|
||||||
|
legacy.POST("/favorites", opt.Favorite.Add)
|
||||||
|
legacy.DELETE("/favorites/:uid", opt.Favorite.Remove)
|
||||||
|
legacy.POST("/favorites/check", opt.Favorite.Check)
|
||||||
|
|
||||||
|
legacy.GET("/history", opt.History.List)
|
||||||
|
legacy.POST("/history", opt.History.Record)
|
||||||
|
legacy.DELETE("/history/:uid", opt.History.Remove)
|
||||||
|
legacy.DELETE("/history", opt.History.Clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,15 +27,26 @@ func NewSSG(opt Options) *gin.Engine {
|
|||||||
r.Use(ssgLogger())
|
r.Use(ssgLogger())
|
||||||
r.Use(middleware.SSGToken(opt.Config.Server.SSGToken))
|
r.Use(middleware.SSGToken(opt.Config.Server.SSGToken))
|
||||||
|
|
||||||
ssg := r.Group("/api/v1/ssg")
|
// 内部契约:不复用公开的 /api/v1 前缀,一眼可辨「这是构建期专用接口」。
|
||||||
|
// 旧前缀 /api/v1/ssg 在下方注册为别名,便于旧构建脚本过渡。
|
||||||
|
ssg := r.Group("/api/internal/ssg")
|
||||||
{
|
{
|
||||||
ssg.GET("/brands/hot", opt.SSG.HotBrands) // 走秀页热门品牌(前 30,热度顺序)
|
ssg.GET("/brands/popular", opt.SSG.HotBrands) // 走秀页热门品牌(前 30,热度顺序)
|
||||||
ssg.GET("/index/runway", opt.SSG.IndexRunway) // 首页(/index/)runway 区块:多模块,当前含 hotBrand
|
ssg.GET("/home/runway", opt.SSG.IndexRunway) // 首页 runway 区块:多模块,当前含 hotBrand
|
||||||
ssg.GET("/street-snaps/popular", opt.SSG.StreetSnapPopular) // 首页热门街拍(按图片数前 20)
|
ssg.GET("/street-snaps/popular", opt.SSG.StreetSnapPopular) // 首页热门街拍(按图片数前 20)
|
||||||
// 注:/street-snaps/ids(全部街拍编码 id 枚举)已移除——街拍详情页走 /item/[id] 按需 SSR,
|
// 注:/street-snaps/ids(全部街拍编码 id 枚举)已移除——街拍详情页走 /item/[id] 按需 SSR,
|
||||||
// 无需构建期枚举 id。handler/service/repository 对应实现已一并删除。
|
// 无需构建期枚举 id。handler/service/repository 对应实现已一并删除。
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deprecated: 旧 SSG 前缀与路径名(/api/v1/ssg、/index/runway、/brands/hot)
|
||||||
|
legacy := r.Group("/api/v1/ssg")
|
||||||
|
{
|
||||||
|
legacy.GET("/brands/hot", opt.SSG.HotBrands)
|
||||||
|
legacy.GET("/brands/popular", opt.SSG.HotBrands)
|
||||||
|
legacy.GET("/index/runway", opt.SSG.IndexRunway)
|
||||||
|
legacy.GET("/street-snaps/popular", opt.SSG.StreetSnapPopular)
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,27 +3,57 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
|
"fashionapi/internal/pkg/storage"
|
||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MediaCleanupEnqueuer 是「清理七牛孤儿图」任务的入队入口;由 IngestService 实现,
|
||||||
|
// 图集服务在删除图集时调用它,把同步的七牛删除改为异步队列,避免阻塞删除请求。
|
||||||
|
type MediaCleanupEnqueuer interface {
|
||||||
|
EnqueueMediaCleanup(ctx context.Context, keys []string) error
|
||||||
|
}
|
||||||
|
|
||||||
// ArticleService 文章业务接口。
|
// ArticleService 文章业务接口。
|
||||||
type ArticleService interface {
|
type ArticleService interface {
|
||||||
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
||||||
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error)
|
||||||
|
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
||||||
|
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||||
|
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||||
|
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||||
|
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
||||||
|
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
|
// DeleteImage 软删单张走秀图片。
|
||||||
|
DeleteImage(ctx context.Context, imageID uint32) error
|
||||||
|
// SetCover 更新走秀封面(URL 由调用方经校验后传入)。
|
||||||
|
SetCover(ctx context.Context, runwayID uint32, image string) error
|
||||||
|
// SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
|
||||||
|
SetDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||||
|
// PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。
|
||||||
|
PurgeGallery(ctx context.Context, id uint32) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type articleService struct {
|
type articleService struct {
|
||||||
articles repository.ArticleRepository
|
articles repository.ArticleRepository
|
||||||
|
media repository.MediaRepository
|
||||||
|
img *imgurl.Composer
|
||||||
|
deleter storage.Deleter
|
||||||
|
cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticleService 创建文章服务。
|
// NewArticleService 创建文章服务。
|
||||||
func NewArticleService(articles repository.ArticleRepository) ArticleService {
|
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
||||||
return &articleService{articles: articles}
|
// 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 查询文章列表,并按需附带每篇的前 N 张图。
|
// List 查询文章列表,并按需附带每篇的前 N 张图。
|
||||||
@ -43,7 +73,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
|||||||
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
||||||
BrandUID: hashid.Encode(r.BrandID),
|
BrandUID: hashid.Encode(r.BrandID),
|
||||||
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
||||||
Cover: r.Cover,
|
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
||||||
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
|
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
})
|
})
|
||||||
@ -57,6 +87,32 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
|||||||
return list, total, nil
|
return list, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
|
||||||
|
// 来源链接的管理字段(对外 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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
list := make([]dto.AdminRunway, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, dto.AdminRunway{
|
||||||
|
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
||||||
|
BrandUID: hashid.Encode(r.BrandID),
|
||||||
|
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"),
|
||||||
|
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
||||||
|
Year: r.Year,
|
||||||
|
Season: r.Season,
|
||||||
|
CollectionType: r.CollectionType,
|
||||||
|
SeasonCode: r.SeasonCode,
|
||||||
|
ImageCount: r.ImageCount,
|
||||||
|
SourceURL: r.SourceURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐篇请求详情。
|
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐篇请求详情。
|
||||||
// ids 是与之对应的数字主键(对外已编码为 UID,此处用数字主键查库)。
|
// ids 是与之对应的数字主键(对外已编码为 UID,此处用数字主键查库)。
|
||||||
func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArticle, ids []uint32, limit int) error {
|
func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArticle, ids []uint32, limit int) error {
|
||||||
@ -65,23 +121,24 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range list {
|
for i := range list {
|
||||||
imgs, ok := grouped[ids[i]]
|
raw, ok := grouped[ids[i]]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 缩略图条带剔除主图(cover),避免与卡片主图重复。cover 恒等于图片列表首张。
|
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||||
|
imgs := s.toPublicImages(raw, false)
|
||||||
imgs = excludeCover(imgs, list[i].Cover)
|
imgs = excludeCover(imgs, list[i].Cover)
|
||||||
if len(imgs) > limit {
|
if len(imgs) > limit {
|
||||||
imgs = imgs[:limit]
|
imgs = imgs[:limit]
|
||||||
}
|
}
|
||||||
list[i].Images = toPublicImages(imgs)
|
list[i].Images = imgs
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// excludeCover 从图片列表中去掉与主图(cover)同 URL 的元素,供列表缩略图条带使用。
|
// excludeCover 从已拼装 URL 的列表里去掉与主图(cover)同 URL 的元素,供列表缩略图条带使用。
|
||||||
// 详情接口(完整图集)不走此函数,封面作为其中一张 look 需保留。
|
// 详情接口(完整图集)不走此函数,封面作为其中一张 look 需保留。
|
||||||
func excludeCover(imgs []model.BrandRunwayImage, cover string) []model.BrandRunwayImage {
|
func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArticleImage {
|
||||||
if cover == "" {
|
if cover == "" {
|
||||||
return imgs
|
return imgs
|
||||||
}
|
}
|
||||||
@ -94,8 +151,9 @@ func excludeCover(imgs []model.BrandRunwayImage, cover string) []model.BrandRunw
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言。
|
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
||||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
||||||
|
func (s *articleService) Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) {
|
||||||
row, err := s.articles.FindByID(ctx, id)
|
row, err := s.articles.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
@ -114,17 +172,80 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
|
|||||||
BrandUID: hashid.Encode(row.BrandID),
|
BrandUID: hashid.Encode(row.BrandID),
|
||||||
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
||||||
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
||||||
Cover: row.Cover,
|
Cover: s.img.Compose(row.Cover, hd),
|
||||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||||
Images: toPublicImages(imgs),
|
SourceURL: row.SourceURL,
|
||||||
|
Images: s.toPublicImages(imgs, hd),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
|
||||||
|
return s.articles.GetForEdit(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
|
return s.articles.UpdateRunway(ctx, id, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error {
|
||||||
|
// 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。
|
||||||
|
key, err := s.articles.GetRunwayImageKey(ctx, imageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.articles.DeleteRunwayImage(ctx, imageID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *articleService) SetCover(ctx context.Context, runwayID uint32, image string) error {
|
||||||
|
return s.articles.SetRunwayCover(ctx, runwayID, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||||
|
if deleted == 1 {
|
||||||
|
// 下架图集:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。
|
||||||
|
return s.PurgeGallery(ctx, id)
|
||||||
|
}
|
||||||
|
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
|
||||||
|
return s.articles.SetRunwayDeleted(ctx, id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeGallery 下架图集并异步清理七牛孤儿图:
|
||||||
|
// 1. 级联软删其全部图片(使其退出引用计数),拿到这些图片的 key;
|
||||||
|
// 2. 把图集自身置为下架(对外不可见);
|
||||||
|
// 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删七牛文件(异步、不阻塞本请求)。
|
||||||
|
//
|
||||||
|
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||||||
|
// 图集已下架、图片已软删,七牛文件留待后续手动清理或周期性兜底扫描。
|
||||||
|
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
|
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.articles.SetRunwayDeleted(ctx, id, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(keys) > 0 && s.cleaner != nil {
|
||||||
|
if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil {
|
||||||
|
log.Printf("[warn] 下架图集 %d 入队清理七牛失败(文件将暂留): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
||||||
// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||||
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
|
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
|
||||||
func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
|
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
||||||
|
func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool) []dto.PublicArticleImage {
|
||||||
if len(imgs) == 0 {
|
if len(imgs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -132,7 +253,7 @@ func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
|
|||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
out = append(out, dto.PublicArticleImage{
|
out = append(out, dto.PublicArticleImage{
|
||||||
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
||||||
Image: im.Image,
|
Image: s.img.Compose(im.Image, hd),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
@ -31,6 +32,10 @@ type AuthService interface {
|
|||||||
Logout(ctx context.Context, refreshToken string) error
|
Logout(ctx context.Context, refreshToken string) error
|
||||||
// RevokeAllByRefresh 根据 refresh token 找到所属用户,吊销其全部 refresh(踢下线)。
|
// RevokeAllByRefresh 根据 refresh token 找到所属用户,吊销其全部 refresh(踢下线)。
|
||||||
RevokeAllByRefresh(ctx context.Context, refreshToken string) (int64, error)
|
RevokeAllByRefresh(ctx context.Context, refreshToken string) (int64, error)
|
||||||
|
// ListUsers 列出全量用户(后台管理提级 VIP 用)。
|
||||||
|
ListUsers(ctx context.Context) ([]dto.UserPayload, error)
|
||||||
|
// SetTier 设置用户等级(free/vip),供后台把某账号提级为 VIP。
|
||||||
|
SetTier(ctx context.Context, id uint32, tier string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type authService struct {
|
type authService struct {
|
||||||
@ -86,7 +91,7 @@ func (s *authService) Login(ctx context.Context, account, password string) (stri
|
|||||||
if _, err := s.refresh.RevokeAllByUser(ctx, user.ID); err != nil {
|
if _, err := s.refresh.RevokeAllByUser(ctx, user.ID); err != nil {
|
||||||
return "", "", 0, nil, internalErr("revoke stale sessions: " + err.Error())
|
return "", "", 0, nil, internalErr("revoke stale sessions: " + err.Error())
|
||||||
}
|
}
|
||||||
access, err := s.jwt.Generate(user.ID, user.Username, user.Email)
|
access, err := s.jwt.Generate(user.ID, user.Username, user.Email, user.Tier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", 0, nil, err
|
return "", "", 0, nil, err
|
||||||
}
|
}
|
||||||
@ -111,7 +116,7 @@ func (s *authService) Refresh(ctx context.Context, refreshToken string) (string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, unauthorized("invalid refresh token")
|
return "", 0, unauthorized("invalid refresh token")
|
||||||
}
|
}
|
||||||
access, err := s.jwt.Generate(user.ID, user.Username, user.Email)
|
access, err := s.jwt.Generate(user.ID, user.Username, user.Email, user.Tier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
@ -198,5 +203,29 @@ func hashToken(raw string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toUserPayload(u model.User) dto.UserPayload {
|
func toUserPayload(u model.User) dto.UserPayload {
|
||||||
return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email}
|
return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email, Tier: u.Tier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers 列出全量用户(后台管理提级 VIP 用)。
|
||||||
|
func (s *authService) ListUsers(ctx context.Context) ([]dto.UserPayload, error) {
|
||||||
|
users, err := s.users.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
out := make([]dto.UserPayload, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, toUserPayload(u))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTier 设置用户等级(free/vip),供后台把某账号提级为 VIP。
|
||||||
|
func (s *authService) SetTier(ctx context.Context, id uint32, tier string) error {
|
||||||
|
if tier != "free" && tier != "vip" {
|
||||||
|
return errors.New("tier must be free or vip")
|
||||||
|
}
|
||||||
|
if err := s.users.SetTier(ctx, id, tier); err != nil {
|
||||||
|
return internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,10 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -17,8 +20,20 @@ type BrandService interface {
|
|||||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||||
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
||||||
Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error)
|
Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error)
|
||||||
|
// Get 按编码 id 取单个品牌(后台编辑用)。不存在返回 ErrBrandNotFound。
|
||||||
|
Get(ctx context.Context, uid string) (*model.Brand, error)
|
||||||
|
// Create 新建品牌,返回编码后的 id。
|
||||||
|
Create(ctx context.Context, nameEn, nameCn string) (string, error)
|
||||||
|
// Update 按编码 id 更新品牌名称。
|
||||||
|
Update(ctx context.Context, uid, nameEn, nameCn string) error
|
||||||
|
// CrawlTasks 返回爬虫抓取所需的品牌列表(编码 brand_uid + 英文品牌名)。
|
||||||
|
// brandID>0 时仅返回该品牌(对应 --brand 单品牌调试)。
|
||||||
|
CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrBrandNotFound 品牌不存在。
|
||||||
|
var ErrBrandNotFound = NewError(http.StatusNotFound, "brand not found")
|
||||||
|
|
||||||
type brandService struct {
|
type brandService struct {
|
||||||
brands repository.BrandRepository
|
brands repository.BrandRepository
|
||||||
}
|
}
|
||||||
@ -96,3 +111,63 @@ func (s *brandService) toPublicBrands(items []model.Brand, locale string) []dto.
|
|||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get 按编码 id 取单个品牌(后台编辑用)。
|
||||||
|
func (s *brandService) Get(ctx context.Context, uid string) (*model.Brand, error) {
|
||||||
|
id, err := hashid.Decode(uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrBrandNotFound
|
||||||
|
}
|
||||||
|
b, err := s.brands.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return nil, ErrBrandNotFound
|
||||||
|
}
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 新建品牌,返回编码后的 id。
|
||||||
|
func (s *brandService) Create(ctx context.Context, nameEn, nameCn string) (string, error) {
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
id, err := s.brands.Create(ctx, &model.Brand{
|
||||||
|
NameEn: nameEn,
|
||||||
|
NameCn: nameCn,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return hashid.Encode(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 按编码 id 更新品牌名称。
|
||||||
|
func (s *brandService) Update(ctx context.Context, uid, nameEn, nameCn string) error {
|
||||||
|
id, err := hashid.Decode(uid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrBrandNotFound
|
||||||
|
}
|
||||||
|
if err := s.brands.Update(ctx, id, nameEn, nameCn); err != nil {
|
||||||
|
return internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。
|
||||||
|
// 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。
|
||||||
|
func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
||||||
|
brands, err := s.brands.CrawlBrands(ctx, brandID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
out := make([]dto.CrawlBrand, 0, len(brands))
|
||||||
|
for _, b := range brands {
|
||||||
|
out = append(out, dto.CrawlBrand{
|
||||||
|
BrandUID: hashid.Encode(b.ID),
|
||||||
|
Name: b.NameEn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,11 +28,13 @@ type IndexRunwayData struct {
|
|||||||
|
|
||||||
type indexService struct {
|
type indexService struct {
|
||||||
brands repository.BrandRepository
|
brands repository.BrandRepository
|
||||||
|
img *imgurl.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIndexService 创建首页数据服务。
|
// NewIndexService 创建首页数据服务。
|
||||||
func NewIndexService(brands repository.BrandRepository) IndexService {
|
// img 负责把库里存储的七牛 key 拼装为可访问 URL(首页统一普通图)。
|
||||||
return &indexService{brands: brands}
|
func NewIndexService(brands repository.BrandRepository, img *imgurl.Composer) IndexService {
|
||||||
|
return &indexService{brands: brands, img: img}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runway 首页 runway 区块数据。
|
// Runway 首页 runway 区块数据。
|
||||||
@ -52,7 +55,7 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa
|
|||||||
ArticleUID: hashid.Encode(b.ArticleID),
|
ArticleUID: hashid.Encode(b.ArticleID),
|
||||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||||
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
||||||
Cover: b.Cover,
|
Cover: s.img.Compose(b.Cover, false), // 首页封面统一普通图
|
||||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,29 +3,51 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
|
"fashionapi/internal/pkg/storage"
|
||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StreetSnapService 街拍业务接口。
|
// StreetSnapService 街拍业务接口。
|
||||||
type StreetSnapService interface {
|
type StreetSnapService interface {
|
||||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||||
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error)
|
||||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||||
|
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||||
|
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||||
|
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||||
|
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
|
// DeleteImage 软删单张街拍图片。
|
||||||
|
DeleteImage(ctx context.Context, imageID uint32) error
|
||||||
|
// SetCover 更新街拍封面。
|
||||||
|
SetCover(ctx context.Context, snapID uint32, image string) error
|
||||||
|
// SetDeleted 上下架:deleted=1 下架,0 上架。
|
||||||
|
SetDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||||
|
// PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。
|
||||||
|
PurgeGallery(ctx context.Context, id uint32) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type streetSnapService struct {
|
type streetSnapService struct {
|
||||||
repo repository.StreetSnapRepository
|
repo repository.StreetSnapRepository
|
||||||
|
media repository.MediaRepository
|
||||||
|
img *imgurl.Composer
|
||||||
|
deleter storage.Deleter
|
||||||
|
cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStreetSnapService 创建街拍服务。
|
// NewStreetSnapService 创建街拍服务。
|
||||||
func NewStreetSnapService(repo repository.StreetSnapRepository) StreetSnapService {
|
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
||||||
return &streetSnapService{repo: repo}
|
// 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrStreetSnapNotFound 街拍不存在。
|
// ErrStreetSnapNotFound 街拍不存在。
|
||||||
@ -47,7 +69,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Cover: r.Cover,
|
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -67,22 +89,23 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range list {
|
for i := range list {
|
||||||
imgs, ok := grouped[ids[i]]
|
raw, ok := grouped[ids[i]]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 缩略图条带剔除主图(cover),避免与卡片主图重复。cover 恒等于图片列表首张。
|
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||||
|
imgs := s.toPublicSnapImages(raw, false)
|
||||||
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
||||||
if len(imgs) > limit {
|
if len(imgs) > limit {
|
||||||
imgs = imgs[:limit]
|
imgs = imgs[:limit]
|
||||||
}
|
}
|
||||||
list[i].Images = toPublicSnapImages(imgs)
|
list[i].Images = imgs
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// excludeCoverSnap 同 excludeCover,作用于街拍图片列表。
|
// excludeCoverSnap 同 excludeCover,作用于已拼装 URL 的街拍图片列表。
|
||||||
func excludeCoverSnap(imgs []model.StreetSnapImage, cover string) []model.StreetSnapImage {
|
func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicArticleImage {
|
||||||
if cover == "" {
|
if cover == "" {
|
||||||
return imgs
|
return imgs
|
||||||
}
|
}
|
||||||
@ -96,7 +119,8 @@ func excludeCoverSnap(imgs []model.StreetSnapImage, cover string) []model.Street
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询街拍详情,含完整图片集。
|
// Detail 查询街拍详情,含完整图片集。
|
||||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
||||||
|
func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
|
||||||
snap, err := s.repo.FindByID(ctx, id)
|
snap, err := s.repo.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
@ -113,8 +137,8 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
|
|||||||
return &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
||||||
Title: snap.Title,
|
Title: snap.Title,
|
||||||
Cover: snap.Cover,
|
Cover: s.img.Compose(snap.Cover, hd),
|
||||||
Images: toPublicSnapImages(imgs),
|
Images: s.toPublicSnapImages(imgs, hd),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +154,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
|||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Cover: r.Cover,
|
Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -139,7 +163,8 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
|||||||
|
|
||||||
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||||
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
||||||
func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
||||||
|
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []dto.PublicArticleImage {
|
||||||
if len(imgs) == 0 {
|
if len(imgs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -147,9 +172,65 @@ func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
|||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
out = append(out, dto.PublicArticleImage{
|
out = append(out, dto.PublicArticleImage{
|
||||||
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||||||
Image: im.Image,
|
Image: s.img.Compose(im.Image, hd),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||||
|
return s.repo.GetForEdit(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
|
return s.repo.UpdateSnap(ctx, id, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) error {
|
||||||
|
// 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。
|
||||||
|
key, err := s.repo.GetSnapImageKey(ctx, imageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.DeleteSnapImage(ctx, imageID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *streetSnapService) SetCover(ctx context.Context, snapID uint32, image string) error {
|
||||||
|
return s.repo.SetSnapCover(ctx, snapID, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||||
|
if deleted == 1 {
|
||||||
|
// 下架街拍:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。
|
||||||
|
return s.PurgeGallery(ctx, id)
|
||||||
|
}
|
||||||
|
// 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。
|
||||||
|
return s.repo.SetSnapDeleted(ctx, id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeGallery 下架街拍并异步清理七牛孤儿图:逻辑同 articleService.PurgeGallery,
|
||||||
|
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
||||||
|
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
|
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.SetSnapDeleted(ctx, id, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(keys) > 0 && s.cleaner != nil {
|
||||||
|
if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil {
|
||||||
|
log.Printf("[warn] 下架街拍 %d 入队清理七牛失败(文件将暂留): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -1,206 +0,0 @@
|
|||||||
// Command backfill_city 给 street_snap 加 city 列并从 title(首选)/ source_url(兜底)回填。
|
|
||||||
//
|
|
||||||
// 项目已移除 AutoMigrate,表结构由外部 SQL 托管(见 scripts/sql/004_add_streetsnap_city.sql)。
|
|
||||||
// 本程序是「没有 mysql 客户端时」的执行器:
|
|
||||||
// - ALTER 走 ensureColumn:先查 information_schema,缺才加,可安全重跑(幂等)。
|
|
||||||
// - 回填:优先从 title 第一段(location,\t 分隔)按城市关键词匹配;
|
|
||||||
// 命中不到再退到 source_url / cover 的 URL slug 解析。幂等(已填过的不会覆盖)。
|
|
||||||
//
|
|
||||||
// 存储的 city 值即「规范英文名首字母大写」(Paris / New York …),
|
|
||||||
// 与前端街拍列表 City 筛选项 value、header 下拉 ?city= 参数完全一致(精确匹配)。
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dsn = "root:root@tcp(127.0.0.1:3306)/db_dev?charset=utf8mb4&parseTime=True&loc=Local"
|
|
||||||
|
|
||||||
// cityKeywords 城市关键词 → 规范存储值(首字母大写)。
|
|
||||||
// 顺序无关;extractCityFromTitle 用「包含」匹配,可容忍 "Paris Fashion Week" / "New York FW25" 等形式。
|
|
||||||
var cityKeywords = []struct {
|
|
||||||
kw string
|
|
||||||
city string
|
|
||||||
}{
|
|
||||||
{"paris", "Paris"},
|
|
||||||
{"milan", "Milan"},
|
|
||||||
{"london", "London"},
|
|
||||||
{"new york", "New York"},
|
|
||||||
{"nyfw", "New York"},
|
|
||||||
{"tokyo", "Tokyo"},
|
|
||||||
{"seoul", "Seoul"},
|
|
||||||
{"florence", "Florence"},
|
|
||||||
{"shanghai", "Shanghai"},
|
|
||||||
{"copenhagen", "Copenhagen"},
|
|
||||||
{"berlin", "Berlin"},
|
|
||||||
{"rome", "Rome"},
|
|
||||||
{"madrid", "Madrid"},
|
|
||||||
{"amsterdam", "Amsterdam"},
|
|
||||||
{"antwerp", "Antwerp"},
|
|
||||||
{"los angeles", "Los Angeles"},
|
|
||||||
{"moscow", "Moscow"},
|
|
||||||
{"mumbai", "Mumbai"},
|
|
||||||
{"beijing", "Beijing"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// citySlug(URL 兜底用):slug → 规范存储值。多词城市(new-york)必须排在单词组之前被 HasPrefix 命中。
|
|
||||||
var citySlug = map[string]string{
|
|
||||||
"paris": "Paris",
|
|
||||||
"milan": "Milan",
|
|
||||||
"london": "London",
|
|
||||||
"new-york": "New York",
|
|
||||||
"tokyo": "Tokyo",
|
|
||||||
"seoul": "Seoul",
|
|
||||||
"copenhagen": "Copenhagen",
|
|
||||||
"florence": "Florence",
|
|
||||||
"berlin": "Berlin",
|
|
||||||
"shanghai": "Shanghai",
|
|
||||||
"beijing": "Beijing",
|
|
||||||
"moscow": "Moscow",
|
|
||||||
"los-angeles": "Los Angeles",
|
|
||||||
"rome": "Rome",
|
|
||||||
"madrid": "Madrid",
|
|
||||||
"amsterdam": "Amsterdam",
|
|
||||||
"antwerp": "Antwerp",
|
|
||||||
"mumbai": "Mumbai",
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractCityFromTitle 从 title 第一段(location,\t 分隔)按关键词匹配规范城市名。
|
|
||||||
func extractCityFromTitle(title string) string {
|
|
||||||
seg := title
|
|
||||||
if i := strings.Index(title, "\t"); i >= 0 {
|
|
||||||
seg = title[:i]
|
|
||||||
}
|
|
||||||
segL := strings.ToLower(strings.TrimSpace(seg))
|
|
||||||
if segL == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, c := range cityKeywords {
|
|
||||||
if strings.Contains(segL, c.kw) {
|
|
||||||
return c.city
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractCityFromURL 兜底:从 source_url / cover 的 URL 第一段 slug 解析城市。
|
|
||||||
func extractCityFromURL(raws ...string) string {
|
|
||||||
for _, raw := range raws {
|
|
||||||
u := strings.TrimSpace(raw)
|
|
||||||
if u == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i := strings.Index(u, "://"); i >= 0 {
|
|
||||||
u = u[i+3:]
|
|
||||||
}
|
|
||||||
if i := strings.Index(u, "/"); i >= 0 {
|
|
||||||
u = u[i+1:]
|
|
||||||
}
|
|
||||||
u = strings.Trim(u, "/")
|
|
||||||
if u == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seg := strings.SplitN(u, "/", 2)[0]
|
|
||||||
segL := strings.ToLower(seg)
|
|
||||||
for slug, city := range citySlug {
|
|
||||||
if segL == slug || strings.HasPrefix(segL, slug+"-") {
|
|
||||||
return city
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("open:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) 幂等加列(与 004_add_streetsnap_city.sql 对齐)
|
|
||||||
ensureColumn(db, "street_snap", "city", "VARCHAR(128) NOT NULL DEFAULT '' AFTER year")
|
|
||||||
|
|
||||||
// 2) 逐行回填:优先 title 第一段,兜底 URL slug
|
|
||||||
type row struct {
|
|
||||||
ID uint32
|
|
||||||
Title string
|
|
||||||
SourceURL string
|
|
||||||
Cover string
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
if r := db.Raw("SELECT id, title, source_url, cover FROM street_snap WHERE is_deleted=0 AND (city IS NULL OR city='')").Scan(&rows); r.Error != nil {
|
|
||||||
log.Fatal("select:", r.Error)
|
|
||||||
}
|
|
||||||
var updated int64
|
|
||||||
for _, r := range rows {
|
|
||||||
city := extractCityFromTitle(r.Title)
|
|
||||||
if city == "" {
|
|
||||||
city = extractCityFromURL(r.SourceURL, r.Cover)
|
|
||||||
}
|
|
||||||
if city == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r2 := db.Exec("UPDATE street_snap SET city=? WHERE id=?", city, r.ID); r2.Error != nil {
|
|
||||||
log.Printf("update id=%d failed: %v", r.ID, r2.Error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
updated++
|
|
||||||
}
|
|
||||||
fmt.Printf(" updated city for %d rows\n", updated)
|
|
||||||
|
|
||||||
// 3) 校验:输出 distinct city 分布 + 未归类样本
|
|
||||||
printCities(db)
|
|
||||||
printUnclassifiedSamples(db)
|
|
||||||
fmt.Println("backfill city done.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureColumn(db *gorm.DB, table, col, def string) {
|
|
||||||
var n int64
|
|
||||||
db.Raw("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='db_dev' AND TABLE_NAME=? AND COLUMN_NAME=?", table, col).Scan(&n)
|
|
||||||
if n > 0 {
|
|
||||||
fmt.Printf(" skip %s.%s (exists)\n", table, col)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stmt := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def)
|
|
||||||
fmt.Println(" +", stmt)
|
|
||||||
if r := db.Exec(stmt); r.Error != nil {
|
|
||||||
log.Fatalf("alter %s.%s failed: %v", table, col, r.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printCities(db *gorm.DB) {
|
|
||||||
type row struct {
|
|
||||||
City string
|
|
||||||
Cnt int64
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
db.Raw(`SELECT city, COUNT(*) cnt FROM street_snap WHERE is_deleted=0 AND city<>'' GROUP BY city ORDER BY cnt DESC`).Scan(&rows)
|
|
||||||
fmt.Printf(" distinct cities: %d\n", len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
fmt.Printf(" %-14q %d\n", r.City, r.Cnt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// printUnclassifiedSamples 输出仍未归类行的 title 第一段样本,便于后续补充城市词典。
|
|
||||||
func printUnclassifiedSamples(db *gorm.DB) {
|
|
||||||
type row struct {
|
|
||||||
Title string
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
db.Raw(`SELECT title FROM street_snap WHERE is_deleted=0 AND (city IS NULL OR city='') LIMIT 25`).Scan(&rows)
|
|
||||||
var total int64
|
|
||||||
db.Raw(`SELECT COUNT(*) FROM street_snap WHERE is_deleted=0 AND (city IS NULL OR city='')`).Scan(&total)
|
|
||||||
fmt.Printf(" unclassified rows: %d (sample locations below)\n", total)
|
|
||||||
for _, r := range rows {
|
|
||||||
loc := r.Title
|
|
||||||
if i := strings.Index(loc, "\t"); i >= 0 {
|
|
||||||
loc = loc[:i]
|
|
||||||
}
|
|
||||||
fmt.Printf(" loc=%-30q title=%-50q\n", loc, r.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
// Command backfill_title_cn 把 brand_runway.title_cn 由已解析的结构化分类字段
|
|
||||||
// (year / collection_type / season)规则生成中文走秀标题。
|
|
||||||
//
|
|
||||||
// 设计:
|
|
||||||
// - title 字段本质就是「季节/品类描述」(Vogue 格式:Spring 2026 Ready-to-Wear、
|
|
||||||
// Resort 2026、Pre-Fall 2023、Fall 2023 Couture、Spring 2024 Menswear),
|
|
||||||
// 品牌名在 JOIN 出的 brand_name 里单独给,不混进 title。
|
|
||||||
// - collection_type / season 早已由历史迁移解析回填,因此 title_cn 可纯规则生成,
|
|
||||||
// 完全不依赖外部翻译服务,且结果稳定可重跑。
|
|
||||||
//
|
|
||||||
// 幂等:每次都按分类字段重新计算并整表覆盖,重跑无害。
|
|
||||||
// 不写任何业务逻辑,仅做一次性数据回填。
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"
|
|
||||||
|
|
||||||
// titleCnSQL 单条 UPDATE 用 CASE 把分类字段映射为中文标题。
|
|
||||||
// year 参与拼接(CONCAT),season 为 NULL 时 COALESCE 成 ''。
|
|
||||||
const titleCnSQL = `
|
|
||||||
UPDATE brand_runway
|
|
||||||
SET title_cn = CASE
|
|
||||||
WHEN collection_type = 'resort' THEN CONCAT(year, ' 度假系列')
|
|
||||||
WHEN collection_type = 'pre_fall' THEN CONCAT(year, ' 早秋系列')
|
|
||||||
WHEN collection_type = 'rtw' AND season = 'spring' THEN CONCAT(year, ' 春夏成衣')
|
|
||||||
WHEN collection_type = 'rtw' AND season = 'fall' THEN CONCAT(year, ' 秋冬成衣')
|
|
||||||
WHEN collection_type = 'rtw' THEN CONCAT(year, ' 成衣')
|
|
||||||
WHEN collection_type = 'menswear' AND season = 'spring' THEN CONCAT(year, ' 春夏男装')
|
|
||||||
WHEN collection_type = 'menswear' AND season = 'fall' THEN CONCAT(year, ' 秋冬男装')
|
|
||||||
WHEN collection_type = 'couture' AND season = 'spring' THEN CONCAT(year, ' 春夏高定')
|
|
||||||
WHEN collection_type = 'couture' AND season = 'fall' THEN CONCAT(year, ' 秋冬高定')
|
|
||||||
ELSE CONCAT(year, ' 系列')
|
|
||||||
END
|
|
||||||
WHERE is_deleted = 0`
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("open:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var before int64
|
|
||||||
db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND title_cn <> ''`).Scan(&before)
|
|
||||||
fmt.Printf("before: title_cn 非空 = %d\n", before)
|
|
||||||
|
|
||||||
if r := db.Exec(titleCnSQL); r.Error != nil {
|
|
||||||
log.Fatalf("backfill title_cn failed: %v", r.Error)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("updated rows = %d\n", r.RowsAffected)
|
|
||||||
}
|
|
||||||
|
|
||||||
var after int64
|
|
||||||
db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND title_cn <> ''`).Scan(&after)
|
|
||||||
fmt.Printf("after: title_cn 非空 = %d\n", after)
|
|
||||||
|
|
||||||
// 抽样核对:原始 title -> 生成 title_cn
|
|
||||||
type row struct {
|
|
||||||
Title string
|
|
||||||
TitleCn string
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
db.Raw(`SELECT title, title_cn FROM brand_runway WHERE is_deleted=0 ORDER BY id LIMIT 12`).Scan(&rows)
|
|
||||||
fmt.Println("samples:")
|
|
||||||
for _, r := range rows {
|
|
||||||
fmt.Printf(" %-32q -> %q\n", r.Title, r.TitleCn)
|
|
||||||
}
|
|
||||||
fmt.Println("title_cn backfill done.")
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
// Command i18n_migrate 落地方案 A 的 i18n 列(加列 + 回填)。
|
|
||||||
//
|
|
||||||
// 项目已移除 AutoMigrate,表结构由外部 SQL 托管(见 scripts/sql/002_i18n_columns.sql,
|
|
||||||
// 那是给人 review 的规范底稿)。本程序是「没有 mysql 客户端时」的执行器:
|
|
||||||
// - ALTER 走 ensureColumn:先查 information_schema,缺才加,可安全重跑(幂等)。
|
|
||||||
// - 回填 UPDATE 内联且幂等,重跑无害。
|
|
||||||
//
|
|
||||||
// 不写任何业务逻辑。
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"
|
|
||||||
const han = "[一-鿿]"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("open:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) 幂等加列(与 002_i18n_columns.sql 对齐)
|
|
||||||
ensureColumn(db, "brand", "name_en", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name")
|
|
||||||
ensureColumn(db, "brand", "name_cn", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name_en")
|
|
||||||
ensureColumn(db, "brand_runway", "title_en", "VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
|
||||||
ensureColumn(db, "brand_runway", "title_cn", "VARCHAR(255) NOT NULL DEFAULT '' AFTER title_en")
|
|
||||||
ensureColumn(db, "brand_runway", "description_en", "VARCHAR(1024) NOT NULL DEFAULT '' AFTER description")
|
|
||||||
ensureColumn(db, "brand_runway", "description_cn", "VARCHAR(1024) NOT NULL DEFAULT '' AFTER description_en")
|
|
||||||
|
|
||||||
// 2) 回填品牌名:从 name / show_name 拆拉丁与中文(幂等)
|
|
||||||
db.Exec(fmt.Sprintf(`
|
|
||||||
UPDATE brand SET
|
|
||||||
name_en = CASE
|
|
||||||
WHEN TRIM(REGEXP_REPLACE(name, '%s', '')) <> '' THEN TRIM(REGEXP_REPLACE(name, '%s', ''))
|
|
||||||
WHEN show_name LIKE '%%/%%' THEN TRIM(SUBSTRING_INDEX(show_name, '/', 1))
|
|
||||||
ELSE ''
|
|
||||||
END,
|
|
||||||
name_cn = CASE
|
|
||||||
WHEN show_name LIKE '%%/%%' AND SUBSTRING_INDEX(show_name, '/', -1) REGEXP '%s'
|
|
||||||
THEN TRIM(SUBSTRING_INDEX(show_name, '/', -1))
|
|
||||||
WHEN show_name REGEXP '%s' THEN TRIM(show_name)
|
|
||||||
WHEN name REGEXP '%s' THEN TRIM(name)
|
|
||||||
ELSE ''
|
|
||||||
END`, han, han, han, han, han))
|
|
||||||
|
|
||||||
// 3) 回填走秀:title / description 当前全英文,直接进 *_en(幂等)
|
|
||||||
db.Exec(`UPDATE brand_runway SET title_en = title, description_en = description, title_cn = '', description_cn = ''`)
|
|
||||||
|
|
||||||
// 4) 校验
|
|
||||||
printCounts(db)
|
|
||||||
fmt.Println("migration done.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureColumn(db *gorm.DB, table, col, def string) {
|
|
||||||
var n int64
|
|
||||||
db.Raw("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='db' AND TABLE_NAME=? AND COLUMN_NAME=?", table, col).Scan(&n)
|
|
||||||
if n > 0 {
|
|
||||||
fmt.Printf(" skip %s.%s (exists)\n", table, col)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stmt := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def)
|
|
||||||
fmt.Println(" +", stmt)
|
|
||||||
if r := db.Exec(stmt); r.Error != nil {
|
|
||||||
log.Fatalf("alter %s.%s failed: %v", table, col, r.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printCounts(db *gorm.DB) {
|
|
||||||
type c struct {
|
|
||||||
Tot, En, Cn, Both, Dcn int64
|
|
||||||
}
|
|
||||||
var b c
|
|
||||||
db.Raw(`SELECT COUNT(*) tot,
|
|
||||||
SUM(CASE WHEN name_en<>'' THEN 1 ELSE 0 END) en,
|
|
||||||
SUM(CASE WHEN name_cn<>'' THEN 1 ELSE 0 END) cn,
|
|
||||||
SUM(CASE WHEN name_en<>'' AND name_cn<>'' THEN 1 ELSE 0 END) both_cnt
|
|
||||||
FROM brand`).Scan(&b)
|
|
||||||
fmt.Printf(" brand: total=%d name_en=%d name_cn=%d both=%d\n", b.Tot, b.En, b.Cn, b.Both)
|
|
||||||
|
|
||||||
var r c
|
|
||||||
db.Raw(`SELECT COUNT(*) tot,
|
|
||||||
SUM(CASE WHEN title_en<>'' THEN 1 ELSE 0 END) en,
|
|
||||||
SUM(CASE WHEN title_cn<>'' THEN 1 ELSE 0 END) cn,
|
|
||||||
SUM(CASE WHEN description_cn<>'' THEN 1 ELSE 0 END) dcn
|
|
||||||
FROM brand_runway WHERE is_deleted=0`).Scan(&r)
|
|
||||||
fmt.Printf(" runway: total=%d title_en=%d title_cn=%d desc_cn=%d\n", r.Tot, r.En, r.Cn, r.Dcn)
|
|
||||||
|
|
||||||
type s struct {
|
|
||||||
Name string
|
|
||||||
ShowName string
|
|
||||||
NameEn string
|
|
||||||
NameCn string
|
|
||||||
}
|
|
||||||
var samples []s
|
|
||||||
db.Raw("SELECT name, show_name, name_en, name_cn FROM brand WHERE name REGEXP ? OR show_name REGEXP ? ORDER BY id LIMIT 8", han, han).Scan(&samples)
|
|
||||||
fmt.Println(" brand samples:")
|
|
||||||
for _, x := range samples {
|
|
||||||
fmt.Printf(" name=%-18q show=%-26q -> en=%-18q cn=%-18q\n", x.Name, x.ShowName, x.NameEn, x.NameCn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
// Command migrate_refresh 一次性建表脚本(与 scripts/sql/*.sql 同源,手写 DDL 以保持与项目"SQL 托管表结构"约定一致)。
|
|
||||||
//
|
|
||||||
// 运行:go run ./scripts/migrate_refresh
|
|
||||||
// 可用环境变量 DB_DSN 覆盖连接串(默认 root:root@127.0.0.1:3306/db_dev)。
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dsn := os.Getenv("DB_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
dsn = "root:root@tcp(127.0.0.1:3306)/db_dev?charset=utf8mb4&parseTime=True&loc=Local"
|
|
||||||
}
|
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
stmt := `CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
user_id INT UNSIGNED NOT NULL,
|
|
||||||
token_hash VARCHAR(64) NOT NULL,
|
|
||||||
expires_at INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
revoked TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
user_agent VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
ip VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
created_at INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_token_hash (token_hash),
|
|
||||||
KEY idx_user_id (user_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
|
|
||||||
if err := db.Exec(stmt).Error; err != nil {
|
|
||||||
log.Fatalf("create refresh_tokens: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("✓ refresh_tokens ready")
|
|
||||||
|
|
||||||
stmtFav := `CREATE TABLE IF NOT EXISTS favorites (
|
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
created_at INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
user_id INT UNSIGNED NOT NULL,
|
|
||||||
target_type VARCHAR(16) NOT NULL DEFAULT '',
|
|
||||||
target_uid VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
title VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
cover VARCHAR(512) NOT NULL DEFAULT '',
|
|
||||||
brand VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uniq_user_target (user_id, target_uid),
|
|
||||||
KEY idx_user (user_id),
|
|
||||||
KEY idx_user_created (user_id, created_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
|
|
||||||
if err := db.Exec(stmtFav).Error; err != nil {
|
|
||||||
log.Fatalf("create favorites: %v", err)
|
|
||||||
}
|
|
||||||
// 复合索引(user_id, created_at):优化收藏分页深翻页(10万收藏场景);已存在则忽略报错
|
|
||||||
if err := db.Exec("ALTER TABLE favorites ADD INDEX idx_user_created (user_id, created_at)").Error; err != nil {
|
|
||||||
log.Printf("ℹ favorites idx_user_created 已存在,可忽略: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("✓ favorites ready")
|
|
||||||
|
|
||||||
// histories:文章级浏览历史(2026-09-02 收敛)。
|
|
||||||
// 仅存 user_id + target_uid(带 r=/s= 前缀的对外编码串,前缀即类型)+ viewed_at;
|
|
||||||
// kind/target_type/title/cover/brand 快照已弃用(账户页展示时回查公开详情接口)。
|
|
||||||
stmtHist := `CREATE TABLE IF NOT EXISTS histories (
|
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
viewed_at INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
user_id INT UNSIGNED NOT NULL,
|
|
||||||
target_uid VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uniq_user_target (user_id, target_uid),
|
|
||||||
KEY idx_user_viewed (user_id, viewed_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
|
|
||||||
if err := db.Exec(stmtHist).Error; err != nil {
|
|
||||||
log.Fatalf("create histories: %v", err)
|
|
||||||
}
|
|
||||||
// —— 存量库收敛(幂等):清掉旧固定串行(runway-looks/street-snaps),卸掉旧列与旧唯一键,换上新的 ——
|
|
||||||
// 旧结构列/键可能不存在(新库)或已收敛(二次执行),每条失败仅提示、不中断。
|
|
||||||
trimHist := []string{
|
|
||||||
"DELETE FROM histories WHERE target_uid NOT LIKE 'r=%' AND target_uid NOT LIKE 's=%'",
|
|
||||||
"ALTER TABLE histories DROP KEY uniq_user_kind_target",
|
|
||||||
"ALTER TABLE histories DROP COLUMN kind",
|
|
||||||
"ALTER TABLE histories DROP COLUMN target_type",
|
|
||||||
"ALTER TABLE histories DROP COLUMN title",
|
|
||||||
"ALTER TABLE histories DROP COLUMN cover",
|
|
||||||
"ALTER TABLE histories DROP COLUMN brand",
|
|
||||||
"ALTER TABLE histories ADD UNIQUE KEY uniq_user_target (user_id, target_uid)",
|
|
||||||
}
|
|
||||||
for _, stmt := range trimHist {
|
|
||||||
if err := db.Exec(stmt).Error; err != nil {
|
|
||||||
log.Printf("ℹ histories 收敛步骤可忽略(可能已收敛/新库): %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Println("✓ histories ready (article-level: user_id+target_uid+viewed_at)")
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user