This commit is contained in:
toom1996
2026-09-22 21:40:04 +08:00
parent c32347ed3c
commit c3a6baa419
25 changed files with 2051 additions and 783 deletions

View File

@ -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)

View File

@ -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)
}
}