update
This commit is contained in:
120
internal/handler/article_handler.go
Normal file
120
internal/handler/article_handler.go
Normal file
@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/pkg/response"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ArticleHandler 对外公开的文章接口。
|
||||
type ArticleHandler struct {
|
||||
articles service.ArticleService
|
||||
}
|
||||
|
||||
// NewArticleHandler 创建文章 handler。
|
||||
func NewArticleHandler(articles service.ArticleService) *ArticleHandler {
|
||||
return &ArticleHandler{articles: articles}
|
||||
}
|
||||
|
||||
// List 文章列表(分页 + 多条件筛选 + 排序)。
|
||||
//
|
||||
// GET /api/public/articles
|
||||
//
|
||||
// 查询参数:
|
||||
// - page / size 分页,size 默认 12、上限 100
|
||||
// - keyword 标题模糊搜索
|
||||
// - brand_id 单个品牌 id
|
||||
// - brand_ids 多个品牌 id,逗号分隔
|
||||
// - collection_type rtw / menswear / couture / resort / pre_fall(可重复传实现多选)
|
||||
// - season spring / fall(可重复传)
|
||||
// - season_code SS26 / FW25,精确匹配
|
||||
// - year 年份(可重复传)
|
||||
// - sort newest(默认)/ year_desc / year_asc / image_count
|
||||
// - with_images 附带每篇前 N 张图,上限 12
|
||||
func (h *ArticleHandler) List(c *gin.Context) {
|
||||
q := dto.ArticleQuery{
|
||||
Page: queryInt(c, "page", 1),
|
||||
Size: queryInt(c, "size", 12),
|
||||
Keyword: c.Query("keyword"),
|
||||
BrandID: decodeBrandID(c.Query("brand_id")),
|
||||
BrandIDs: decodeBrandIDs(c.Query("brand_ids")),
|
||||
CollectionTypes: c.QueryArray("collection_type"),
|
||||
Seasons: c.QueryArray("season"),
|
||||
SeasonCode: c.Query("season_code"),
|
||||
Years: parseIntSlice(c.QueryArray("year")),
|
||||
Sort: c.DefaultQuery("sort", "newest"),
|
||||
WithImages: queryInt(c, "with_images", 0),
|
||||
Locale: parseLocale(c),
|
||||
}
|
||||
q.Normalize()
|
||||
|
||||
list, total, err := h.articles.List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Page(c, http.StatusOK, list, total, q.Page, q.Size)
|
||||
}
|
||||
|
||||
// Detail 文章详情(含完整图片集)。
|
||||
//
|
||||
// GET /api/public/articles/:id
|
||||
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
|
||||
func (h *ArticleHandler) Detail(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.Param("id"))
|
||||
if raw == "" {
|
||||
response.Error(c, http.StatusBadRequest, "缺少文章 id")
|
||||
return
|
||||
}
|
||||
numeric, err := hashid.Decode(raw)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "文章不存在")
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Data(c, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// decodeBrandID 把对外编码串还原为数字主键;非法或为空返回 ""(表示不过滤)。
|
||||
func decodeBrandID(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
n, err := hashid.Decode(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(uint64(n), 10)
|
||||
}
|
||||
|
||||
// decodeBrandIDs 解析逗号分隔的编码串列表,逐个解码为数字主键,跳过非法项。
|
||||
func decodeBrandIDs(s string) []uint32 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []uint32
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if n, err := hashid.Decode(p); err == nil {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
89
internal/handler/auth_handler.go
Normal file
89
internal/handler/auth_handler.go
Normal file
@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/middleware"
|
||||
"fashionapi/internal/pkg/response"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthHandler 账号体系接口(JWT 无状态)。
|
||||
type AuthHandler struct {
|
||||
auth service.AuthService
|
||||
}
|
||||
|
||||
// NewAuthHandler 创建账号 handler。
|
||||
func NewAuthHandler(auth service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{auth: auth}
|
||||
}
|
||||
|
||||
// Register 注册。
|
||||
//
|
||||
// POST /api/auth/register
|
||||
// 请求体:{ username, email, password }
|
||||
// 成功:201 { token, user }
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req dto.AuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.auth.Register(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// Login 登录。
|
||||
//
|
||||
// POST /api/auth/login
|
||||
// 请求体:{ account, password },account 可填邮箱或用户名
|
||||
// 成功:200 { token, user }
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req dto.AuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.auth.Login(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Me 当前登录用户,需 Authorization: Bearer <token>。
|
||||
//
|
||||
// GET /api/auth/me
|
||||
// 成功:200 { user }
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFrom(c)
|
||||
if !ok {
|
||||
response.Error(c, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.auth.Me(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
}
|
||||
|
||||
// Logout 登出。
|
||||
//
|
||||
// POST /api/auth/logout
|
||||
// 无状态 JWT 服务端无需作废令牌,由客户端丢弃即可;保留接口以便将来扩展黑名单。
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
56
internal/handler/brand_handler.go
Normal file
56
internal/handler/brand_handler.go
Normal file
@ -0,0 +1,56 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/response"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BrandHandler 对外公开的品牌接口。
|
||||
type BrandHandler struct {
|
||||
brands service.BrandService
|
||||
}
|
||||
|
||||
// NewBrandHandler 创建品牌 handler。
|
||||
func NewBrandHandler(brands service.BrandService) *BrandHandler {
|
||||
return &BrandHandler{brands: brands}
|
||||
}
|
||||
|
||||
// List 品牌列表(A-Z 字母索引 / 搜索 / 分页)。
|
||||
//
|
||||
// GET /api/public/brands
|
||||
//
|
||||
// 查询参数:
|
||||
// - letter A-Z 字母;other 表示中文分桶;缺省返回全部拉丁字母开头的品牌
|
||||
// - keyword 品牌名 / 展示名模糊搜索
|
||||
// - page / size 分页,size 默认 200、上限 500
|
||||
// - only_with_articles 默认 1,只返回有走秀档案的品牌;传 0 返回全部
|
||||
// - featured 传 1 时只返回"代表品牌"精选集合
|
||||
// - metric 精选排名指标:images(默认)/ shows
|
||||
// - limit 精选集合大小,默认 200、上限 500
|
||||
func (h *BrandHandler) List(c *gin.Context) {
|
||||
q := dto.BrandQuery{
|
||||
Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
Page: queryInt(c, "page", 1),
|
||||
Size: queryInt(c, "size", 200),
|
||||
OnlyWithArticles: queryBool(c, "only_with_articles", true),
|
||||
Featured: queryBool(c, "featured", false),
|
||||
Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))),
|
||||
Limit: queryInt(c, "limit", 200),
|
||||
Locale: parseLocale(c),
|
||||
}
|
||||
q.Normalize()
|
||||
|
||||
list, total, err := h.brands.List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Page(c, http.StatusOK, list, total, q.Page, q.Size)
|
||||
}
|
||||
109
internal/handler/handler.go
Normal file
109
internal/handler/handler.go
Normal file
@ -0,0 +1,109 @@
|
||||
// Package handler HTTP 处理层。
|
||||
//
|
||||
// 职责边界:只做三件事 —— 解析请求参数、调用 service、写出响应。
|
||||
// 不包含任何 SQL 与业务规则。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fashionapi/internal/pkg/response"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// fail 把 service 层错误翻译为 HTTP 响应。
|
||||
//
|
||||
// 业务错误携带自己的状态码与文案,原样透传(保证与原项目逐字一致);
|
||||
// 其余未预期错误统一按 500 处理,避免内部细节泄漏成不可控的响应。
|
||||
func fail(c *gin.Context, err error) {
|
||||
var svcErr *service.Error
|
||||
if errors.As(err, &svcErr) {
|
||||
response.Error(c, svcErr.Status, svcErr.Message)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// queryInt 读取整型查询参数,缺失或非法时返回默认值。
|
||||
func queryInt(c *gin.Context, key string, def int) int {
|
||||
v := strings.TrimSpace(c.Query(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// queryBool 读取布尔查询参数。
|
||||
//
|
||||
// def 为该参数缺失时的取值:
|
||||
// - def=true(如 only_with_articles):只有显式传 0 / false 才关闭;
|
||||
// - def=false(如 featured):只有显式传 1 / true 才开启。
|
||||
//
|
||||
// 这两种语义都来自原项目,必须保持,否则前端 A-Z 索引的数据量会突变。
|
||||
func queryBool(c *gin.Context, key string, def bool) bool {
|
||||
v := strings.ToLower(strings.TrimSpace(c.Query(key)))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
if def {
|
||||
return v != "0" && v != "false"
|
||||
}
|
||||
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 {
|
||||
if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" {
|
||||
return l
|
||||
}
|
||||
if al := c.GetHeader("Accept-Language"); al != "" {
|
||||
for _, part := range strings.Split(al, ",") {
|
||||
code := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0]))
|
||||
switch {
|
||||
case code == "zh" || strings.HasPrefix(code, "zh-"):
|
||||
return "cn"
|
||||
case strings.HasPrefix(code, "en"):
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
18
internal/handler/health_handler.go
Normal file
18
internal/handler/health_handler.go
Normal file
@ -0,0 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HealthHandler 健康检查,供容器编排与反向代理探活使用。
|
||||
type HealthHandler struct{}
|
||||
|
||||
// NewHealthHandler 创建健康检查 handler。
|
||||
func NewHealthHandler() *HealthHandler { return &HealthHandler{} }
|
||||
|
||||
// Check GET /api/health
|
||||
func (h *HealthHandler) Check(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
90
internal/handler/ssg_handler.go
Normal file
90
internal/handler/ssg_handler.go
Normal file
@ -0,0 +1,90 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/response"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SSGHandler 承载「仅供构建期(astro build)使用的内部接口」。
|
||||
//
|
||||
// 这些接口返回全量/全 ID 数据,理论上不应暴露给公网,因此:
|
||||
// - 它们只挂在独立的内部端口(默认 8091,见 router.NewSSG)上;
|
||||
// - 该端口由 docker-compose 绑定 127.0.0.1(回环),nginx 也绝不反代;
|
||||
// - 复用现有 service 层逻辑,与对外公开接口保持同一份数据源与映射规则,
|
||||
// 不会出现「构建接口与线上接口返回结构不一致」的坑。
|
||||
type SSGHandler struct {
|
||||
brands service.BrandService
|
||||
articles service.ArticleService
|
||||
}
|
||||
|
||||
// NewSSGHandler 创建 SSG handler。
|
||||
func NewSSGHandler(brands service.BrandService, articles service.ArticleService) *SSGHandler {
|
||||
return &SSGHandler{brands: brands, articles: articles}
|
||||
}
|
||||
|
||||
// Popular 首页「热门品牌」区块数据(SSG 内部版)。
|
||||
//
|
||||
// GET /api/ssg/brands/popular?limit=20
|
||||
//
|
||||
// 与公开版逻辑完全一致(复用 service.BrandService.Popular),只是挪到了内部端口,
|
||||
// 不再挂在对外公开的 /api/public 下。limit 默认 20、上限 100。
|
||||
func (h *SSGHandler) Popular(c *gin.Context) {
|
||||
limit := queryInt(c, "limit", 20)
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
list, err := h.brands.Popular(c.Request.Context(), limit, parseLocale(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Data(c, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// Brands 品牌索引页(HBX 风格 A-Z)的 SSG 全量数据。
|
||||
//
|
||||
// GET /api/ssg/brands?size=300&only_with_articles=1&featured=1
|
||||
//
|
||||
// 直接复用 BrandQuery + service.BrandService.List,支持字母/搜索/精选/分页等全部参数,
|
||||
// 返回 {data:[...]} 与公开列表同构,前端 brands.astro 无需任何适配。
|
||||
func (h *SSGHandler) Brands(c *gin.Context) {
|
||||
q := dto.BrandQuery{
|
||||
Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
Page: queryInt(c, "page", 1),
|
||||
Size: queryInt(c, "size", 200),
|
||||
OnlyWithArticles: queryBool(c, "only_with_articles", true),
|
||||
Featured: queryBool(c, "featured", false),
|
||||
Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))),
|
||||
Limit: queryInt(c, "limit", 200),
|
||||
Locale: parseLocale(c),
|
||||
}
|
||||
q.Normalize()
|
||||
|
||||
list, total, err := h.brands.List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Page(c, http.StatusOK, list, total, q.Page, q.Size)
|
||||
}
|
||||
|
||||
// ArticleIDs 供 SSG 的 getStaticPaths 枚举详情页路径用。
|
||||
//
|
||||
// GET /api/ssg/articles/ids
|
||||
//
|
||||
// 只回 [id,...](升序),不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省带宽。
|
||||
func (h *SSGHandler) ArticleIDs(c *gin.Context) {
|
||||
ids, err := h.articles.IDs(c.Request.Context())
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Data(c, http.StatusOK, ids)
|
||||
}
|
||||
Reference in New Issue
Block a user