Files
backend_v2/cmd/server/main.go
toom1996 151e09ab30 feat: 抓取任务队列 + 媒体机直传 + 向量回填
crawl(品牌抓取任务队列)
- /admin/internal/crawl/tasks 领取 / :id/result 回报,spider 可常驻拉任务
- 后台品牌列表支持「加入抓取队列」,队列页可看状态与重试
- 租约超时的 running 自动回收为 queued,避免 worker 崩溃后任务卡死

media(媒体机直传)
- ingest 载荷新增 main_key/detail_keys/main_size/detail_sizes:
  有 key 就直接落库,跳过下载与上传;与 URL 形态可逐图混用
- 细节图按 Details/DetailKeys 两者较长者遍历(只给 key 时不再丢图)
- 媒体机上传的 key 不进失败回滚清单(内容寻址、可能与他人共享)
- /admin/internal/upload-url 签发 S4 PUT 预签名(spider 不持有 AK/SK)

embeddings(向量回填)
- GET /admin/internal/embeddings/pending 派发缺向量的图(含可直接下载的 url)
- POST /admin/internal/embeddings 回写向量并重算近邻;失败项计入
  image_embedding_failures,连续失败到上限后退出队列(坏图不占位)
- 近邻重算改为异步串行:同步会让 100 张的批次 POST 超时(实测 >60s)
- 新增 key 分支单测:混用 key/url、细节图只给 key、字节数、回滚清单、向量占位
2026-10-25 00:37:13 +08:00

