Zuncle 53e5d81fa8 fix(auth): 商品详情接口移至公开路由,修复未登录浏览触发登录弹窗
问题背景:
- 平台审核失败,原因:小程序页面未完整浏览即要求授权登录,属于登录规范不合规
- GET /api/app/products/:id 位于认证路由组,未登录用户访问返回 401,
  触发前端全局拦截器弹出"账号登录已过期"弹窗

修改内容:
1. router.go: 将 GET /products/:id 从 appAuthApiRouter(认证组)
   移至 appPublicApiRouter(公开组),允许未登录用户浏览商品详情
2. product.go: 针对公开端点增加安全加固
   - 增加商品 ID 参数校验(ParseInt 错误和 id<=0)
   - 将兜底错误信息从 validation.Error(err) 改为通用提示
     "商品信息获取失败",防止原始 DB 错误泄露给未认证请求

影响范围:
- 仅影响 GET /api/app/products/:id 端点
- GET /api/app/products(商品列表)仍保留在认证组
- 该 handler 不依赖 JWT session 上下文,移至公开组后功能无变化
- 返回数据仅包含商品元信息(名称、图片、价格、库存),不含用户敏感数据
2026-03-26 14:35:25 +08:00

202 lines
6.8 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package app
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
prodsvc "bindbox-game/internal/service/product"
usersvc "bindbox-game/internal/service/user"
)
type productHandler struct {
logger logger.CustomLogger
readDB *dao.Query
product prodsvc.Service
user usersvc.Service
}
func NewProduct(logger logger.CustomLogger, db mysql.Repo, user usersvc.Service) *productHandler {
return &productHandler{logger: logger, readDB: dao.Use(db.GetDbR()), product: prodsvc.New(logger, db), user: user}
}
type listAppProductsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
CategoryID *int64 `form:"category_id"`
PriceMin *int64 `form:"price_min"`
PriceMax *int64 `form:"price_max"`
SalesMin *int64 `form:"sales_min"`
InStock *bool `form:"in_stock"`
SortBy string `form:"sort_by"`
Order string `form:"order"`
}
type listAppProductsItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
MainImage string `json:"main_image"`
Price int64 `json:"price"`
PointsRequired float64 `json:"points_required"` // 积分(分/rate`
Sales int64 `json:"sales"`
InStock bool `json:"in_stock"`
}
type listAppProductsResponse struct {
Total int64 `json:"total"`
CurrentPage int `json:"currentPage"`
PageSize int `json:"pageSize"`
List []listAppProductsItem `json:"list"`
}
// ListProductsForApp 商品列表
// @Summary 商品列表
// @Description 分页查询商品列表,支持分类筛选,返回分页信息与商品数组
// @Tags APP端.商品
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param page query int false "页码默认1"
// @Param page_size query int false "每页数量默认20"
// @Param category_id query int false "分类ID"
// @Success 200 {object} listAppProductsResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/products [get]
func (h *productHandler) ListProductsForApp() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listAppProductsRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
items, total, err := h.product.ListForApp(ctx.RequestContext(), prodsvc.AppListInput{CategoryID: req.CategoryID, PriceMin: req.PriceMin, PriceMax: req.PriceMax, SalesMin: req.SalesMin, InStock: req.InStock, SortBy: req.SortBy, Order: req.Order, Page: req.Page, PageSize: req.PageSize})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
rsp := &listAppProductsResponse{Total: total, CurrentPage: req.Page, PageSize: req.PageSize, List: make([]listAppProductsItem, len(items))}
for i, it := range items {
pts := h.user.CentsToPointsFloat(ctx.RequestContext(), it.Price)
rsp.List[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, PointsRequired: pts, Sales: it.Sales, InStock: it.InStock}
}
ctx.Payload(rsp)
}
}
type getAppProductDetailResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Album []string `json:"album"`
Price int64 `json:"price"`
PointsRequired float64 `json:"points_required"` // 积分(分/rate`
Sales int64 `json:"sales"`
Stock int64 `json:"stock"`
Description string `json:"description"`
Service []string `json:"service"`
Recommendations []listAppProductsItem `json:"recommendations"`
}
// GetProductDetailForApp 商品详情
// @Summary 商品详情
// @Description 根据商品ID返回完整商品信息含相册与同类推荐校验下架/缺货状态
// @Tags APP端.商品
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param id path int true "商品ID"
// @Success 200 {object} getAppProductDetailResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/products/{id} [get]
func (h *productHandler) GetProductDetailForApp() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的商品ID"))
return
}
d, err := h.product.GetDetailForApp(ctx.RequestContext(), id)
if err != nil {
if err.Error() == "PRODUCT_OFFSHELF" {
ctx.AbortWithError(core.Error(http.StatusOK, 20001, "商品已下架"))
return
}
if err.Error() == "PRODUCT_OUT_OF_STOCK" {
ctx.AbortWithError(core.Error(http.StatusOK, 20002, "商品缺货"))
return
}
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "商品信息获取失败"))
return
}
ptsDetail := h.user.CentsToPointsFloat(ctx.RequestContext(), d.Price)
rsp := &getAppProductDetailResponse{ID: d.ID, Name: d.Name, Album: d.Album, Price: d.Price, PointsRequired: ptsDetail, Sales: d.Sales, Stock: d.Stock, Description: d.Description, Service: d.Service, Recommendations: make([]listAppProductsItem, len(d.Recommendations))}
for i, it := range d.Recommendations {
ptsRec := h.user.CentsToPointsFloat(ctx.RequestContext(), it.Price)
rsp.Recommendations[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, PointsRequired: ptsRec, Sales: it.Sales, InStock: it.InStock}
}
ctx.Payload(rsp)
}
}
func parseImages(s string) []string {
var arr []string
// lightweight JSON array parser without comments
if s == "" {
return arr
}
// naive parse: trim brackets and split by comma, remove quotes
b := []byte(s)
if len(b) < 2 {
return arr
}
start, end := 0, len(b)
for start < end && (b[start] == '[' || b[start] == ' ' || b[start] == '\n' || b[start] == '\t') {
start++
}
for end > start && (b[end-1] == ']' || b[end-1] == ' ' || b[end-1] == '\n' || b[end-1] == '\t') {
end--
}
if start >= end {
return arr
}
content := string(b[start:end])
parts := []rune(content)
cur := ""
inStr := false
for _, r := range parts {
if r == '"' {
if inStr {
arr = append(arr, cur)
cur = ""
inStr = false
} else {
inStr = true
}
continue
}
if inStr {
cur += string(r)
}
}
return arr
}
func parseFirstImage(s string) string {
imgs := parseImages(s)
if len(imgs) > 0 {
return imgs[0]
}
return ""
}