update
This commit is contained in:
129
internal/router/backstage.go
Normal file
129
internal/router/backstage.go
Normal 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()
|
||||
}
|
||||
}
|
||||
292
internal/router/backstage_test.go
Normal file
292
internal/router/backstage_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user