Files
backend_v2/cmd/server/main.go
toom1996 74ba700598 update
2026-09-07 00:01:48 +08:00

233 lines
9.6 KiB
Go
Raw Permalink 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"
)
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)
}
}()
log.Println("✓ MySQL 已连接:", 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)
// 跨表图片引用计数(删除图集时判定七牛孤儿文件用)
mediaRepo = repository.NewMediaRepository(db)
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
// 图片 URL 拼装器:库里只存七牛 key,渲染时按 tier 拼 base_url + 样式。
imgComposer = imgurl.New(cfg.Qiniu.BaseURL, cfg.Qiniu.StyleNormal, cfg.Qiniu.StyleVip, cfg.Qiniu.LocalBase, cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.SignTTLNormal, cfg.Qiniu.SignTTLHD)
// 图片存储:七牛云优先(若启用),失败兜底本地 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)
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)
)
// 爬虫入库:入队 + 异步 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{
Config: cfg,
JWT: jwtManager,
Article: handler.NewArticleHandler(articleSvc),
Brand: handler.NewBrandHandler(brandSvc),
StreetSnap: handler.NewStreetSnapHandler(snapSvc),
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)
// 爬虫上报 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("✓ 服务已停止")
}