This commit is contained in:
toom1996
2026-09-13 00:52:35 +08:00
parent 10d8a96e8c
commit dbee704c95
44 changed files with 755 additions and 1148 deletions

View File

@ -94,7 +94,7 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
return
}
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c), middleware.IsVIP(middleware.TierFrom(c)))
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
if err != nil {
fail(c, err)
return

View File

@ -2,6 +2,7 @@ package handler
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strconv"
@ -10,6 +11,7 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
@ -37,9 +39,24 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest}
}
// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。
// 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文,
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
var pagerTmpl = template.Must(template.New("base").Parse(`{{define "pager"}}<div class="pager">
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>{{end}}`))
// renderHTML 用单文件模板字符串渲染 HTML(不依赖外部模板文件,保持自包含)。
// 在解析页面正文前先 Clone 含 {{define "pager"}} 的基类模板,使各列表页可复用翻页片段。
func renderHTML(c *gin.Context, status int, tpl string, data any) {
t, err := template.New("t").Parse(tpl)
base, err := pagerTmpl.Clone()
if err != nil {
c.String(http.StatusInternalServerError, "template clone error: "+err.Error())
return
}
t, err := base.Parse(tpl)
if err != nil {
c.String(http.StatusInternalServerError, "template error: "+err.Error())
return
@ -136,20 +153,23 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
return
}
const size = 30
page := q.Page
if page < 1 {
page = 1
p := pagination.Params{Page: q.Page, PageSize: size}
if p.Page < 1 {
p.Page = 1
}
meta := p.NewMeta(total)
renderHTML(c, http.StatusOK, tplBrands, gin.H{
"Items": items,
"Total": total,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasPrev": page > 1,
"HasNext": int64(page*size) < total,
"Keyword": q.Keyword,
"Letter": q.Letter,
"Items": items,
"Total": total,
"Keyword": q.Keyword,
"Letter": q.Letter,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
Label: fmt.Sprintf("第 %d 页 · 共 %d 条", meta.Page, total),
},
})
}
@ -214,22 +234,26 @@ func (h *BackstageHandler) Runways(c *gin.Context) {
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return
}
page := q.Page
if page < 1 {
page = 1
const size = 24
p := pagination.Params{Page: q.Page, PageSize: size}
if p.Page < 1 {
p.Page = 1
}
meta := p.NewMeta(total)
renderHTML(c, http.StatusOK, tplRunways, gin.H{
"Items": items,
"Total": total,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasPrev": page > 1,
"HasNext": int64(page*24) < total,
"Collection": q.Collection,
"Season": q.Season,
"Year": q.Year,
"Sort": q.Sort,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
Label: fmt.Sprintf("第 %d 页 · 共 %d 条", meta.Page, total),
},
})
}
@ -242,7 +266,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw})
return
}
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en", true)
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
if err != nil {
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw})
return
@ -264,21 +288,25 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return
}
page := q.Page
if page < 1 {
page = 1
const size = 24
p := pagination.Params{Page: q.Page, PageSize: size}
if p.Page < 1 {
p.Page = 1
}
meta := p.NewMeta(total)
renderHTML(c, http.StatusOK, tplStreetSnaps, gin.H{
"Items": items,
"Total": total,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasPrev": page > 1,
"HasNext": int64(page*24) < total,
"Year": q.Year,
"City": q.City,
"Sort": q.Sort,
"Items": items,
"Total": total,
"Year": q.Year,
"City": q.City,
"Sort": q.Sort,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
Label: fmt.Sprintf("第 %d 页 · 共 %d 条", meta.Page, total),
},
})
}
@ -291,7 +319,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw})
return
}
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), true)
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
if err != nil {
renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw})
return
@ -334,18 +362,22 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
if kind != "" && kind != "all" {
listBase = "/" + kind
}
p := pagination.Params{Page: page, PageSize: 20}
meta := p.NewMeta(total)
renderHTML(c, http.StatusOK, tplReviewList, gin.H{
"Drafts": drafts,
"Total": total,
"Page": page,
"Status": status,
"Kind": kind,
"Kinds": kinds,
"ListBase": listBase,
"HasPrev": page > 1,
"HasNext": int64(page*20) < total,
"PrevPage": page - 1,
"NextPage": page + 1,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
Label: fmt.Sprintf("第 %d 页", meta.Page),
},
})
}
@ -449,14 +481,16 @@ type ingestJobView struct {
ID uint32
Status string
Attempts uint8
SourceURL string
LastError string
UpdatedAt string
}
// IngestJobs 列出最近的入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
// IngestJobs 分页列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
// 支持 ?page(从 1 起)与 ?pageSize(默认 50,上限 200);其余查询串原样忽略。
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
jobs, err := h.ingest.ListJobs(c.Request.Context(), 200)
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
offset := p.Offset()
jobs, total, err := h.ingest.ListJobs(c.Request.Context(), offset, p.PageSize)
if err != nil {
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return
@ -471,12 +505,27 @@ func (h *BackstageHandler) IngestJobs(c *gin.Context) {
ID: j.ID,
Status: j.Status,
Attempts: j.Attempts,
SourceURL: j.SourceURL,
LastError: j.LastError,
UpdatedAt: ua,
})
}
renderHTML(c, http.StatusOK, tplIngestJobs, gin.H{"Jobs": views})
// 越界页码(如手动改 URL 成大数)钳制到最后一页展示,与原有行为一致。
// meta.Clamp 仅调整展示用的 Page/Prev/Next,不改变上面已算好的查询 offset。
meta := p.NewMeta(total)
meta.Clamp()
renderHTML(c, http.StatusOK, tplIngestJobs, gin.H{
"Jobs": views,
"Page": meta.Page,
"PageSize": p.PageSize,
"Total": total,
"TotalPages": meta.TotalPages,
"Pager": pagination.Pager{
PrevURL: pagination.PageLinkWith(c, meta.PrevPage, "pageSize", p.PageSize),
NextURL: pagination.PageLinkWith(c, meta.NextPage, "pageSize", p.PageSize),
HasPrev: meta.HasPrev,
HasNext: meta.HasNext,
},
})
}
// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending),worker 会在下一个周期重新处理。
@ -945,11 +994,7 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
</tbody>
</table>
<div class="pager">
{{if .HasPrev}}<a href="/admin/brands?keyword={{.Keyword}}&letter={{.Letter}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
<span class="muted">第 {{.Page}} 页 · 共 {{.Total}} 条</span>
{{if .HasNext}}<a href="/admin/brands?keyword={{.Keyword}}&letter={{.Letter}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>
{{template "pager" .Pager}}
</div></body></html>`
const tplRunways = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1008,11 +1053,7 @@ a.view{color:#111;text-decoration:underline}
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
</tbody>
</table>
<div class="pager">
{{if .HasPrev}}<a href="/admin/runways?collection={{.Collection}}&season={{.Season}}&year={{.Year}}&sort={{.Sort}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
<span class="muted">第 {{.Page}} 页 · 共 {{.Total}} 条</span>
{{if .HasNext}}<a href="/admin/runways?collection={{.Collection}}&season={{.Season}}&year={{.Year}}&sort={{.Sort}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>
{{template "pager" .Pager}}
</div></body></html>`
const tplRunwayDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1037,7 +1078,6 @@ h1{font-size:22px;margin:8px 0 2px}
<div class="main">
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
<h1>{{.Article.Title}}</h1>
<p class="meta">来源:{{if .Article.SourceURL}}<a href="{{.Article.SourceURL}}" target="_blank" rel="noopener">{{.Article.SourceURL}}</a>{{else}}—{{end}}</p>
<div class="grid">
{{range .Article.Images}}<div class="cell"><img src="{{.Image}}" loading="lazy"><span>{{.Name}}</span></div>{{end}}
</div>
@ -1087,11 +1127,7 @@ a.view{color:#111;text-decoration:underline}
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
</tbody>
</table>
<div class="pager">
{{if .HasPrev}}<a href="/admin/street-snaps?city={{.City}}&year={{.Year}}&sort={{.Sort}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
<span class="muted">第 {{.Page}} 页 · 共 {{.Total}} 条</span>
{{if .HasNext}}<a href="/admin/street-snaps?city={{.City}}&year={{.Year}}&sort={{.Sort}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>
{{template "pager" .Pager}}
</div></body></html>`
const tplStreetSnapDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1204,11 +1240,7 @@ a.view{color:#111;text-decoration:underline}
</tr>{{end}}
</tbody>
</table>
<div class="pager">
{{if .HasPrev}}<a href="/admin/reviews{{.ListBase}}?status={{.Status}}&page={{.PrevPage}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
<span class="muted">第 {{.Page}} 页</span>
{{if .HasNext}}<a href="/admin/reviews{{.ListBase}}?status={{.Status}}&page={{.NextPage}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>
{{template "pager" .Pager}}
</div></body></html>`
const tplReviewDetail = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
@ -1238,7 +1270,7 @@ button.reject{background:#c0392b}
<body>
<div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="/admin/reviews{{if ne .Draft.Kind ""}}/{{.Draft.Kind}}{{end}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
<div class="main">
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b> · 来源:{{if .Draft.SourceURL}}<a href="{{.Draft.SourceURL}}" target="_blank" rel="noopener">{{.Draft.SourceURL}}</a>{{else}}—{{end}}</p>
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p>
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
<button type="submit">通过并发布</button>
@ -1386,26 +1418,29 @@ code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
form.retry{margin:0}
button.retry{font:12px system-ui;padding:4px 10px;border:1px solid #c0392b;color:#c0392b;background:#fff;border-radius:6px;cursor:pointer}
button.retry:hover{background:#c0392b;color:#fff}
.pager{display:flex;justify-content:flex-end;gap:12px;align-items:center;margin-top:16px}
.pager a{padding:7px 14px;border:1px solid #ccc;border-radius:6px;color:#111;text-decoration:none}
.pager a.off{color:#bbb;border-color:#eee;pointer-events:none}
</style></head>
<body>
<div class="bar"><span>管理后台 · 爬虫入库任务</span><span><a href="/admin/">返回首页</a> · <a href="/admin/logout">退出</a></span></div>
<div class="main">
<h1>爬虫入库任务(最近 {{len .Jobs}} 条)</h1>
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。</p>
<h1>爬虫入库任务</h1>
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
<table>
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>来源 URL</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{{range .Jobs}}<tr>
<td><code>{{.ID}}</code></td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>{{.Attempts}}</td>
<td class="break"><code>{{.SourceURL}}</code></td>
<td class="err break">{{.LastError}}</td>
<td class="muted">{{.UpdatedAt}}</td>
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
</tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div></body></html>`
const tplError = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>错误</title></head>

View File

@ -2,17 +2,17 @@ package handler
import (
"net/http"
"strconv"
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/pkg/response"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
)
// FavoriteHandler 收藏接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。
// FavoriteHandler 收藏接口(需 Bearer 鉴权,主挂载于 /api/v1/me,legacy /api/v1/auth 仅兼容旧前端)。
type FavoriteHandler struct {
favs service.FavoriteService
}
@ -33,20 +33,13 @@ func (h *FavoriteHandler) List(c *gin.Context) {
response.Error(c, http.StatusUnauthorized, "unauthorized")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 24
}
p := pagination.Parse(c, "page", "per_page", 1, 24, 100)
kind := c.Query("kind")
if kind != "" && kind != dto.FavoriteKindGallery && kind != dto.FavoriteKindImage {
response.Error(c, http.StatusBadRequest, "invalid kind")
return
}
items, total, err := h.favs.ListPaged(c.Request.Context(), userID, page, perPage, kind)
items, total, err := h.favs.ListPaged(c.Request.Context(), userID, p.Page, p.PageSize, kind)
if err != nil {
fail(c, err)
return
@ -54,7 +47,7 @@ func (h *FavoriteHandler) List(c *gin.Context) {
response.Data(c, http.StatusOK, gin.H{
"items": items,
"total": total,
"last_page": response.LastPage(total, perPage),
"last_page": response.LastPage(total, p.PageSize),
})
}
@ -102,7 +95,7 @@ func (h *FavoriteHandler) Remove(c *gin.Context) {
// Check 批量查询给定 ids 中哪些已收藏(列表页打勾用,与收藏总量解耦)。
//
// POST /api/v1/me/favorites/check
// POST /api/v1/me/favorites/checks
// body: { ids: ["r=...","s=..."] }
// 成功:200 { data: ["r=..."] }(仅返回已收藏的 id)
func (h *FavoriteHandler) Check(c *gin.Context) {

View File

@ -58,34 +58,6 @@ func queryBool(c *gin.Context, key string, def bool) bool {
return v == "1" || v == "true"
}
// parseUint32CSV 解析逗号分隔的正整数列表(如 brand_ids=1,2,3),忽略非法项。
func parseUint32CSV(s string) []uint32 {
if strings.TrimSpace(s) == "" {
return nil
}
var out []uint32
for _, part := range strings.Split(s, ",") {
if n, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && n > 0 {
out = append(out, uint32(n))
}
}
return out
}
// parseIntSlice 把字符串数组解析为整型数组,忽略非法项。
func parseIntSlice(in []string) []int {
if len(in) == 0 {
return nil
}
var out []int
for _, v := range in {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
out = append(out, n)
}
}
return out
}
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
func parseLocale(c *gin.Context) string {

View File

@ -2,17 +2,17 @@ package handler
import (
"net/http"
"strconv"
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/pkg/response"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
)
// HistoryHandler 浏览历史接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。
// HistoryHandler 浏览历史接口(需 Bearer 鉴权,主挂载于 /api/v1/me,legacy /api/v1/auth 仅兼容旧前端)。
type HistoryHandler struct {
hist service.HistoryService
}
@ -32,15 +32,8 @@ func (h *HistoryHandler) List(c *gin.Context) {
response.Error(c, http.StatusUnauthorized, "unauthorized")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 24
}
items, total, err := h.hist.ListPaged(c.Request.Context(), userID, page, perPage)
p := pagination.Parse(c, "page", "per_page", 1, 24, 100)
items, total, err := h.hist.ListPaged(c.Request.Context(), userID, p.Page, p.PageSize)
if err != nil {
fail(c, err)
return
@ -48,7 +41,7 @@ func (h *HistoryHandler) List(c *gin.Context) {
response.Data(c, http.StatusOK, gin.H{
"items": items,
"total": total,
"last_page": response.LastPage(total, perPage),
"last_page": response.LastPage(total, p.PageSize),
})
}

View File

@ -3,7 +3,6 @@ package handler
import (
"errors"
"net/http"
"sort"
"strconv"
"fashionapi/internal/dto"
@ -64,39 +63,11 @@ func (h *IngestHandler) CrawlBrands(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"brands": brands})
}
// CrawlExists 图集预检接口(POST /admin/internal/crawl/exists,复用 IngestAuth HMAC 验签)。
//
// 爬虫在抓详情页之前批量问「这些 source_url 是否已爬取过」,跳过已存在的图集,
// 省掉「抓详情页 → 提取图片 URL → 上报 → worker 判重后丢弃」这一整套无效动作。
// 判定条件与 worker 判重一致,因此跳过是安全的:不会漏抓,也不会重复抓。
func (h *IngestHandler) CrawlExists(c *gin.Context) {
var req dto.CrawlExistsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
exists, err := h.ingest.ExistsSourceURLs(c.Request.Context(), req.SourceURLs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
existing := make([]string, 0, len(exists))
for u := range exists {
existing = append(existing, u)
}
// 排序保证输出稳定,便于日志比对与测试断言。
sort.Strings(existing)
c.JSON(http.StatusOK, dto.CrawlExistsResponse{
Existing: existing,
ExistingCount: len(existing),
})
}
func isClientErr(err error) bool {
// service 层 NewError 带 http 状态码;这里简单按消息前缀判断,避免暴露内部类型。
if err == nil {
return false
}
msg := err.Error()
return len(msg) > 0 && (msg == "source_url required" || msg == "brand_uid required")
return len(msg) > 0 && msg == "brand_uid required"
}

View File

@ -6,7 +6,6 @@ import (
"strings"
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/response"
"fashionapi/internal/service"
@ -31,7 +30,7 @@ func NewStreetSnapHandler(svc service.StreetSnapService) *StreetSnapHandler {
// 查询参数:
// - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条)
// - year 年份(整数),0 表示不过滤
// - city 地区/城市(精确匹配 city 列),'' 表示不过滤
// - city 地区/城市(精确匹配 city 列),” 表示不过滤
// - sort image_count(默认,按图片数降序)/ year_desc / year_asc
// - locale cn / en
//
@ -79,7 +78,7 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
return
}
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), middleware.IsVIP(middleware.TierFrom(c)))
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
if err != nil {
fail(c, err)
return