This commit is contained in:
toom1996
2026-09-25 11:31:52 +08:00
parent 4f4c11d508
commit 42f6316125
51 changed files with 4489 additions and 998 deletions

View File

@ -77,11 +77,11 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
admin.Use(opt.Backstage.NavStats())
{
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands)
@ -89,31 +89,29 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
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("/articles", opt.Backstage.Articles)
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
admin.POST("/articles/:kind/:id/reject", opt.Backstage.ArticleReject)
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)
// 分类型编辑/详情/删图/设封面/上下架:从文章管理列表跳入,按类型落到各自的编辑页。
// 列表本身已合并到 /admin/articles,这里只保留编辑链路,避免重复维护两套列表。
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.ReviewImageDelete)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -13,7 +13,9 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/handler"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/repository"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
@ -164,12 +166,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return nil, service.ErrStreetSnapNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street(待审)",
@ -178,11 +180,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr
},
}, nil
}
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (f *fakeStreet) Popular(ctx context.Context, limit int, locale string) ([]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
imgs := []model.StreetSnapImage{
{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},
}
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street", Status: "pending"}, imgs, nil
}
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil
@ -198,13 +205,63 @@ func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
type fakeAdminRepo struct{}
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
all := []repository.AdminRecordCard{
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
{Kind: "street", ID: 2, Title: "Paris Fashion Week Street", Subtitle: "Paris", ImageCount: 8, Status: "pending"},
}
if kind != "" {
kept := all[:0]
for _, c := range all {
if c.Kind == kind {
kept = append(kept, c)
}
}
all = kept
}
if status != "" {
kept := all[:0]
for _, c := range all {
if c.Status == status {
kept = append(kept, c)
}
}
all = kept
}
return all, int64(len(all)), nil
}
func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int64, error) {
if kind == "runway" {
if status == "" || status == "published" {
return 1, nil
}
return 0, nil
}
if kind == "street" {
if status == "" || status == "pending" {
return 1, nil
}
return 0, nil
}
if status == "" {
return 2, nil
}
if status == "pending" || status == "published" {
return 1, nil
}
return 0, 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{}, &fakeReview{attachOK: true}, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
@ -281,13 +338,17 @@ func TestBackstage(t *testing.T) {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
// 6) 文章管理混合列表渲染(走秀 + 街拍合并)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles", 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())
body := w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") {
t.Fatalf("文章管理列表异常: %d %s", w.Code, body)
}
if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") {
t.Fatalf("文章管理列表应含状态 tab: %s", body)
}
// 7) 后台走秀详情渲染图集。
@ -301,13 +362,14 @@ func TestBackstage(t *testing.T) {
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
// 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles?status=pending", 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())
body = w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") {
t.Fatalf("待审核筛选异常: %d %s", w.Code, body)
}
// 9) 后台街拍详情渲染图集。
@ -420,7 +482,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
rev := &fakeReview{attachOK: true}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
return r, rev
}
@ -444,14 +506,14 @@ func TestBackstageStreetAttachRedirect(t *testing.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 := httptest.NewRequest(http.MethodPost, "/admin/articles/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") {
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
@ -465,7 +527,7 @@ func TestBackstageStreetAttachNeedsMain(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("img=2&img=3"))
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -483,13 +545,13 @@ func TestBackstageStreetDetach(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/detach", nil)
req := httptest.NewRequest(http.MethodPost, "/admin/articles/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") {
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "detach" || rev.last.imgID != 2 {
@ -499,33 +561,23 @@ func TestBackstageStreetDetach(t *testing.T) {
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
func TestBackstageStreetRenderGroups(t *testing.T) {
hashid.Init("test-salt")
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
rev.view = &service.RecordDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.RecordImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
},
Groups: []service.ImageGroup{
{Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
},
}
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil)
req := httptest.NewRequest(http.MethodGet, "/admin/street-snaps/"+hashid.EncodeWithType(7, hashid.TypeSnap)+"/edit", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
t.Fatalf("街拍编辑页应 200,实际 %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a")
}
// 副图徽标显示所属主图(沿链归并后的实际主图)。
// 副图徽标显示所属主图。
if !strings.Contains(body, "副图 → #1") {
t.Fatalf("副图应显示所属主图")
}
@ -552,7 +604,7 @@ func TestBackstageApprovePublishes(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil)
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -605,7 +657,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
}
// 导航已在共享 partial 里,各模块入口都应在
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
if !strings.Contains(body, ">"+label) {
t.Fatalf("导航应包含入口 %q", label)
}
@ -619,7 +671,7 @@ func TestBackstageReviewBatchApprove(t *testing.T) {
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 := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -652,7 +704,7 @@ func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -672,7 +724,7 @@ func TestBackstageReviewBatchPartialFailure(t *testing.T) {
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 := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)