update
This commit is contained in:
@ -44,6 +44,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
r.Static(opt.UploadURLPrefix, opt.UploadDir)
|
||||
}
|
||||
|
||||
// 后台自有静态资源(admin.css / admin.js):embed 进二进制,无 CDN、无构建产物依赖;
|
||||
// 给一段缓存时间,避免每翻一页都重新下载。
|
||||
assets := r.Group("/admin/assets")
|
||||
assets.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=600")
|
||||
c.Next()
|
||||
})
|
||||
assets.StaticFS("/", http.FS(handler.BackstageAssetFS()))
|
||||
|
||||
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
|
||||
r.GET("/admin/login", opt.Backstage.LoginPage)
|
||||
r.POST("/admin/login", opt.Backstage.Login)
|
||||
@ -65,7 +74,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
// 受保护:后台页面,统一走 cookie 鉴权中间件
|
||||
admin := r.Group("/admin")
|
||||
admin.Use(requireBackstageLogin(opt.JWT))
|
||||
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
|
||||
admin.Use(opt.Backstage.NavStats())
|
||||
{
|
||||
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
||||
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
|
||||
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
|
||||
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
|
||||
|
||||
admin.GET("/", opt.Backstage.Dashboard)
|
||||
admin.GET("/brands", opt.Backstage.Brands)
|
||||
admin.GET("/brands/new", opt.Backstage.BrandNew)
|
||||
|
||||
@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -293,11 +294,16 @@ func TestBackstage(t *testing.T) {
|
||||
|
||||
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
|
||||
|
||||
// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。
|
||||
// fakeReview 实现审核服务接口;
|
||||
// ⚠️ 嵌入的 service.ReviewService 是 nil 接口,只对「确实实现的方法」安全 ——
|
||||
// 任何未覆盖的方法被调用时都会 nil panic(曾经因为导航中间件调 KindTabs 而炸出来)。
|
||||
// 因此凡是页面/接口会走到的服务方法,都必须在这里显式实现。
|
||||
type fakeReview struct {
|
||||
service.ReviewService
|
||||
attachOK bool
|
||||
last struct {
|
||||
attachOK bool
|
||||
approveErr error
|
||||
rejectErr error
|
||||
last struct {
|
||||
kind string
|
||||
draftID uint32
|
||||
mainID uint32
|
||||
@ -305,6 +311,13 @@ type fakeReview struct {
|
||||
imgID uint32
|
||||
op string
|
||||
}
|
||||
// calls 记录批量审核的逐条调用,供断言。
|
||||
calls []struct {
|
||||
Action string
|
||||
Kind string
|
||||
ID uint32
|
||||
Reason string
|
||||
}
|
||||
view *service.DraftDetailView
|
||||
}
|
||||
|
||||
@ -332,6 +345,39 @@ func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, i
|
||||
return nil
|
||||
}
|
||||
|
||||
// KindTabs 是导航中间件在每个后台页面上都会调用的方法(用于显示待审计数角标),
|
||||
// 必须显式实现,否则会落到嵌入的 nil 接口上 panic。
|
||||
func (f *fakeReview) KindTabs(_ context.Context) []service.KindTab {
|
||||
return []service.KindTab{
|
||||
{Kind: "", Label: "全部", Pending: 2},
|
||||
{Kind: "street", Label: "街拍", Pending: 1},
|
||||
{Kind: "runway", Label: "走秀", Pending: 1},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer string) (uint32, error) {
|
||||
f.calls = append(f.calls, struct {
|
||||
Action string
|
||||
Kind string
|
||||
ID uint32
|
||||
Reason string
|
||||
}{"approve", kind, id, ""})
|
||||
if f.approveErr != nil {
|
||||
return 0, f.approveErr
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (f *fakeReview) Reject(_ context.Context, kind string, id uint32, reviewer, reason string) error {
|
||||
f.calls = append(f.calls, struct {
|
||||
Action string
|
||||
Kind string
|
||||
ID uint32
|
||||
Reason string
|
||||
}{"reject", kind, id, reason})
|
||||
return f.rejectErr
|
||||
}
|
||||
|
||||
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
||||
t.Helper()
|
||||
m := jwt.NewManager("test-secret", 168)
|
||||
@ -462,3 +508,136 @@ func TestBackstageStreetRenderGroups(t *testing.T) {
|
||||
t.Fatalf("不应再出现「并入上一张」")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ───────────────────────────
|
||||
|
||||
// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。
|
||||
func TestBackstageAssets(t *testing.T) {
|
||||
r, _ := newReviewRouter(t)
|
||||
|
||||
for _, tc := range []struct{ path, want string }{
|
||||
{"/admin/assets/admin.css", ".bar-nav"},
|
||||
{"/admin/assets/admin.js", "data-admin-js"},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s 应 200,实际 %d", tc.path, w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), tc.want) {
|
||||
t.Fatalf("%s 内容缺少 %q", tc.path, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackstageNavStatsOnEveryPage 后台任意页面都带待审计数角标。
|
||||
// 该角标由 NavStats 中间件提供;它同时是「所有页面都会调用 ReviewService.KindTabs」的证据。
|
||||
func TestBackstageNavStatsOnEveryPage(t *testing.T) {
|
||||
r, _ := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/brands", nil)
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("品牌页应 200: %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "bar-badge") || !strings.Contains(body, ">2<") {
|
||||
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
||||
}
|
||||
// 导航已在共享 partial 里,各模块入口都应在
|
||||
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
|
||||
if !strings.Contains(body, ">"+label) {
|
||||
t.Fatalf("导航应包含入口 %q", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackstageReviewBatchApprove 批量通过:一次请求处理多条,返回 ok 计数。
|
||||
func TestBackstageReviewBatchApprove(t *testing.T) {
|
||||
r, rev := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("批量通过应 200: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
OK int `json:"ok"`
|
||||
Pending int64 `json:"pending"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v", err)
|
||||
}
|
||||
if res.OK != 2 {
|
||||
t.Fatalf("应通过 2 条,实际 %d", res.OK)
|
||||
}
|
||||
if res.Pending != 2 {
|
||||
t.Fatalf("应回带最新待审计数 2,实际 %d", res.Pending)
|
||||
}
|
||||
if len(rev.calls) != 2 || rev.calls[0].ID != 11 || rev.calls[1].Kind != "runway" {
|
||||
t.Fatalf("逐条调用未透传到 service: %+v", rev.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackstageReviewBatchRejectNeedsReason 驳回必须带理由(留痕要求),否则 400 且不调用 service。
|
||||
func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
|
||||
r, rev := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("缺理由应 400: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(rev.calls) != 0 {
|
||||
t.Fatalf("缺理由不应调用 service: %+v", rev.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
|
||||
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
||||
r, rev := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
|
||||
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(tok)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("部分失败仍应 200: %d", w.Code)
|
||||
}
|
||||
var res struct {
|
||||
OK int `json:"ok"`
|
||||
Failed []struct {
|
||||
ID uint32 `json:"id"`
|
||||
Error string `json:"error"`
|
||||
} `json:"failed"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v", err)
|
||||
}
|
||||
if res.OK != 1 || len(res.Failed) != 1 || res.Failed[0].ID != 12 {
|
||||
t.Fatalf("应 1 成功 1 失败(id=12): ok=%d failed=%+v", res.OK, res.Failed)
|
||||
}
|
||||
if len(rev.calls) != 1 {
|
||||
t.Fatalf("未注册类型不应调用 service: %+v", rev.calls)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user