Files
backend_v2/cmd/server/main.go
toom1996 d15d2a4701 update
2026-09-28 10:52:50 +08:00

253 lines
11 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"
"net/http"
"os"
"os/signal"
"path/filepath"
"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"
"github.com/gin-gonic/gin"
)
// s4BaseURL 取 S4 对外访问域名:优先用上传器自动推导值(保证 free/vip 同 host),否则用配置值。
func s4BaseURL(cfgBase string, up *storage.S4Uploader) string {
if up != nil && up.Enabled() {
if b := up.PublicBaseURL(); b != "" {
return b
}
}
return cfgBase
}
func main() {
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
flag.Parse()
// 1. 加载配置(yml 为主,环境变量可覆盖)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("✗ 加载配置失败: %v", err)
}
if from := cfg.LoadedFrom(); from != "" {
log.Println("✓ 配置文件:", from)
} else {
log.Println("! 未找到配置文件,使用默认值与环境变量")
}
gin.SetMode(cfg.Server.Mode)
// 2. 连接数据库
db, err := database.New(cfg.Database)
if err != nil {
log.Fatalf("✗ %v", err)
}
defer func() {
if err := database.Close(db); err != nil {
log.Printf("! 关闭数据库连接失败: %v", err)
}
}()
// 2.5 结构不再随启动自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的
// 纯 SQL 维护(dbtool dump → psql -f)。这里只探活,不执行任何 DDL。
log.Println("✓ PostgreSQL 已连接:", cfg.Database.Addr())
// 3. 确保上传目录存在(静态文件服务的根目录)
uploadDir, _ := filepath.Abs(cfg.Upload.Dir)
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
log.Fatalf("✗ 创建上传目录失败: %v", err)
}
log.Printf("✓ 静态资源: %s → %s", cfg.Upload.URLPrefix, 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)
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
log.Printf("✓ 图片存储: 缤纷云 S4 bucket=%s base=%s(库只存 key,渲染时拼 base_url)", cfg.S4.Bucket, s4Up.PublicBaseURL())
} else {
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix)
}
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy)
// 图集服务:注入 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,供下方独立引擎使用。
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo)
// 爬虫上报 handler(HMAC 验签入口)
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
// 6. 启动对外公开 HTTP 服务(8090),并支持优雅关闭
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: engine,
}
go func() {
log.Printf("🚀 公开服务已启动: http://localhost:%s", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("✗ 公开服务启动失败: %v", err)
}
}()
// 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() {
log.Printf("🔒 SSG 内部服务已启动: http://localhost:%s (仅构建期/回环可达)", cfg.Server.SSGPort)
if err := srvSSG.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("✗ SSG 内部服务启动失败: %v", err)
}
}()
// 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)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("→ 正在关闭服务 ...")
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer cancel()
workerCancel() // 通知入库 worker 停止领取新任务
if err := srv.Shutdown(ctx); err != nil {
log.Printf("! 公开服务优雅关闭超时: %v", err)
}
if err := srvSSG.Shutdown(ctx); err != nil {
log.Printf("! SSG 内部服务优雅关闭超时: %v", err)
}
if err := srvBackstage.Shutdown(ctx); err != nil {
log.Printf("! 管理后台优雅关闭超时: %v", err)
}
log.Println("✓ 服务已停止")
}