332 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Command server 启动 Fashion 对外 API 服务。
//
// 本文件是唯一的依赖装配点(组合根):配置 → 数据库 → 仓储 → 服务 → 处理器 → 路由。
// 依赖自下而上显式传入,不使用包级全局变量,便于替换实现与编写测试。
package main
import (
"context"
"errors"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"fashionapi/internal/config"
"fashionapi/internal/database"
"fashionapi/internal/handler"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/imgurl"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
"fashionapi/internal/router"
"fashionapi/internal/service"
"fashionapi/internal/vision"
"github.com/gin-gonic/gin"
)
// s4BaseURL 取 S4 对外访问域名:优先用上传器自动推导值(保证 free/vip 同 host),否则用配置值。
func s4BaseURL(cfgBase string, up *storage.S4Uploader) string {
if up != nil && up.Enabled() {
if b := up.PublicBaseURL(); b != "" {
return b
}
}
return cfgBase
}
// setupLogger 根据运行模式初始化结构化日志(JSON 输出,便于集中采集)。
func setupLogger(mode string) {
level := slog.LevelDebug
switch mode {
case "release":
level = slog.LevelInfo
case "test":
level = slog.LevelWarn
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
}
func main() {
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
flag.Parse()
// 1. 加载配置(yml 为主,环境变量可覆盖)
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("加载配置失败", "error", err)
os.Exit(1)
}
// 缺失的必填/推荐敏感配置:本地 TTY 交互提示补填(写回 config.local.yml),
// 容器/CI 等非交互环境缺失必填项则直接报错退出并提示用环境变量注入。
if err := config.PromptMissing(cfg); err != nil {
slog.Error("启动前校验失败", "error", err)
os.Exit(1)
}
gin.SetMode(cfg.Server.Mode)
setupLogger(cfg.Server.Mode)
if from := cfg.LoadedFrom(); from != "" {
slog.Info("配置文件", "path", from)
} else {
slog.Warn("未找到配置文件,使用默认值与环境变量")
}
// 2. 连接数据库
db, err := database.New(cfg.Database)
if err != nil {
slog.Error("数据库连接失败", "error", err)
os.Exit(1)
}
defer func() {
if err := database.Close(db); err != nil {
slog.Error("关闭数据库连接失败", "error", err)
}
}()
// 2.5 结构不再随启动自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的
// 纯 SQL 维护(dbtool dump → psql -f)。这里只探活,不执行任何 DDL。
slog.Info("PostgreSQL 已连接", "addr", cfg.Database.Addr())
// 3. 确保上传目录存在(静态文件服务的根目录)
uploadDir, _ := filepath.Abs(cfg.Upload.Dir)
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
slog.Error("创建上传目录失败", "error", err)
os.Exit(1)
}
slog.Info("静态资源", "prefix", cfg.Upload.URLPrefix, "dir", uploadDir)
// 4.5 初始化公开 ID 混淆(HashID)。
// 对外接口的 id / brand_id 一律用 hashid 编码串,内部仍用数字主键;
// 部署可用 HASHID_SECRET 提供独立盐值(默认走内置盐)。
hashid.Init(cfg.Server.HashIDSecret)
// 5. 装配各层依赖
var (
articleRepo = repository.NewArticleRepository(db)
brandRepo = repository.NewBrandRepository(db)
userRepo = repository.NewUserRepository(db)
snapRepo = repository.NewStreetSnapRepository(db)
refreshRepo = repository.NewRefreshTokenRepository(db)
favRepo = repository.NewFavoriteRepository(db)
histRepo = repository.NewHistoryRepository(db)
ingestRepo = repository.NewIngestRepository(db)
reviewRepo = repository.NewReviewRepository(db)
// 文章管理混合列表(跨 brand_runways / street_snaps 的统一只读查询)
adminRepo = repository.NewAdminRepository(db)
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
mediaRepo = repository.NewMediaRepository(db)
// 标题翻译:记忆仓储 + 服务(记忆优先、规则兜底)。入库自动补中文标题、后台编辑记记忆。
translateRepo = repository.NewTranslateRepository(db)
translateSvc = service.NewTranslateService(brandRepo, translateRepo)
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
// S4 上传器(凭证齐全才真正启用;否则为零值,图片回落本地兜底)。
s4Up = storage.NewS4Uploader(cfg.S4.AK, cfg.S4.SK, cfg.S4.Bucket, cfg.S4.Endpoint, cfg.S4.BaseURL, cfg.S4.Region)
// 图片 URL 拼装器:库里只存 S4 key,渲染时拼 base_url + 展示样式(VIP 与免费同质量)。
// base_url 优先取 S4 上传器自动推导值(保证展示图 URL 同源)。
// StyleThumb 供列表/卡片等小尺寸场景使用(详情页与大图仍走 StyleDisplay)。
imgComposer = imgurl.New(s4BaseURL(cfg.S4.BaseURL, s4Up), cfg.S4.StyleDisplay, cfg.S4.StyleThumb)
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
// localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用 S4 时两者均为 S4 实现。
localUp *storage.LocalUploader = storage.NewLocalUploader(uploadDir, cfg.Upload.URLPrefix)
imgUp storage.Uploader = localUp
imgDel storage.Deleter = localUp
articleSvc service.ArticleService // 在 ingestSvc 创建后再装配(需注入清理入队器)
brandSvc = service.NewBrandService(brandRepo)
indexSvc = service.NewIndexService(brandRepo, imgComposer)
snapSvc service.StreetSnapService // 在 ingestSvc 创建后再装配(需注入清理入队器)
authSvc = service.NewAuthService(userRepo, refreshRepo, jwtManager, cfg.JWT.ExpireHours, cfg.JWT.RefreshExpireHours)
favSvc = service.NewFavoriteService(favRepo)
histSvc = service.NewHistoryService(histRepo)
// 审核:草稿列表 / 通过晋升正式表 / 驳回(后台预览始终高清)
reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel)
)
// 把图片 URL 拼装器交给后台模板:编辑/详情页用 imgURL 渲染图片,S4 模式下也能打开。
handler.SetImgComposer(imgComposer)
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
if cfg.S4.Enabled && s4Up != nil && s4Up.Enabled() {
// Upload 只返回 S4 key(不拼 base_url),完整可访问 URL 由 imgComposer 在读取时按 tier 拼;
// free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。
imgUp = s4Up
imgDel = s4Up
slog.Info("图片存储(S4)", "bucket", cfg.S4.Bucket, "base", s4Up.PublicBaseURL())
} else {
slog.Info("图片存储(本地)", "prefix", cfg.Upload.URLPrefix)
}
// 视觉服务(FashionSigLIP):可选,仅用于后台「疑似同款」提示,不参与任何去重。
// 未启用时两者均为 nil,IngestService 会整体跳过 embed(零开销),入库行为与启用前一致。
var (
visionClient *vision.Client
embedRepo repository.EmbeddingRepository
)
if cfg.Vision.Enabled {
visionClient = vision.New(
cfg.Vision.Endpoint,
cfg.Vision.Dim,
time.Duration(cfg.Vision.TimeoutSeconds)*time.Second,
cfg.Vision.FailThreshold,
)
embedRepo = repository.NewEmbeddingRepository(db)
slog.Info("视觉服务已启用",
"endpoint", cfg.Vision.Endpoint,
"dim", cfg.Vision.Dim,
"similar_threshold", cfg.Vision.SimilarThreshold)
} else {
slog.Info("视觉服务未启用(不产出语义向量,后台无「疑似同款」提示)")
}
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc, visionClient, embedRepo)
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
engine := router.New(router.Options{
Config: cfg,
JWT: jwtManager,
Article: handler.NewArticleHandler(articleSvc, favSvc),
Brand: handler.NewBrandHandler(brandSvc),
StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc),
Auth: handler.NewAuthHandler(authSvc),
Favorite: handler.NewFavoriteHandler(favSvc),
History: handler.NewHistoryHandler(histSvc),
Health: handler.NewHealthHandler(),
})
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
// 「疑似同款」服务与视觉服务同开关:未启用时 embedRepo 为 nil,页面上不出现该角标。
similarSvc := service.NewSimilarService(embedRepo, cfg.Vision.SimilarThreshold)
// 把「疑似同款」服务注入入库流程:写完向量后顺手预计算近邻并落库,
// 后台页面因此只需读一次小查询(原先是页面内对每张图实时检索,一个 116 张图的图集要 5 秒)。
ingestSvc.SetSimilar(similarSvc)
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo, translateSvc, similarSvc)
// 品牌抓取任务(crawl_tasks):后台决定爬哪些品牌,spider 服务(可跨机)领取执行。
backstageHandler.SetCrawl(repository.NewCrawlRepository(db))
// 向量回填接口的仓储(媒体机直传后,向量只能由外部 worker 回传)。
backstageHandler.SetEmbeddingRepo(embedRepo)
// S4 上传器:给媒体机签发直传 URL(密钥只留在后端)。
backstageHandler.SetUploader(s4Up)
// 手工建稿:复用入库仓储的写入方法(Create*WithImages,事务内完成)与同一套存储上传
// (imgUp = S4/本地主上传器,sha1 内容寻址),保证与爬虫入库产出的 key / 行结构一致。
backstageHandler.SetManual(service.NewManualArticleService(ingestRepo, brandRepo, imgUp, localUp, visionClient, embedRepo))
// 爬虫上报 handler(HMAC 验签入口)
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
// 6. 启动对外公开 HTTP 服务(8090),并支持优雅关闭
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: engine,
}
go func() {
slog.Info("公开服务已启动", "addr", "http://localhost:"+cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("公开服务启动失败", "error", err)
os.Exit(1)
}
}()
// 7. 启动 SSG 内部服务(默认 8091,仅供 astro build 构建期调用)。
// 该端口由部署层绑定到 127.0.0.1(回环),外部不可达;此处无论如何都启动,
// 即便部署层误绑了 0.0.0.0,SSGToken 中间件也能作为最后一道闸。
ssgEngine := router.NewSSG(router.Options{
Config: cfg,
SSG: handler.NewSSGHandler(brandSvc, indexSvc, snapSvc),
})
srvSSG := &http.Server{
Addr: ":" + cfg.Server.SSGPort,
Handler: ssgEngine,
}
go func() {
slog.Info("SSG 内部服务已启动", "addr", "http://localhost:"+cfg.Server.SSGPort)
if err := srvSSG.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("SSG 内部服务启动失败", "error", err)
os.Exit(1)
}
}()
// 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())
// 并发数可配:批量重爬时单进程 2 个 worker 是吞吐瓶颈(配合 INGEST_IMAGE_CONCURRENCY 决定总并发)。
// 默认仍是 2,行为与之前一致;1~16 之外的值忽略并回落默认。
ingestWorkers := 2
if v := strings.TrimSpace(os.Getenv("INGEST_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 16 {
ingestWorkers = n
} else {
slog.Warn("INGEST_WORKERS 非法(应为 1~16 的整数),回退默认 2", "value", v)
}
}
go ingestSvc.Run(workerCtx, ingestWorkers, 3*time.Second)
slog.Info("入库 worker 已启动", "queue", "ingest_jobs", "workers", ingestWorkers)
srvBackstage := &http.Server{
Addr: ":" + cfg.Server.BackstagePort,
Handler: backstageEngine,
}
go func() {
slog.Info("管理后台已启动", "addr", "http://localhost:"+cfg.Server.BackstagePort+"/admin")
if err := srvBackstage.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("管理后台启动失败", "error", err)
os.Exit(1)
}
}()
// 收到中断信号后,给正在处理的请求留出完成时间再退出,
// 避免直接杀进程导致请求被截断、连接池未正常释放。
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("正在关闭服务")
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer cancel()
workerCancel() // 通知入库 worker 停止领取新任务
if err := srv.Shutdown(ctx); err != nil {
slog.Error("公开服务优雅关闭超时", "error", err)
}
if err := srvSSG.Shutdown(ctx); err != nil {
slog.Error("SSG 内部服务优雅关闭超时", "error", err)
}
if err := srvBackstage.Shutdown(ctx); err != nil {
slog.Error("管理后台优雅关闭超时", "error", err)
}
slog.Info("服务已停止")
}