This commit is contained in:
toom1996
2026-09-07 00:04:01 +08:00
parent 6a5a4378ab
commit 10d8a96e8c
29 changed files with 5349 additions and 0 deletions

View File

@ -0,0 +1,129 @@
package router
import (
"net/http"
"fashionapi/internal/config"
"fashionapi/internal/handler"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/jwt"
"github.com/gin-gonic/gin"
)
// BackstageOptions 后台引擎所需依赖。
type BackstageOptions struct {
Config *config.Config
JWT *jwt.Manager
Backstage *handler.BackstageHandler
UploadURLPrefix string // 静态资源 URL 前缀(如 /uploads),用于后台渲染封面/图集
UploadDir string // 静态资源本地根目录,与 UploadURLPrefix 配对
// Ingest 爬虫上报 handler(HMAC 验签入口)。为 nil 时不挂载 ingest 路由。
Ingest *handler.IngestHandler
// IngestAuth 验签配置(含 nonce 表)。Secret 为空则 ingest 路由返回 503。
IngestAuth *middleware.IngestAuthConfig
}
// adminCookie 与 handler 包保持一致:后台会话令牌 cookie 名(HttpOnly,路径 /admin)。
const adminCookie = "admin_session"
// NewBackstage 构建管理后台引擎(默认 :8092),返回 *gin.Engine。
//
// 与公开引擎(8090)/ SSG 引擎(8091)的区别:
// 1. 不装载 CORS —— 后台是同源服务端渲染页面,CORS 用不上;
// 2. 登录态走 HttpOnly cookie(admin_session)而非 Bearer,由 requireBackstageLogin 校验;
// 3. 未登录访问 /admin/* 一律 302 跳登录页,无需返回 JSON 401。
//
// 该引擎由 main 监听在独立端口,可独立绑定/限流,与对外服务物理隔离。
func NewBackstage(opt BackstageOptions) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// 静态资源:让后台能直接渲染封面与图集(与公开引擎共享同一上传目录)。
if opt.UploadURLPrefix != "" && opt.UploadDir != "" {
r.Static(opt.UploadURLPrefix, opt.UploadDir)
}
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
r.GET("/admin/login", opt.Backstage.LoginPage)
r.POST("/admin/login", opt.Backstage.Login)
r.GET("/admin/logout", opt.Backstage.Logout)
// 爬虫上报入口(HMAC 验签,不挂登录 cookie):/admin/internal/ingest
// 仅当配置了 INGEST_SECRET 才挂载真实接口,否则返回 503(尚未启用)。
if opt.Ingest != nil && opt.IngestAuth != nil && opt.IngestAuth.Secret != "" {
internal := r.Group("/admin/internal")
internal.Use(middleware.IngestAuth(*opt.IngestAuth))
internal.POST("/ingest", opt.Ingest.Submit)
internal.GET("/crawl/brands", opt.Ingest.CrawlBrands)
// 图集预检:爬虫抓详情页前批量问「这些 source_url 是否已爬取过」,跳过已存在的。
internal.POST("/crawl/exists", opt.Ingest.CrawlExists)
} else {
r.POST("/admin/internal/ingest", func(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingest disabled"})
})
}
// 受保护:后台页面,统一走 cookie 鉴权中间件
admin := r.Group("/admin")
admin.Use(requireBackstageLogin(opt.JWT))
{
admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands)
admin.GET("/brands/new", opt.Backstage.BrandNew)
admin.POST("/brands", opt.Backstage.BrandCreate)
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
admin.GET("/runways", opt.Backstage.Runways)
admin.GET("/runways/:id", opt.Backstage.RunwayDetail)
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
admin.GET("/street-snaps", opt.Backstage.StreetSnaps)
admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail)
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
// 用户管理:列出用户 + 提级 / 降为 VIP
admin.GET("/users", opt.Backstage.Users)
admin.POST("/users/:id/tier", opt.Backstage.UserSetTier)
}
return r
}
// requireBackstageLogin 校验后台会话 cookie;缺失或失效则跳登录页。
func requireBackstageLogin(jwtManager *jwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(adminCookie)
if err != nil || token == "" {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
claims, err := jwtManager.Parse(token)
if err != nil {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
c.Set(middleware.ContextUserID, claims.UserID)
c.Set(middleware.ContextUsername, claims.Username)
c.Next()
}
}

View File

@ -0,0 +1,292 @@
package router
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"fashionapi/internal/config"
"fashionapi/internal/dto"
"fashionapi/internal/handler"
"fashionapi/internal/model"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/service"
)
// ── 测试用假实现(不连库)──────────────────────────────────────────────
type fakeAuth struct{ jwt *jwt.Manager }
func (f *fakeAuth) Me(ctx context.Context, id uint32) (*dto.UserPayload, error) {
return &dto.UserPayload{ID: id, Username: "admin"}, nil
}
func (f *fakeAuth) Login(ctx context.Context, account, password string) (string, string, int, *dto.UserPayload, error) {
if account != "admin" || password != "secret" {
return "", "", 0, nil, errors.New("invalid credentials")
}
tok, _ := f.jwt.Generate(1, "admin", "admin@example.com", "free")
return tok, "refresh", 3600, &dto.UserPayload{ID: 1, Username: "admin", Email: "admin@example.com"}, nil
}
func (f *fakeAuth) Refresh(ctx context.Context, rt string) (string, int, error) {
return "x", 3600, nil
}
func (f *fakeAuth) Logout(ctx context.Context, rt string) error { return nil }
func (f *fakeAuth) RevokeAllByRefresh(ctx context.Context, rt string) (int64, error) {
return 1, nil
}
func (f *fakeAuth) ListUsers(ctx context.Context) ([]dto.UserPayload, error) {
return []dto.UserPayload{{ID: 1, Username: "admin", Tier: "free"}}, nil
}
func (f *fakeAuth) SetTier(ctx context.Context, id uint32, tier string) error { return nil }
type fakeBrand struct{}
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
}
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
return nil, nil
}
func (f *fakeBrand) Get(ctx context.Context, uid string) (*model.Brand, error) {
if uid != "001DESke" {
return nil, service.ErrBrandNotFound
}
return &model.Brand{ID: 1, NameEn: "Acne Studios", NameCn: "艾克妮"}, nil
}
func (f *fakeBrand) Create(ctx context.Context, nameEn, nameCn string) (string, error) {
return "009DESke", nil
}
func (f *fakeBrand) Update(ctx context.Context, uid, nameEn, nameCn string) error {
if uid != "001DESke" {
return service.ErrBrandNotFound
}
return nil
}
func (f *fakeBrand) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
return []dto.CrawlBrand{{BrandUID: "001DESke", Name: "Acne Studios"}}, nil
}
// 编译期接口满足性检查
var (
_ service.ArticleService = (*fakeArticle)(nil)
_ service.StreetSnapService = (*fakeStreet)(nil)
_ service.BrandService = (*fakeBrand)(nil)
)
type fakeArticle struct{}
func (f *fakeArticle) List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) {
return []dto.PublicArticle{{
UID: "r001DESke",
BrandUID: "001DESke",
Title: "Acne Studios Spring 2024",
Cover: "/uploads/runway/cover.jpg",
BrandName: "Acne Studios",
ImageCount: 12,
}}, 1, nil
}
func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
return []dto.AdminRunway{{
UID: "r001DESke",
BrandUID: "001DESke",
BrandName: "Acne Studios",
Title: "Acne Studios Spring 2024",
Year: 2024,
Season: "spring",
CollectionType: "rtw",
SeasonCode: "SS24",
ImageCount: 12,
SourceURL: "https://www.vogue.com/fashion-shows",
}}, 1, nil
}
func (f *fakeArticle) Detail(ctx context.Context, id, locale string, hd bool) (*dto.PublicArticleDetail, error) {
return &dto.PublicArticleDetail{
UID: id,
BrandUID: "001DESke",
Title: "Acne Studios Spring 2024",
BrandName: "Acne Studios",
SourceURL: "https://www.vogue.com/fashion-shows",
Images: []dto.PublicArticleImage{
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
{ID: "i2", Image: "/uploads/runway/2.jpg", Name: "Look 2"},
},
}, nil
}
func (f *fakeArticle) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
return &model.BrandRunway{ID: id, TitleEn: "Acne Studios Spring 2024"}, nil, nil
}
func (f *fakeArticle) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
return nil
}
func (f *fakeArticle) DeleteImage(ctx context.Context, imageID uint32) error { return nil }
func (f *fakeArticle) SetCover(ctx context.Context, runwayID uint32, image string) error {
return nil
}
func (f *fakeArticle) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
return nil
}
func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
type fakeStreet struct{}
func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
return []dto.PublicStreetSnap{{
UID: "s001DESke",
Title: "Paris Fashion Week Street",
Cover: "/uploads/snap/cover.jpg",
ImageCount: 8,
}}, 1, nil
}
func (f *fakeStreet) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street",
Images: []dto.PublicArticleImage{
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
},
}, nil
}
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
return nil, nil
}
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil
}
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil
}
func (f *fakeStreet) DeleteImage(ctx context.Context, imageID uint32) error { return nil }
func (f *fakeStreet) SetCover(ctx context.Context, snapID uint32, image string) error {
return nil
}
func (f *fakeStreet) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
return nil
}
func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, nil, nil)
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if w.Code != 200 || !strings.Contains(w.Body.String(), "管理后台登录") {
t.Fatalf("登录页异常: %d %s", w.Code, w.Body.String())
}
// 2) 未登录访问 /admin/ 应 302 跳登录
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if w.Code != 302 || !strings.Contains(w.Header().Get("Location"), "/admin/login") {
t.Fatalf("未登录保护异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 3) 错误密码不种 cookie
w = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
if w.Code != 200 || len(w.Result().Cookies()) != 0 {
t.Fatalf("错误密码应停留登录页且不种 cookie: %d cookies=%d", w.Code, len(w.Result().Cookies()))
}
// 4) 正确登录种 cookie 并跳首页
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=secret"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
if w.Code != 302 {
t.Fatalf("登录应 302: %d", w.Code)
}
cookies := w.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != adminCookie || cookies[0].Value == "" {
t.Fatalf("登录未下发会话 cookie")
}
token := cookies[0].Value
// 5) 带 cookie 访问品牌列表,应渲染出数据
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/brands", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios") {
t.Fatalf("品牌列表异常: %d %s", w.Code, w.Body.String())
}
// 5b) 品牌新建表单可访问
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/brands/new", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "新建品牌") {
t.Fatalf("品牌新建表单异常: %d %s", w.Code, w.Body.String())
}
// 5c) 品牌编辑表单渲染现有值
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/brands/001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios") {
t.Fatalf("品牌编辑表单异常: %d %s", w.Code, w.Body.String())
}
// 5d) 提交新建应 302 跳回列表
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/admin/brands", strings.NewReader("name_en=Test+Brand&name_cn=%E8%AF%95"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 302 || w.Header().Get("Location") != "/admin/brands" {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
}
// 7) 走秀详情渲染图集
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") {
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
}
// 9) 街拍详情渲染图集
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
}
}