feat(street): 后台审核页街拍主副图分组渲染与操作路由

This commit is contained in:
toom1996
2026-09-21 22:32:27 +08:00
parent 96858aa85f
commit ed92b475d1
3 changed files with 306 additions and 12 deletions

View File

@ -94,6 +94,10 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
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)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages)
admin.POST("/reviews/:kind/:id/images/:img/attach-prev", opt.Backstage.ReviewAttachPrevDraftImage)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -14,6 +14,8 @@ import (
"fashionapi/internal/model"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
)
// ── 测试用假实现(不连库)──────────────────────────────────────────────
@ -175,7 +177,7 @@ 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)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
@ -288,3 +290,166 @@ func TestBackstage(t *testing.T) {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
}
}
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。
type fakeReview struct {
service.ReviewService
attachOK bool
last struct {
kind string
draftID uint32
mainID uint32
imgIDs []uint32
imgID uint32
op string
}
view *service.DraftDetailView
}
var _ service.ReviewService = (*fakeReview)(nil)
func (f *fakeReview) DraftDetail(_ context.Context, _ string, _ uint32) (*service.DraftDetailView, error) {
if f.view != nil {
return f.view, nil
}
return &service.DraftDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
}
func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, mainID uint32, imgIDs []uint32) error {
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
func (f *fakeReview) AttachPrevDraftImage(_ context.Context, kind string, draftID, imgID uint32) error {
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "attach-prev"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error {
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
t.Helper()
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
rev := &fakeReview{attachOK: true}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil)
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
return r, rev
}
func reviewToken(t *testing.T, r *gin.Engine) *http.Cookie {
t.Helper()
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)
cookies := w.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("登录未下发会话 cookie")
}
return cookies[0]
}
// TestBackstageStreetAttachRedirect 批量并入应 302 跳回带 ?main= 的详情页,且参数透传到 service。
func TestBackstageStreetAttachRedirect(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("并入应 302,实际 %d %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7?main=1") {
t.Fatalf("重定向应带 ?main=1,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
t.Fatalf("参数透传错误: %+v", rev.last)
}
}
// TestBackstageStreetAttachPrev 并入上一张应 302。
func TestBackstageStreetAttachPrev(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/attach-prev", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("并入上一张应 302,实际 %d", w.Code)
}
if rev.last.op != "attach-prev" || rev.last.imgID != 2 {
t.Fatalf("参数透传错误: %+v", rev.last)
}
}
// TestBackstageStreetDetach 拆出应 302 跳回「以该图为主图」的详情页。
func TestBackstageStreetDetach(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/detach", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("拆出应 302,实际 %d", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7?main=2") {
t.Fatalf("拆出后应以该图为主图跳转,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "detach" || rev.last.imgID != 2 {
t.Fatalf("参数透传错误: %+v", rev.last)
}
}
// TestBackstageStreetRenderGroups 详情页应按组折叠渲染(strip-group + 副图名)。
func TestBackstageStreetRenderGroups(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
rev.view = &service.DraftDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.DraftImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
},
Groups: []service.DraftImageGroup{
{Image: service.DraftImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.DraftImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.DraftImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7?main=1", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "strip-group") {
t.Fatalf("应按组折叠渲染 strip-group")
}
if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a")
}
}