246 lines
10 KiB
Go
246 lines
10 KiB
Go
// 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)
|
||
}
|
||
}()
|
||
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)
|
||
// 跨表图片引用计数(删除图集时判定 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)
|
||
)
|
||
|
||
// 爬虫入库:入队 + 异步 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)
|
||
// 图集服务:注入 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("✓ 服务已停止")
|
||
}
|