Merge remote-tracking branch 'origin/zuncle'
This commit is contained in:
commit
eaf4af4ba4
@ -13,6 +13,7 @@ import (
|
|||||||
livestreamsvc "bindbox-game/internal/service/livestream"
|
livestreamsvc "bindbox-game/internal/service/livestream"
|
||||||
productsvc "bindbox-game/internal/service/product"
|
productsvc "bindbox-game/internal/service/product"
|
||||||
snapshotsvc "bindbox-game/internal/service/snapshot"
|
snapshotsvc "bindbox-game/internal/service/snapshot"
|
||||||
|
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||||
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
||||||
titlesvc "bindbox-game/internal/service/title"
|
titlesvc "bindbox-game/internal/service/title"
|
||||||
usersvc "bindbox-game/internal/service/user"
|
usersvc "bindbox-game/internal/service/user"
|
||||||
@ -37,6 +38,7 @@ type handler struct {
|
|||||||
rollbackSvc snapshotsvc.RollbackService
|
rollbackSvc snapshotsvc.RollbackService
|
||||||
douyinSvc douyinsvc.Service
|
douyinSvc douyinsvc.Service
|
||||||
livestream livestreamsvc.Service
|
livestream livestreamsvc.Service
|
||||||
|
synthesis synthesissvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
|
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
|
||||||
@ -63,5 +65,6 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
|
|||||||
rollbackSvc: rollbackSvc,
|
rollbackSvc: rollbackSvc,
|
||||||
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
||||||
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
||||||
|
synthesis: synthesissvc.New(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,18 +22,20 @@ type cardsRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cardStatResponse struct {
|
type cardStatResponse struct {
|
||||||
ItemCardSales int64 `json:"itemCardSales"`
|
ItemCardSales int64 `json:"itemCardSales"`
|
||||||
DrawCount int64 `json:"drawCount"`
|
DrawCount int64 `json:"drawCount"`
|
||||||
NewUsers int64 `json:"newUsers"`
|
NewUsers int64 `json:"newUsers"`
|
||||||
TotalPoints float64 `json:"totalPoints"`
|
TotalPoints float64 `json:"totalPoints"`
|
||||||
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
|
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
|
||||||
TotalCoupons int64 `json:"totalCoupons"`
|
TotalCoupons int64 `json:"totalCoupons"` // 存量优惠券数量
|
||||||
TotalItemCards int64 `json:"totalItemCards"`
|
TotalCouponValue int64 `json:"totalCouponValue"` // 优惠券总价值(分)
|
||||||
TotalGamePasses int64 `json:"totalGamePasses"`
|
TotalItemCards int64 `json:"totalItemCards"` // 存量道具卡
|
||||||
ItemCardChange string `json:"itemCardChange"`
|
TotalGamePasses int64 `json:"totalGamePasses"` // 存量次卡(余次)
|
||||||
DrawChange string `json:"drawChange"`
|
TotalGamePassValue int64 `json:"totalGamePassValue"` // 次卡总价值(分)
|
||||||
NewUserChange string `json:"newUserChange"`
|
ItemCardChange string `json:"itemCardChange"`
|
||||||
PointsChange string `json:"pointsChange"`
|
DrawChange string `json:"drawChange"`
|
||||||
|
NewUserChange string `json:"newUserChange"`
|
||||||
|
PointsChange string `json:"pointsChange"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) DashboardCards() core.HandlerFunc {
|
func (h *handler) DashboardCards() core.HandlerFunc {
|
||||||
@ -141,11 +143,23 @@ func (h *handler) DashboardCards() core.HandlerFunc {
|
|||||||
prevDelta = prevDeltaRows[0].Sum
|
prevDelta = prevDeltaRows[0].Sum
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量:存量优惠券 (未使用)
|
// 批量:存量优惠券 (未使用) 及优惠券总价值
|
||||||
tcCur, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
tcCur, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
Where(h.readDB.UserCoupons.Status.Eq(1)).
|
Where(h.readDB.UserCoupons.Status.Eq(1)).
|
||||||
Count()
|
Count()
|
||||||
|
|
||||||
|
// 计算优惠券总价值(关联system_coupons表获取面值)
|
||||||
|
var tcValue int64
|
||||||
|
tcValueResult := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Raw(`
|
||||||
|
SELECT COALESCE(SUM(sc.discount_value), 0) as total_value
|
||||||
|
FROM user_coupons uc
|
||||||
|
JOIN system_coupons sc ON sc.id = uc.coupon_id
|
||||||
|
WHERE uc.status = 1
|
||||||
|
`).Scan(&tcValue)
|
||||||
|
if tcValueResult.Error == nil {
|
||||||
|
// tcValue已经通过Scan赋值
|
||||||
|
}
|
||||||
|
|
||||||
// 批量:存量道具卡 (有效)
|
// 批量:存量道具卡 (有效)
|
||||||
ticCur, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
ticCur, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
Where(h.readDB.UserItemCards.Status.Eq(1)).
|
Where(h.readDB.UserItemCards.Status.Eq(1)).
|
||||||
@ -156,7 +170,7 @@ func (h *handler) DashboardCards() core.HandlerFunc {
|
|||||||
Where(h.readDB.UserInventory.Status.Eq(1)).
|
Where(h.readDB.UserInventory.Status.Eq(1)).
|
||||||
Count()
|
Count()
|
||||||
|
|
||||||
// 批量:存量次卡 (剩余次数)
|
// 批量:存量次卡 (剩余次数) 及次卡总价值
|
||||||
var tgpRows []struct{ Sum int64 }
|
var tgpRows []struct{ Sum int64 }
|
||||||
_ = h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
|
_ = h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
Where(h.readDB.UserGamePasses.ExpiredAt.Gt(time.Now())).
|
Where(h.readDB.UserGamePasses.ExpiredAt.Gt(time.Now())).
|
||||||
@ -168,14 +182,28 @@ func (h *handler) DashboardCards() core.HandlerFunc {
|
|||||||
tgpCur = tgpRows[0].Sum
|
tgpCur = tgpRows[0].Sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算次卡总价值(关联activities表获取单次价格)
|
||||||
|
var tgpValue int64
|
||||||
|
tgpValueResult := h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).UnderlyingDB().Raw(`
|
||||||
|
SELECT COALESCE(SUM(ugp.remaining * COALESCE(a.price_draw, 0)), 0) as total_value
|
||||||
|
FROM user_game_passes ugp
|
||||||
|
LEFT JOIN activities a ON a.id = ugp.activity_id
|
||||||
|
WHERE ugp.expired_at > NOW() OR ugp.expired_at IS NULL OR ugp.expired_at = '0000-00-00 00:00:00'
|
||||||
|
`).Scan(&tgpValue)
|
||||||
|
if tgpValueResult.Error != nil {
|
||||||
|
tgpValue = 0
|
||||||
|
}
|
||||||
|
|
||||||
rsp.ItemCardSales = icCur
|
rsp.ItemCardSales = icCur
|
||||||
rsp.DrawCount = dlCur
|
rsp.DrawCount = dlCur
|
||||||
rsp.NewUsers = nuCur
|
rsp.NewUsers = nuCur
|
||||||
rsp.TotalPoints = h.userSvc.CentsToPointsFloat(ctx.RequestContext(), tpCur)
|
rsp.TotalPoints = h.userSvc.CentsToPointsFloat(ctx.RequestContext(), tpCur)
|
||||||
rsp.TotalInventory = tinvCur
|
rsp.TotalInventory = tinvCur
|
||||||
rsp.TotalCoupons = tcCur
|
rsp.TotalCoupons = tcCur
|
||||||
|
rsp.TotalCouponValue = tcValue
|
||||||
rsp.TotalItemCards = ticCur
|
rsp.TotalItemCards = ticCur
|
||||||
rsp.TotalGamePasses = tgpCur
|
rsp.TotalGamePasses = tgpCur
|
||||||
|
rsp.TotalGamePassValue = tgpValue
|
||||||
rsp.ItemCardChange = percentChange(icPrev, icCur)
|
rsp.ItemCardChange = percentChange(icPrev, icCur)
|
||||||
rsp.DrawChange = percentChange(dlPrev, dlCur)
|
rsp.DrawChange = percentChange(dlPrev, dlCur)
|
||||||
rsp.NewUserChange = percentChange(nuPrev, nuCur)
|
rsp.NewUserChange = percentChange(nuPrev, nuCur)
|
||||||
|
|||||||
@ -1031,11 +1031,7 @@ func (h *handler) CancelOrder() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21006, "订单不存在"))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21006, "订单不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(order.ID), h.readDB.Orders.Status.Eq(1)).Updates(map[string]any{
|
if _, err = h.userSvc.CancelOrder(ctx.RequestContext(), order.UserID, order.ID, "admin_cancel"); err != nil {
|
||||||
h.readDB.Orders.Status.ColumnName().String(): 3,
|
|
||||||
h.readDB.Orders.CancelledAt.ColumnName().String(): time.Now(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,9 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type createProductCategoryRequest struct {
|
type createProductCategoryRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ParentID int64 `json:"parent_id"`
|
ParentID int64 `json:"parent_id"`
|
||||||
Status int32 `json:"status"`
|
Status int32 `json:"status"`
|
||||||
|
IsFragment *int32 `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createProductCategoryResponse struct {
|
type createProductCategoryResponse struct {
|
||||||
@ -44,7 +45,7 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.product.CreateCategory(ctx.RequestContext(), prodsvc.CreateCategoryInput{
|
item, err := h.product.CreateCategory(ctx.RequestContext(), prodsvc.CreateCategoryInput{
|
||||||
Name: req.Name, ParentID: req.ParentID, Status: req.Status,
|
Name: req.Name, ParentID: req.ParentID, Status: req.Status, IsFragment: req.IsFragment,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
@ -57,9 +58,10 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modifyProductCategoryRequest struct {
|
type modifyProductCategoryRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
ParentID *int64 `json:"parent_id"`
|
ParentID *int64 `json:"parent_id"`
|
||||||
Status *int32 `json:"status"`
|
Status *int32 `json:"status"`
|
||||||
|
IsFragment *int32 `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type pcSimpleMessage struct {
|
type pcSimpleMessage struct {
|
||||||
@ -90,7 +92,7 @@ func (h *handler) ModifyProductCategory() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.product.ModifyCategory(ctx.RequestContext(), id, prodsvc.ModifyCategoryInput{Name: req.Name, ParentID: req.ParentID, Status: req.Status}); err != nil {
|
if err := h.product.ModifyCategory(ctx.RequestContext(), id, prodsvc.ModifyCategoryInput{Name: req.Name, ParentID: req.ParentID, Status: req.Status, IsFragment: req.IsFragment}); err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -132,10 +134,11 @@ type listProductCategoriesRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type productCategoryListItem struct {
|
type productCategoryListItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ParentID int64 `json:"parent_id"`
|
ParentID int64 `json:"parent_id"`
|
||||||
Status int32 `json:"status"`
|
Status int32 `json:"status"`
|
||||||
|
IsFragment int32 `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
type listProductCategoriesResponse struct {
|
type listProductCategoriesResponse struct {
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
@ -175,7 +178,7 @@ func (h *handler) ListProductCategories() core.HandlerFunc {
|
|||||||
res.Total = total
|
res.Total = total
|
||||||
res.List = make([]productCategoryListItem, len(items))
|
res.List = make([]productCategoryListItem, len(items))
|
||||||
for i, it := range items {
|
for i, it := range items {
|
||||||
res.List[i] = productCategoryListItem{ID: it.ID, Name: it.Name, ParentID: it.ParentID, Status: it.Status}
|
res.List[i] = productCategoryListItem{ID: it.ID, Name: it.Name, ParentID: it.ParentID, Status: it.Status, IsFragment: it.IsFragment}
|
||||||
}
|
}
|
||||||
ctx.Payload(res)
|
ctx.Payload(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@ type listProductsRequest struct {
|
|||||||
Name string `form:"name"`
|
Name string `form:"name"`
|
||||||
CategoryID *int64 `form:"category_id"`
|
CategoryID *int64 `form:"category_id"`
|
||||||
Status *int32 `form:"status"`
|
Status *int32 `form:"status"`
|
||||||
|
IsFragment *int32 `form:"is_fragment"`
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"page_size"`
|
PageSize int `form:"page_size"`
|
||||||
}
|
}
|
||||||
@ -177,7 +178,7 @@ func (h *handler) ListProducts() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, total, err := h.product.ListProducts(ctx.RequestContext(), prodsvc.ListProductsInput{Name: req.Name, CategoryID: req.CategoryID, Status: req.Status, Page: req.Page, PageSize: req.PageSize})
|
items, total, err := h.product.ListProducts(ctx.RequestContext(), prodsvc.ListProductsInput{Name: req.Name, CategoryID: req.CategoryID, Status: req.Status, IsFragment: req.IsFragment, Page: req.Page, PageSize: req.PageSize})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@ -8,6 +9,7 @@ import (
|
|||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -429,3 +431,91 @@ func (h *handler) GetShippingOrderDetail() core.HandlerFunc {
|
|||||||
ctx.Payload(rsp)
|
ctx.Payload(rsp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cancelShippingRequest struct {
|
||||||
|
RecordIDs []int64 `json:"record_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelShippingResponse struct {
|
||||||
|
CancelledCount int64 `json:"cancelled_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCancelShipping 管理端撤销发货申请
|
||||||
|
// @Summary 管理端撤销发货申请
|
||||||
|
// @Description 将待发货(status=1)的记录撤销为已取消(status=5),并恢复对应库存状态
|
||||||
|
// @Tags 管理端.发货管理
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security LoginVerifyToken
|
||||||
|
// @Param RequestBody body cancelShippingRequest true "请求参数"
|
||||||
|
// @Success 200 {object} cancelShippingResponse
|
||||||
|
// @Failure 400 {object} code.Failure
|
||||||
|
// @Router /api/admin/shipping/orders/cancel [post]
|
||||||
|
func (h *handler) AdminCancelShipping() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(cancelShippingRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.RecordIDs) == 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "record_ids不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.RecordIDs) > 100 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "单次最多处理100条记录"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminID := ctx.SessionUserInfo().Id
|
||||||
|
var cancelledCount int64
|
||||||
|
|
||||||
|
err := h.writeDB.Transaction(func(tx *dao.Query) error {
|
||||||
|
records, err := tx.ShippingRecords.WithContext(ctx.RequestContext()).
|
||||||
|
Select(tx.ShippingRecords.ID, tx.ShippingRecords.InventoryID, tx.ShippingRecords.UserID).
|
||||||
|
Where(tx.ShippingRecords.ID.In(req.RecordIDs...)).
|
||||||
|
Where(tx.ShippingRecords.Status.Eq(1)).
|
||||||
|
Find()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query shipping records failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return fmt.Errorf("没有找到待发货记录,可能已被处理")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rec := range records {
|
||||||
|
res, err := tx.ShippingRecords.WithContext(ctx.RequestContext()).
|
||||||
|
Where(tx.ShippingRecords.ID.Eq(rec.ID)).
|
||||||
|
Where(tx.ShippingRecords.Status.Eq(1)).
|
||||||
|
Update(tx.ShippingRecords.Status, 5)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update shipping record failed: %w", err)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remark := fmt.Sprintf("|shipping_cancelled_by_admin:%d", adminID)
|
||||||
|
dbResult := tx.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().Exec(
|
||||||
|
"UPDATE user_inventory SET status=1, shipping_no='', remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
|
||||||
|
remark, rec.InventoryID, rec.UserID,
|
||||||
|
)
|
||||||
|
if dbResult.Error != nil {
|
||||||
|
return fmt.Errorf("restore inventory failed: %w", dbResult.Error)
|
||||||
|
}
|
||||||
|
if dbResult.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("restore inventory failed: inventory id=%d not matched", rec.InventoryID)
|
||||||
|
}
|
||||||
|
cancelledCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Payload(&cancelShippingResponse{CancelledCount: cancelledCount})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
114
internal/api/admin/synthesis_admin.go
Normal file
114
internal/api/admin/synthesis_admin.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"bindbox-game/internal/code"
|
||||||
|
"bindbox-game/internal/pkg/core"
|
||||||
|
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listRecipesRequest struct {
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listSynthesisLogsRequest struct {
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
UserID *int64 `form:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ListSynthesisRecipes() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(listRecipesRequest)
|
||||||
|
_ = ctx.ShouldBindForm(req)
|
||||||
|
list, total, err := h.synthesis.ListRecipes(ctx.RequestContext(), req.Page, req.PageSize)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]interface{}{"list": list, "total": total})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) GetSynthesisRecipe() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||||||
|
if id <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, err := h.synthesis.GetRecipe(ctx.RequestContext(), id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) CreateSynthesisRecipe() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(synthesissvc.CreateRecipeRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipe, err := h.synthesis.CreateRecipe(ctx.RequestContext(), *req)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ModifySynthesisRecipe() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||||||
|
if id <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := new(synthesissvc.CreateRecipeRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.synthesis.ModifyRecipe(ctx.RequestContext(), id, *req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) DeleteSynthesisRecipe() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||||||
|
if id <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.synthesis.DeleteRecipe(ctx.RequestContext(), id); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ListSynthesisLogs() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(listSynthesisLogsRequest)
|
||||||
|
_ = ctx.ShouldBindForm(req)
|
||||||
|
list, total, err := h.synthesis.ListLogs(ctx.RequestContext(), req.Page, req.PageSize, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]interface{}{"list": list, "total": total})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/logger"
|
"bindbox-game/internal/pkg/logger"
|
||||||
@ -120,7 +122,8 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
|
|||||||
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, PointsRequired: pts, Status: it.Status, Supported: true}
|
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, PointsRequired: pts, Status: it.Status, Supported: true}
|
||||||
}
|
}
|
||||||
case "coupon":
|
case "coupon":
|
||||||
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1), h.readDB.SystemCoupons.ShowInMiniapp.Eq(1))
|
now := time.Now()
|
||||||
|
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1), h.readDB.SystemCoupons.ShowInMiniapp.Eq(1), h.readDB.SystemCoupons.ValidEnd.Gt(now))
|
||||||
// 关键词筛选
|
// 关键词筛选
|
||||||
if req.Keyword != "" {
|
if req.Keyword != "" {
|
||||||
q = q.Where(h.readDB.SystemCoupons.Name.Like("%" + req.Keyword + "%"))
|
q = q.Where(h.readDB.SystemCoupons.Name.Like("%" + req.Keyword + "%"))
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"bindbox-game/internal/service/douyin"
|
"bindbox-game/internal/service/douyin"
|
||||||
gamesvc "bindbox-game/internal/service/game"
|
gamesvc "bindbox-game/internal/service/game"
|
||||||
"bindbox-game/internal/service/sysconfig"
|
"bindbox-game/internal/service/sysconfig"
|
||||||
|
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||||
tasksvc "bindbox-game/internal/service/task_center"
|
tasksvc "bindbox-game/internal/service/task_center"
|
||||||
titlesvc "bindbox-game/internal/service/title"
|
titlesvc "bindbox-game/internal/service/title"
|
||||||
usersvc "bindbox-game/internal/service/user"
|
usersvc "bindbox-game/internal/service/user"
|
||||||
@ -18,8 +19,9 @@ type handler struct {
|
|||||||
readDB *dao.Query
|
readDB *dao.Query
|
||||||
user usersvc.Service
|
user usersvc.Service
|
||||||
task tasksvc.Service
|
task tasksvc.Service
|
||||||
douyin douyin.Service
|
douyin douyin.Service
|
||||||
repo mysql.Repo
|
repo mysql.Repo
|
||||||
|
synthesis synthesissvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
|
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
|
||||||
@ -32,7 +34,8 @@ func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *ha
|
|||||||
readDB: dao.Use(db.GetDbR()),
|
readDB: dao.Use(db.GetDbR()),
|
||||||
user: userSvc,
|
user: userSvc,
|
||||||
task: taskSvc,
|
task: taskSvc,
|
||||||
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
||||||
repo: db,
|
repo: db,
|
||||||
|
synthesis: synthesissvc.New(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type redeemCouponRequest struct {
|
type redeemCouponRequest struct {
|
||||||
@ -52,6 +54,10 @@ func (h *handler) RedeemPointsToCoupon() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, "only amount coupons supported"))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, "only amount coupons supported"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !sc.ValidEnd.IsZero() && sc.ValidEnd.Before(time.Now()) {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150005, "该优惠券模板已过期,无法兑换"))
|
||||||
|
return
|
||||||
|
}
|
||||||
// sc.DiscountValue 是优惠券面值(分),直接用于扣除
|
// sc.DiscountValue 是优惠券面值(分),直接用于扣除
|
||||||
// 例如:30 元优惠券 = 3000 分
|
// 例如:30 元优惠券 = 3000 分
|
||||||
needCents := sc.DiscountValue
|
needCents := sc.DiscountValue
|
||||||
|
|||||||
60
internal/api/user/synthesis_app.go
Normal file
60
internal/api/user/synthesis_app.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"bindbox-game/internal/code"
|
||||||
|
"bindbox-game/internal/pkg/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type synthesizeRequest struct {
|
||||||
|
RecipeID int64 `json:"recipe_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listSynthesisLogsAppRequest struct {
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ListSynthesisRecipesForUser() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
list, err := h.synthesis.GetAvailableRecipesForUser(ctx.RequestContext(), userID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]interface{}{"list": list})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) DoSynthesis() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(synthesizeRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil || req.RecipeID <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid recipe_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
inv, err := h.synthesis.Synthesize(ctx.RequestContext(), userID, req.RecipeID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ListSynthesisLogsForUser() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
userID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
req := new(listSynthesisLogsAppRequest)
|
||||||
|
_ = ctx.ShouldBindForm(req)
|
||||||
|
list, total, err := h.synthesis.ListLogs(ctx.RequestContext(), req.Page, req.PageSize, &userID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]interface{}{"list": list, "total": total})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TableNameFragmentSynthesisLogs = "fragment_synthesis_logs"
|
||||||
|
|
||||||
|
type FragmentSynthesisLogs struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;comment:用户ID" json:"user_id"`
|
||||||
|
RecipeID int64 `gorm:"column:recipe_id;not null;comment:配方ID" json:"recipe_id"`
|
||||||
|
ConsumedInventoryIDs string `gorm:"column:consumed_inventory_ids;type:json;not null;comment:消耗的碎片资产ID列表" json:"consumed_inventory_ids"`
|
||||||
|
ProducedInventoryID int64 `gorm:"column:produced_inventory_id;not null;comment:合成产出的资产ID" json:"produced_inventory_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FragmentSynthesisLogs) TableName() string {
|
||||||
|
return TableNameFragmentSynthesisLogs
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TableNameFragmentSynthesisRecipeMaterials = "fragment_synthesis_recipe_materials"
|
||||||
|
|
||||||
|
type FragmentSynthesisRecipeMaterials struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"`
|
||||||
|
RecipeID int64 `gorm:"column:recipe_id;not null;comment:配方ID" json:"recipe_id"`
|
||||||
|
FragmentProductID int64 `gorm:"column:fragment_product_id;not null;comment:碎片商品ID" json:"fragment_product_id"`
|
||||||
|
RequiredCount int32 `gorm:"column:required_count;not null;default:1;comment:该碎片所需数量" json:"required_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FragmentSynthesisRecipeMaterials) TableName() string {
|
||||||
|
return TableNameFragmentSynthesisRecipeMaterials
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TableNameFragmentSynthesisRecipes = "fragment_synthesis_recipes"
|
||||||
|
|
||||||
|
type FragmentSynthesisRecipes struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"`
|
||||||
|
Name string `gorm:"column:name;not null;comment:合成配方名称" json:"name"`
|
||||||
|
Description string `gorm:"column:description;not null;default:'';comment:合成配方描述" json:"description"`
|
||||||
|
TargetProductID int64 `gorm:"column:target_product_id;not null;comment:合成目标商品ID" json:"target_product_id"`
|
||||||
|
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 2禁用" json:"status"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FragmentSynthesisRecipes) TableName() string {
|
||||||
|
return TableNameFragmentSynthesisRecipes
|
||||||
|
}
|
||||||
@ -19,8 +19,9 @@ type ProductCategories struct {
|
|||||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
|
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
|
||||||
Name string `gorm:"column:name;not null;comment:分类名称" json:"name"` // 分类名称
|
Name string `gorm:"column:name;not null;comment:分类名称" json:"name"` // 分类名称
|
||||||
ParentID int64 `gorm:"column:parent_id;comment:父分类ID(可空)" json:"parent_id"` // 父分类ID(可空)
|
ParentID int64 `gorm:"column:parent_id;comment:父分类ID(可空)" json:"parent_id"` // 父分类ID(可空)
|
||||||
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 2禁用" json:"status"` // 状态:1启用 2禁用
|
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 2禁用" json:"status"` // 状态:1启用 2禁用
|
||||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
IsFragment int32 `gorm:"column:is_fragment;not null;default:0;comment:是否碎片分类:0否 1是" json:"is_fragment"` // 是否碎片分类:0否 1是
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName ProductCategories's table name
|
// TableName ProductCategories's table name
|
||||||
|
|||||||
@ -364,6 +364,15 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
|||||||
adminAuthApiRouter.GET("/shipping/orders", intc.RequireAdminAction("shipping:view"), adminHandler.ListShippingOrders())
|
adminAuthApiRouter.GET("/shipping/orders", intc.RequireAdminAction("shipping:view"), adminHandler.ListShippingOrders())
|
||||||
adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail())
|
adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail())
|
||||||
adminAuthApiRouter.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch())
|
adminAuthApiRouter.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch())
|
||||||
|
adminAuthApiRouter.POST("/shipping/orders/cancel", intc.RequireAdminAction("shipping:modify"), adminHandler.AdminCancelShipping())
|
||||||
|
|
||||||
|
// 碎片合成配方管理
|
||||||
|
adminAuthApiRouter.GET("/synthesis/recipes", adminHandler.ListSynthesisRecipes())
|
||||||
|
adminAuthApiRouter.GET("/synthesis/recipes/:id", adminHandler.GetSynthesisRecipe())
|
||||||
|
adminAuthApiRouter.POST("/synthesis/recipes", adminHandler.CreateSynthesisRecipe())
|
||||||
|
adminAuthApiRouter.PUT("/synthesis/recipes/:id", adminHandler.ModifySynthesisRecipe())
|
||||||
|
adminAuthApiRouter.DELETE("/synthesis/recipes/:id", adminHandler.DeleteSynthesisRecipe())
|
||||||
|
adminAuthApiRouter.GET("/synthesis/logs", adminHandler.ListSynthesisLogs())
|
||||||
|
|
||||||
adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund())
|
adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund())
|
||||||
adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())
|
adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())
|
||||||
@ -529,6 +538,11 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
|||||||
lotteryGroup.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints())
|
lotteryGroup.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 碎片合成
|
||||||
|
appAuthApiRouter.GET("/users/:user_id/synthesis/recipes", userHandler.ListSynthesisRecipesForUser())
|
||||||
|
appAuthApiRouter.POST("/users/:user_id/synthesis/do", userHandler.DoSynthesis())
|
||||||
|
appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser())
|
||||||
|
|
||||||
// 对对碰其他接口(不需要严查黑名单,或者已在preorder查过)
|
// 对对碰其他接口(不需要严查黑名单,或者已在preorder查过)
|
||||||
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
||||||
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
||||||
|
|||||||
@ -229,8 +229,7 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
|
|||||||
// 如果是金额券,status=1。
|
// 如果是金额券,status=1。
|
||||||
// 如果是满减券,status=1。
|
// 如果是满减券,status=1。
|
||||||
if uc.Status != 1 {
|
if uc.Status != 1 {
|
||||||
fmt.Printf("[优惠券] 状态不可用 id=%d status=%d\n", userCouponID, uc.Status)
|
return 0, nil, fmt.Errorf("优惠券不可用")
|
||||||
return 0, nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s 余额=%d\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string {
|
fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s 余额=%d\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string {
|
||||||
@ -243,25 +242,20 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
|
|||||||
sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First()
|
sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID)
|
return 0, nil, fmt.Errorf("优惠券模板不存在")
|
||||||
return 0, nil, nil
|
|
||||||
}
|
}
|
||||||
if uc.ValidStart.After(now) {
|
if uc.ValidStart.After(now) {
|
||||||
fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID)
|
return 0, nil, fmt.Errorf("优惠券未到使用时间")
|
||||||
return 0, nil, nil
|
|
||||||
}
|
}
|
||||||
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
|
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
|
||||||
fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID)
|
return 0, nil, fmt.Errorf("优惠券已过期")
|
||||||
return 0, nil, nil
|
|
||||||
}
|
}
|
||||||
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
|
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
|
||||||
if !scopeOK {
|
if !scopeOK {
|
||||||
fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType)
|
return 0, nil, fmt.Errorf("优惠券不适用于当前活动")
|
||||||
return 0, nil, nil
|
|
||||||
}
|
}
|
||||||
if order.TotalAmount < sc.MinSpend {
|
if order.TotalAmount < sc.MinSpend {
|
||||||
fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount)
|
return 0, nil, fmt.Errorf("订单金额未达优惠券使用门槛")
|
||||||
return 0, nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 50% 封顶
|
// 50% 封顶
|
||||||
|
|||||||
@ -33,24 +33,27 @@ type service struct {
|
|||||||
logger logger.CustomLogger
|
logger logger.CustomLogger
|
||||||
readDB *dao.Query
|
readDB *dao.Query
|
||||||
writeDB *dao.Query
|
writeDB *dao.Query
|
||||||
|
repo mysql.Repo
|
||||||
listCache map[string]cachedList
|
listCache map[string]cachedList
|
||||||
detailCache map[int64]cachedDetail
|
detailCache map[int64]cachedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
||||||
return &service{logger: l, readDB: dao.Use(db.GetDbR()), writeDB: dao.Use(db.GetDbW()), listCache: make(map[string]cachedList), detailCache: make(map[int64]cachedDetail)}
|
return &service{logger: l, readDB: dao.Use(db.GetDbR()), writeDB: dao.Use(db.GetDbW()), repo: db, listCache: make(map[string]cachedList), detailCache: make(map[int64]cachedDetail)}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCategoryInput struct {
|
type CreateCategoryInput struct {
|
||||||
Name string
|
Name string
|
||||||
ParentID int64
|
ParentID int64
|
||||||
Status int32
|
Status int32
|
||||||
|
IsFragment *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModifyCategoryInput struct {
|
type ModifyCategoryInput struct {
|
||||||
Name *string
|
Name *string
|
||||||
ParentID *int64
|
ParentID *int64
|
||||||
Status *int32
|
Status *int32
|
||||||
|
IsFragment *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListCategoriesInput struct {
|
type ListCategoriesInput struct {
|
||||||
@ -62,6 +65,9 @@ type ListCategoriesInput struct {
|
|||||||
|
|
||||||
func (s *service) CreateCategory(ctx context.Context, in CreateCategoryInput) (*model.ProductCategories, error) {
|
func (s *service) CreateCategory(ctx context.Context, in CreateCategoryInput) (*model.ProductCategories, error) {
|
||||||
m := &model.ProductCategories{Name: in.Name, ParentID: in.ParentID, Status: in.Status}
|
m := &model.ProductCategories{Name: in.Name, ParentID: in.ParentID, Status: in.Status}
|
||||||
|
if in.IsFragment != nil {
|
||||||
|
m.IsFragment = *in.IsFragment
|
||||||
|
}
|
||||||
if err := s.writeDB.ProductCategories.WithContext(ctx).Create(m); err != nil {
|
if err := s.writeDB.ProductCategories.WithContext(ctx).Create(m); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -80,6 +86,9 @@ func (s *service) ModifyCategory(ctx context.Context, id int64, in ModifyCategor
|
|||||||
if in.Status != nil {
|
if in.Status != nil {
|
||||||
set["status"] = *in.Status
|
set["status"] = *in.Status
|
||||||
}
|
}
|
||||||
|
if in.IsFragment != nil {
|
||||||
|
set["is_fragment"] = *in.IsFragment
|
||||||
|
}
|
||||||
if len(set) == 0 {
|
if len(set) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -140,6 +149,7 @@ type ListProductsInput struct {
|
|||||||
Name string
|
Name string
|
||||||
CategoryID *int64
|
CategoryID *int64
|
||||||
Status *int32
|
Status *int32
|
||||||
|
IsFragment *int32
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
}
|
}
|
||||||
@ -245,6 +255,17 @@ func (s *service) ListProducts(ctx context.Context, in ListProductsInput) (items
|
|||||||
if in.Status != nil {
|
if in.Status != nil {
|
||||||
q = q.Where(s.readDB.Products.Status.Eq(*in.Status))
|
q = q.Where(s.readDB.Products.Status.Eq(*in.Status))
|
||||||
}
|
}
|
||||||
|
if in.IsFragment != nil {
|
||||||
|
var fragCatIDs []int64
|
||||||
|
s.repo.GetDbR().WithContext(ctx).
|
||||||
|
Raw("SELECT id FROM product_categories WHERE is_fragment = ? AND deleted_at IS NULL", *in.IsFragment).
|
||||||
|
Scan(&fragCatIDs)
|
||||||
|
if len(fragCatIDs) > 0 {
|
||||||
|
q = q.Where(s.readDB.Products.CategoryID.In(fragCatIDs...))
|
||||||
|
} else {
|
||||||
|
return []*model.Products{}, 0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
total, err = q.Count()
|
total, err = q.Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
92
internal/service/product/product_fragment_filter_test.go
Normal file
92
internal/service/product/product_fragment_filter_test.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package product
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bindbox-game/internal/pkg/logger"
|
||||||
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newProductServiceForTest(t *testing.T) Service {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE product_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_id INTEGER DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_fragment INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
);
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create product_categories failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category_id INTEGER DEFAULT 0,
|
||||||
|
images_json TEXT DEFAULT '',
|
||||||
|
price INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sales INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
show_in_miniapp INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create products failed: %v", err)
|
||||||
|
}
|
||||||
|
lg, err := logger.NewCustomLogger()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create logger failed: %v", err)
|
||||||
|
}
|
||||||
|
return New(lg, mysql.NewTestRepo(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListProducts_IsFragmentZero_NoNonFragmentCategories_ReturnsEmpty(t *testing.T) {
|
||||||
|
svc := newProductServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fragCat := &model.ProductCategories{Name: "frag-cat", Status: 1, IsFragment: 1}
|
||||||
|
if err := svc.(*service).repo.GetDbW().Create(fragCat).Error; err != nil {
|
||||||
|
t.Fatalf("create fragment category failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.(*service).repo.GetDbW().Create(&model.Products{
|
||||||
|
Name: "frag-product",
|
||||||
|
CategoryID: fragCat.ID,
|
||||||
|
Price: 100,
|
||||||
|
Stock: 10,
|
||||||
|
Status: 1,
|
||||||
|
ShowInMiniapp: 1,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create fragment product failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFragment := int32(0)
|
||||||
|
items, total, err := svc.ListProducts(ctx, ListProductsInput{IsFragment: &isFragment, Page: 1, PageSize: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProducts failed: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("expected total=0, got %d", total)
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
t.Fatalf("expected no items, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
451
internal/service/synthesis/synthesis.go
Normal file
451
internal/service/synthesis/synthesis.go
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
package synthesis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
ListRecipes(ctx context.Context, page, size int) (list []*RecipeWithMaterials, total int64, err error)
|
||||||
|
GetRecipe(ctx context.Context, id int64) (*RecipeWithMaterials, error)
|
||||||
|
CreateRecipe(ctx context.Context, req CreateRecipeRequest) (*model.FragmentSynthesisRecipes, error)
|
||||||
|
ModifyRecipe(ctx context.Context, id int64, req CreateRecipeRequest) error
|
||||||
|
DeleteRecipe(ctx context.Context, id int64) error
|
||||||
|
GetAvailableRecipesForUser(ctx context.Context, userID int64) ([]*UserRecipeView, error)
|
||||||
|
Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error)
|
||||||
|
ListLogs(ctx context.Context, page, size int, userID *int64) (list []*SynthesisLogView, total int64, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaterialInput struct {
|
||||||
|
FragmentProductID int64 `json:"fragment_product_id"`
|
||||||
|
RequiredCount int32 `json:"required_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRecipeRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TargetProductID int64 `json:"target_product_id"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
Materials []MaterialInput `json:"materials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeWithMaterials struct {
|
||||||
|
*model.FragmentSynthesisRecipes
|
||||||
|
Materials []*model.FragmentSynthesisRecipeMaterials `json:"materials"`
|
||||||
|
TargetProduct *model.Products `json:"target_product,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserMaterialView struct {
|
||||||
|
FragmentProductID int64 `json:"fragment_product_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
RequiredCount int32 `json:"required_count"`
|
||||||
|
OwnedCount int64 `json:"owned_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRecipeView struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TargetProduct *model.Products `json:"target_product"`
|
||||||
|
CanSynthesize bool `json:"can_synthesize"`
|
||||||
|
Materials []UserMaterialView `json:"materials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SynthesisLogView struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
RecipeID int64 `json:"recipe_id"`
|
||||||
|
RecipeName string `json:"recipe_name"`
|
||||||
|
ProducedInventoryID int64 `json:"produced_inventory_id"`
|
||||||
|
TargetProductName string `json:"target_product_name"`
|
||||||
|
ConsumedCount int `json:"consumed_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
repo mysql.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db mysql.Repo) Service {
|
||||||
|
return &service{repo: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ListRecipes(ctx context.Context, page, size int) ([]*RecipeWithMaterials, int64, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
db := s.repo.GetDbR()
|
||||||
|
var total int64
|
||||||
|
if err := db.WithContext(ctx).Model(&model.FragmentSynthesisRecipes{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var recipes []*model.FragmentSynthesisRecipes
|
||||||
|
if err := db.WithContext(ctx).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&recipes).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
result := make([]*RecipeWithMaterials, 0, len(recipes))
|
||||||
|
for _, r := range recipes {
|
||||||
|
rm := &RecipeWithMaterials{FragmentSynthesisRecipes: r}
|
||||||
|
db.WithContext(ctx).Where("recipe_id = ?", r.ID).Find(&rm.Materials)
|
||||||
|
var p model.Products
|
||||||
|
if err := db.WithContext(ctx).Where("id = ?", r.TargetProductID).First(&p).Error; err == nil {
|
||||||
|
rm.TargetProduct = &p
|
||||||
|
}
|
||||||
|
result = append(result, rm)
|
||||||
|
}
|
||||||
|
return result, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetRecipe(ctx context.Context, id int64) (*RecipeWithMaterials, error) {
|
||||||
|
db := s.repo.GetDbR()
|
||||||
|
var r model.FragmentSynthesisRecipes
|
||||||
|
if err := db.WithContext(ctx).Where("id = ?", id).First(&r).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rm := &RecipeWithMaterials{FragmentSynthesisRecipes: &r}
|
||||||
|
db.WithContext(ctx).Where("recipe_id = ?", r.ID).Find(&rm.Materials)
|
||||||
|
var p model.Products
|
||||||
|
if err := db.WithContext(ctx).Where("id = ?", r.TargetProductID).First(&p).Error; err == nil {
|
||||||
|
rm.TargetProduct = &p
|
||||||
|
}
|
||||||
|
return rm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateRecipe(ctx context.Context, req CreateRecipeRequest) (*model.FragmentSynthesisRecipes, error) {
|
||||||
|
if len(req.Materials) == 0 {
|
||||||
|
return nil, fmt.Errorf("materials_required")
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
for _, m := range req.Materials {
|
||||||
|
if m.FragmentProductID <= 0 || m.RequiredCount <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid_material")
|
||||||
|
}
|
||||||
|
if _, ok := seen[m.FragmentProductID]; ok {
|
||||||
|
return nil, fmt.Errorf("duplicate_material")
|
||||||
|
}
|
||||||
|
seen[m.FragmentProductID] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := s.validateRecipeProducts(ctx, req.TargetProductID, req.Materials); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recipe := &model.FragmentSynthesisRecipes{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
TargetProductID: req.TargetProductID,
|
||||||
|
Status: req.Status,
|
||||||
|
}
|
||||||
|
if recipe.Status == 0 {
|
||||||
|
recipe.Status = 1
|
||||||
|
}
|
||||||
|
db := s.repo.GetDbW()
|
||||||
|
return recipe, db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(recipe).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, m := range req.Materials {
|
||||||
|
mat := &model.FragmentSynthesisRecipeMaterials{
|
||||||
|
RecipeID: recipe.ID,
|
||||||
|
FragmentProductID: m.FragmentProductID,
|
||||||
|
RequiredCount: m.RequiredCount,
|
||||||
|
}
|
||||||
|
if err := tx.Create(mat).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ModifyRecipe(ctx context.Context, id int64, req CreateRecipeRequest) error {
|
||||||
|
if len(req.Materials) == 0 {
|
||||||
|
return fmt.Errorf("materials_required")
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
for _, m := range req.Materials {
|
||||||
|
if m.FragmentProductID <= 0 || m.RequiredCount <= 0 {
|
||||||
|
return fmt.Errorf("invalid_material")
|
||||||
|
}
|
||||||
|
if _, ok := seen[m.FragmentProductID]; ok {
|
||||||
|
return fmt.Errorf("duplicate_material")
|
||||||
|
}
|
||||||
|
seen[m.FragmentProductID] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := s.validateRecipeProducts(ctx, req.TargetProductID, req.Materials); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
db := s.repo.GetDbW()
|
||||||
|
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.FragmentSynthesisRecipes{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"name": req.Name,
|
||||||
|
"description": req.Description,
|
||||||
|
"target_product_id": req.TargetProductID,
|
||||||
|
"status": req.Status,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("recipe_id = ?", id).Delete(&model.FragmentSynthesisRecipeMaterials{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, m := range req.Materials {
|
||||||
|
mat := &model.FragmentSynthesisRecipeMaterials{
|
||||||
|
RecipeID: id,
|
||||||
|
FragmentProductID: m.FragmentProductID,
|
||||||
|
RequiredCount: m.RequiredCount,
|
||||||
|
}
|
||||||
|
if err := tx.Create(mat).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) DeleteRecipe(ctx context.Context, id int64) error {
|
||||||
|
db := s.repo.GetDbW()
|
||||||
|
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("recipe_id = ?", id).Delete(&model.FragmentSynthesisRecipeMaterials{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id = ?", id).Delete(&model.FragmentSynthesisRecipes{}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64) ([]*UserRecipeView, error) {
|
||||||
|
db := s.repo.GetDbR()
|
||||||
|
var recipes []*model.FragmentSynthesisRecipes
|
||||||
|
if err := db.WithContext(ctx).Where("status = 1").Find(&recipes).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*UserRecipeView, 0, len(recipes))
|
||||||
|
for _, r := range recipes {
|
||||||
|
var materials []*model.FragmentSynthesisRecipeMaterials
|
||||||
|
db.WithContext(ctx).Where("recipe_id = ?", r.ID).Find(&materials)
|
||||||
|
|
||||||
|
var targetProduct model.Products
|
||||||
|
db.WithContext(ctx).Where("id = ?", r.TargetProductID).First(&targetProduct)
|
||||||
|
|
||||||
|
view := &UserRecipeView{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
Description: r.Description,
|
||||||
|
TargetProduct: &targetProduct,
|
||||||
|
CanSynthesize: true,
|
||||||
|
Materials: make([]UserMaterialView, 0, len(materials)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range materials {
|
||||||
|
var p model.Products
|
||||||
|
db.WithContext(ctx).Where("id = ?", m.FragmentProductID).First(&p)
|
||||||
|
|
||||||
|
var ownedCount int64
|
||||||
|
db.WithContext(ctx).Model(&model.UserInventory{}).
|
||||||
|
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
||||||
|
Count(&ownedCount)
|
||||||
|
|
||||||
|
if ownedCount < int64(m.RequiredCount) {
|
||||||
|
view.CanSynthesize = false
|
||||||
|
}
|
||||||
|
image := ""
|
||||||
|
if p.ImagesJSON != "" {
|
||||||
|
var imgs []string
|
||||||
|
if json.Unmarshal([]byte(p.ImagesJSON), &imgs) == nil && len(imgs) > 0 {
|
||||||
|
image = imgs[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.Materials = append(view.Materials, UserMaterialView{
|
||||||
|
FragmentProductID: m.FragmentProductID,
|
||||||
|
Name: p.Name,
|
||||||
|
Image: image,
|
||||||
|
RequiredCount: m.RequiredCount,
|
||||||
|
OwnedCount: ownedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result = append(result, view)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error) {
|
||||||
|
db := s.repo.GetDbR()
|
||||||
|
|
||||||
|
var recipe model.FragmentSynthesisRecipes
|
||||||
|
if err := db.WithContext(ctx).Where("id = ? AND status = 1", recipeID).First(&recipe).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("recipe_not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var materials []*model.FragmentSynthesisRecipeMaterials
|
||||||
|
db.WithContext(ctx).Where("recipe_id = ?", recipeID).Find(&materials)
|
||||||
|
if len(materials) == 0 {
|
||||||
|
return nil, fmt.Errorf("recipe_no_materials")
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetProduct model.Products
|
||||||
|
if err := db.WithContext(ctx).Where("id = ? AND status = 1", recipe.TargetProductID).First(&targetProduct).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("target_product_unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
type materialConsume struct {
|
||||||
|
ProductID int64
|
||||||
|
Required int32
|
||||||
|
InventoryIDs []int64
|
||||||
|
}
|
||||||
|
toConsume := make([]materialConsume, 0, len(materials))
|
||||||
|
|
||||||
|
for _, m := range materials {
|
||||||
|
var invList []*model.UserInventory
|
||||||
|
db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
|
||||||
|
Order("id ASC").
|
||||||
|
Limit(int(m.RequiredCount)).
|
||||||
|
Find(&invList)
|
||||||
|
|
||||||
|
if int32(len(invList)) < m.RequiredCount {
|
||||||
|
return nil, fmt.Errorf("insufficient_fragments")
|
||||||
|
}
|
||||||
|
ids := make([]int64, len(invList))
|
||||||
|
for i, inv := range invList {
|
||||||
|
ids[i] = inv.ID
|
||||||
|
}
|
||||||
|
toConsume = append(toConsume, materialConsume{
|
||||||
|
ProductID: m.FragmentProductID,
|
||||||
|
Required: m.RequiredCount,
|
||||||
|
InventoryIDs: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var newInv model.UserInventory
|
||||||
|
wdb := s.repo.GetDbW()
|
||||||
|
err := wdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
allConsumedIDs := make([]int64, 0)
|
||||||
|
for _, mc := range toConsume {
|
||||||
|
var locked []model.UserInventory
|
||||||
|
if err := tx.Raw("SELECT * FROM user_inventory WHERE id IN ? AND user_id = ? AND status = 1 FOR UPDATE", mc.InventoryIDs, userID).Scan(&locked).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if int32(len(locked)) < mc.Required {
|
||||||
|
return fmt.Errorf("insufficient_fragments")
|
||||||
|
}
|
||||||
|
if err := tx.Exec(
|
||||||
|
"UPDATE user_inventory SET status = 2, updated_at = NOW(3), remark = CONCAT(IFNULL(remark,''), '|synthesis_consumed:recipe_', ?) WHERE id IN ? AND user_id = ? AND status = 1",
|
||||||
|
recipeID, mc.InventoryIDs, userID,
|
||||||
|
).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
allConsumedIDs = append(allConsumedIDs, mc.InventoryIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
newInv = model.UserInventory{
|
||||||
|
UserID: userID,
|
||||||
|
ProductID: recipe.TargetProductID,
|
||||||
|
ValueCents: targetProduct.Price,
|
||||||
|
Status: 1,
|
||||||
|
Remark: fmt.Sprintf("synthesis_produced:recipe_%d", recipeID),
|
||||||
|
}
|
||||||
|
if err := tx.Omit("ValueSnapshotAt", "ShippingNo").Create(&newInv).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
consumedJSON, _ := json.Marshal(allConsumedIDs)
|
||||||
|
log := &model.FragmentSynthesisLogs{
|
||||||
|
UserID: userID,
|
||||||
|
RecipeID: recipeID,
|
||||||
|
ConsumedInventoryIDs: string(consumedJSON),
|
||||||
|
ProducedInventoryID: newInv.ID,
|
||||||
|
}
|
||||||
|
return tx.Create(log).Error
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &newInv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ListLogs(ctx context.Context, page, size int, userID *int64) ([]*SynthesisLogView, int64, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
db := s.repo.GetDbR()
|
||||||
|
q := db.WithContext(ctx).Model(&model.FragmentSynthesisLogs{})
|
||||||
|
if userID != nil && *userID > 0 {
|
||||||
|
q = q.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var logs []*model.FragmentSynthesisLogs
|
||||||
|
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
result := make([]*SynthesisLogView, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
view := &SynthesisLogView{
|
||||||
|
ID: l.ID,
|
||||||
|
CreatedAt: l.CreatedAt,
|
||||||
|
UserID: l.UserID,
|
||||||
|
RecipeID: l.RecipeID,
|
||||||
|
ProducedInventoryID: l.ProducedInventoryID,
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
json.Unmarshal([]byte(l.ConsumedInventoryIDs), &ids)
|
||||||
|
view.ConsumedCount = len(ids)
|
||||||
|
|
||||||
|
var recipe model.FragmentSynthesisRecipes
|
||||||
|
if db.WithContext(ctx).Unscoped().Where("id = ?", l.RecipeID).First(&recipe).Error == nil {
|
||||||
|
view.RecipeName = recipe.Name
|
||||||
|
var p model.Products
|
||||||
|
if db.WithContext(ctx).Where("id = ?", recipe.TargetProductID).First(&p).Error == nil {
|
||||||
|
view.TargetProductName = p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, view)
|
||||||
|
}
|
||||||
|
return result, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRecipeProducts 校验材料必须属于碎片分类,目标商品必须不属于碎片分类
|
||||||
|
func (s *service) validateRecipeProducts(ctx context.Context, targetProductID int64, materials []MaterialInput) error {
|
||||||
|
db := s.repo.GetDbR()
|
||||||
|
|
||||||
|
var fragCatIDs []int64
|
||||||
|
db.WithContext(ctx).Raw("SELECT id FROM product_categories WHERE is_fragment = 1 AND deleted_at IS NULL").Scan(&fragCatIDs)
|
||||||
|
fragCatSet := make(map[int64]struct{}, len(fragCatIDs))
|
||||||
|
for _, id := range fragCatIDs {
|
||||||
|
fragCatSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetProduct model.Products
|
||||||
|
if err := db.WithContext(ctx).Where("id = ?", targetProductID).First(&targetProduct).Error; err != nil {
|
||||||
|
return fmt.Errorf("target_product_not_found")
|
||||||
|
}
|
||||||
|
if _, isFrag := fragCatSet[targetProduct.CategoryID]; isFrag {
|
||||||
|
return fmt.Errorf("target_product_cannot_be_fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range materials {
|
||||||
|
var p model.Products
|
||||||
|
if err := db.WithContext(ctx).Where("id = ?", m.FragmentProductID).First(&p).Error; err != nil {
|
||||||
|
return fmt.Errorf("material_product_not_found:%d", m.FragmentProductID)
|
||||||
|
}
|
||||||
|
if _, isFrag := fragCatSet[p.CategoryID]; !isFrag {
|
||||||
|
return fmt.Errorf("material_must_be_fragment:%d", m.FragmentProductID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
95
internal/service/synthesis/synthesis_test.go
Normal file
95
internal/service/synthesis/synthesis_test.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
package synthesis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSynthesisServiceForTest(t *testing.T) *service {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE product_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
is_fragment INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
);
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create product_categories failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
);
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create products failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(mysql.NewTestRepo(db)).(*service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRecipeProducts_TargetCannotBeFragment(t *testing.T) {
|
||||||
|
svc := newSynthesisServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil {
|
||||||
|
t.Fatalf("seed categories failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (10, 1), (11, 1)").Error; err != nil {
|
||||||
|
t.Fatalf("seed products failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.validateRecipeProducts(ctx, 10, []MaterialInput{{FragmentProductID: 11, RequiredCount: 1}})
|
||||||
|
if err == nil || err.Error() != "target_product_cannot_be_fragment" {
|
||||||
|
t.Fatalf("expected target_product_cannot_be_fragment, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRecipeProducts_MaterialMustBeFragment(t *testing.T) {
|
||||||
|
svc := newSynthesisServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil {
|
||||||
|
t.Fatalf("seed categories failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (20, 2), (21, 2)").Error; err != nil {
|
||||||
|
t.Fatalf("seed products failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.validateRecipeProducts(ctx, 20, []MaterialInput{{FragmentProductID: 21, RequiredCount: 1}})
|
||||||
|
want := "material_must_be_fragment:21"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("expected %s, got: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRecipeProducts_ValidCombination(t *testing.T) {
|
||||||
|
svc := newSynthesisServiceForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil {
|
||||||
|
t.Fatalf("seed categories failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (30, 2), (31, 1)").Error; err != nil {
|
||||||
|
t.Fatalf("seed products failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.validateRecipeProducts(ctx, 30, []MaterialInput{{FragmentProductID: 31, RequiredCount: 2}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -51,6 +51,10 @@ func (s *service) CreateAddressShare(ctx context.Context, userID int64, inventor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", time.Time{}, err
|
return "", "", time.Time{}, err
|
||||||
}
|
}
|
||||||
|
fragIDs := s.getFragmentProductIDs(ctx, []*model.UserInventory{inv})
|
||||||
|
if _, isFrag := fragIDs[inv.ProductID]; isFrag {
|
||||||
|
return "", "", time.Time{}, fmt.Errorf("fragment_item_cannot_transfer")
|
||||||
|
}
|
||||||
token, err := signShareToken(userID, inventoryID, expiresAt)
|
token, err := signShareToken(userID, inventoryID, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", time.Time{}, err
|
return "", "", time.Time{}, err
|
||||||
@ -422,6 +426,9 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
|||||||
productIDs := make([]int64, 0, len(uniq))
|
productIDs := make([]int64, 0, len(uniq))
|
||||||
productIDSet := make(map[int64]struct{})
|
productIDSet := make(map[int64]struct{})
|
||||||
|
|
||||||
|
// 预先获取碎片分类的商品ID集合
|
||||||
|
fragmentPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||||
|
|
||||||
for _, id := range uniq {
|
for _, id := range uniq {
|
||||||
inv := invMap[id]
|
inv := invMap[id]
|
||||||
if inv == nil {
|
if inv == nil {
|
||||||
@ -459,6 +466,13 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
|||||||
}{ID: id, Reason: "invalid_status"})
|
}{ID: id, Reason: "invalid_status"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, isFrag := fragmentPIDs[inv.ProductID]; isFrag {
|
||||||
|
skipped = append(skipped, struct {
|
||||||
|
ID int64
|
||||||
|
Reason string
|
||||||
|
}{ID: id, Reason: "fragment_item_cannot_ship"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
validInvs = append(validInvs, inv)
|
validInvs = append(validInvs, inv)
|
||||||
if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 {
|
if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 {
|
||||||
productIDSet[inv.ProductID] = struct{}{}
|
productIDSet[inv.ProductID] = struct{}{}
|
||||||
@ -649,6 +663,21 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
|
|||||||
return 0, fmt.Errorf("no_valid_inventory")
|
return 0, fmt.Errorf("no_valid_inventory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3.1 过滤碎片分类资产(碎片不可兑换)
|
||||||
|
fragmentPIDs := s.getFragmentProductIDs(ctx, invList)
|
||||||
|
if len(fragmentPIDs) > 0 {
|
||||||
|
filtered := make([]*model.UserInventory, 0, len(invList))
|
||||||
|
for _, inv := range invList {
|
||||||
|
if _, isFrag := fragmentPIDs[inv.ProductID]; !isFrag {
|
||||||
|
filtered = append(filtered, inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return 0, fmt.Errorf("fragment_items_cannot_redeem")
|
||||||
|
}
|
||||||
|
invList = filtered
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||||
productIDs := make([]int64, 0, len(invList))
|
productIDs := make([]int64, 0, len(invList))
|
||||||
productIDSet := make(map[int64]struct{})
|
productIDSet := make(map[int64]struct{})
|
||||||
@ -788,3 +817,51 @@ func (s *service) VoidUserInventory(ctx context.Context, adminID int64, userID i
|
|||||||
_ = adminID
|
_ = adminID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getFragmentProductIDs 获取碎片分类下的所有商品ID集合
|
||||||
|
// 如果传入 invList,只查询这些资产涉及的商品对应的分类;否则查询所有碎片分类的商品
|
||||||
|
func (s *service) getFragmentProductIDs(ctx context.Context, invList []*model.UserInventory) map[int64]struct{} {
|
||||||
|
result := make(map[int64]struct{})
|
||||||
|
|
||||||
|
// 查询所有碎片分类ID
|
||||||
|
var fragCatIDs []int64
|
||||||
|
s.repo.GetDbR().WithContext(ctx).
|
||||||
|
Table("product_categories").
|
||||||
|
Where("is_fragment = 1 AND deleted_at IS NULL").
|
||||||
|
Pluck("id", &fragCatIDs)
|
||||||
|
if len(fragCatIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询碎片分类下的所有商品ID
|
||||||
|
var fragProdIDs []int64
|
||||||
|
if invList != nil {
|
||||||
|
pidSet := make(map[int64]struct{})
|
||||||
|
for _, inv := range invList {
|
||||||
|
if inv.ProductID > 0 {
|
||||||
|
pidSet[inv.ProductID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pids := make([]int64, 0, len(pidSet))
|
||||||
|
for pid := range pidSet {
|
||||||
|
pids = append(pids, pid)
|
||||||
|
}
|
||||||
|
if len(pids) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
s.repo.GetDbR().WithContext(ctx).
|
||||||
|
Table("products").
|
||||||
|
Where("id IN ? AND category_id IN ?", pids, fragCatIDs).
|
||||||
|
Pluck("id", &fragProdIDs)
|
||||||
|
} else {
|
||||||
|
s.repo.GetDbR().WithContext(ctx).
|
||||||
|
Table("products").
|
||||||
|
Where("category_id IN ? AND deleted_at IS NULL", fragCatIDs).
|
||||||
|
Pluck("id", &fragProdIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range fragProdIDs {
|
||||||
|
result[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@ -26,6 +26,9 @@ func (s *service) AddCoupon(ctx context.Context, userID int64, couponID int64) e
|
|||||||
}())
|
}())
|
||||||
return errors.New("coupon not found or disabled")
|
return errors.New("coupon not found or disabled")
|
||||||
}
|
}
|
||||||
|
if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) {
|
||||||
|
return errors.New("coupon template expired")
|
||||||
|
}
|
||||||
// 配额检查:若 TotalQuantity > 0 则限制发放总量
|
// 配额检查:若 TotalQuantity > 0 则限制发放总量
|
||||||
if tpl.TotalQuantity > 0 {
|
if tpl.TotalQuantity > 0 {
|
||||||
issued, ierr := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.CouponID.Eq(couponID)).Count()
|
issued, ierr := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.CouponID.Eq(couponID)).Count()
|
||||||
|
|||||||
@ -53,12 +53,22 @@ func (s *service) ListAppCoupons(ctx context.Context, userID int64, status int32
|
|||||||
Where("`"+tableName+"`.user_id = ?", userID)
|
Where("`"+tableName+"`.user_id = ?", userID)
|
||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
case 1: // 有效:余额 > 0 且 未过期
|
case 1: // 有效:余额 > 0 且 未过期(NULL/零值 valid_end 视为永久有效)
|
||||||
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
|
db = db.Where(
|
||||||
|
tableName+".balance_amount > ? AND "+
|
||||||
|
"("+tableName+".valid_end IS NULL OR "+tableName+".valid_end = ? OR "+tableName+".valid_end > ?) AND "+
|
||||||
|
tableName+".status IN (?, ?, ?)",
|
||||||
|
0, time.Time{}, now, 1, 2, 4,
|
||||||
|
)
|
||||||
case 2: // 已失效:余额用完 OR 已标记过期 OR 已过截止时间
|
case 2: // 已失效:余额用完 OR 已标记过期 OR 已过截止时间
|
||||||
db = db.Where("("+tableName+".balance_amount = ?) OR "+tableName+".status = ? OR "+tableName+".valid_end <= ?", 0, 3, now)
|
db = db.Where("("+tableName+".balance_amount = ?) OR "+tableName+".status = ? OR ("+tableName+".valid_end IS NOT NULL AND "+tableName+".valid_end != ? AND "+tableName+".valid_end <= ?)", 0, 3, time.Time{}, now)
|
||||||
default:
|
default:
|
||||||
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
|
db = db.Where(
|
||||||
|
tableName+".balance_amount > ? AND "+
|
||||||
|
"("+tableName+".valid_end IS NULL OR "+tableName+".valid_end = ? OR "+tableName+".valid_end > ?) AND "+
|
||||||
|
tableName+".status IN (?, ?, ?)",
|
||||||
|
0, time.Time{}, now, 1, 2, 4,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = db.Count(&total).Error; err != nil {
|
if err = db.Count(&total).Error; err != nil {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type AggregatedInventory struct {
|
|||||||
ShippingStatus int32 `json:"shipping_status"`
|
ShippingStatus int32 `json:"shipping_status"`
|
||||||
Status int32 `json:"status"` // 用于区分 1持有 3已处理
|
Status int32 `json:"status"` // 用于区分 1持有 3已处理
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
IsFragment bool `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int, status int32) (items []*InventoryWithProduct, total int64, err error) {
|
func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int, status int32) (items []*InventoryWithProduct, total int64, err error) {
|
||||||
@ -262,6 +263,8 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
pagedGroups := groupResults[start:end]
|
pagedGroups := groupResults[start:end]
|
||||||
|
|
||||||
// 2. 获取商品详情和发货状态
|
// 2. 获取商品详情和发货状态
|
||||||
|
// 预加载碎片分类商品ID
|
||||||
|
fragPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||||
items = make([]*AggregatedInventory, 0, len(pagedGroups))
|
items = make([]*AggregatedInventory, 0, len(pagedGroups))
|
||||||
for _, g := range pagedGroups {
|
for _, g := range pagedGroups {
|
||||||
// 查询该分组下的所有 inventory id
|
// 查询该分组下的所有 inventory id
|
||||||
@ -302,6 +305,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, isFrag := fragPIDs[g.ProductID]
|
||||||
items = append(items, &AggregatedInventory{
|
items = append(items, &AggregatedInventory{
|
||||||
ProductID: g.ProductID,
|
ProductID: g.ProductID,
|
||||||
ProductName: name,
|
ProductName: name,
|
||||||
@ -313,6 +317,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
ShippingStatus: shipStatus,
|
ShippingStatus: shipStatus,
|
||||||
Status: g.Status,
|
Status: g.Status,
|
||||||
UpdatedAt: g.UpdatedAt.Format("2006-01-02 15:04:05"),
|
UpdatedAt: g.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
IsFragment: isFrag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,32 +59,66 @@ func (s *service) CancelOrder(ctx context.Context, userID int64, orderID int64,
|
|||||||
|
|
||||||
// 4. 退还优惠券(恢复预扣的余额和状态)
|
// 4. 退还优惠券(恢复预扣的余额和状态)
|
||||||
if order.CouponID > 0 {
|
if order.CouponID > 0 {
|
||||||
var oc struct {
|
// 幂等校验:若已记录过 cancel_refund 流水则跳过
|
||||||
AppliedAmount int64
|
refundExists, err := tx.UserCouponLedger.WithContext(ctx).Where(
|
||||||
|
tx.UserCouponLedger.UserCouponID.Eq(order.CouponID),
|
||||||
|
tx.UserCouponLedger.OrderID.Eq(order.ID),
|
||||||
|
tx.UserCouponLedger.Action.Eq("cancel_refund"),
|
||||||
|
).Count()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
// 获取该订单实际扣减的优惠券金额
|
|
||||||
_ = tx.OrderCoupons.WithContext(ctx).Where(tx.OrderCoupons.OrderID.Eq(order.ID), tx.OrderCoupons.UserCouponID.Eq(order.CouponID)).Scan(&oc)
|
|
||||||
|
|
||||||
// 执行原子回退:增加余额 + 重置状态 + 清除占用订单
|
if refundExists == 0 {
|
||||||
res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
|
var oc struct {
|
||||||
UPDATE user_coupons
|
AppliedAmount int64
|
||||||
SET balance_amount = balance_amount + ?,
|
}
|
||||||
status = 1,
|
// 优先从 order_coupons 获取实际抵扣金额
|
||||||
used_order_id = 0,
|
if err := tx.OrderCoupons.WithContext(ctx).Where(
|
||||||
used_at = NULL
|
tx.OrderCoupons.OrderID.Eq(order.ID),
|
||||||
WHERE id = ? AND used_order_id = ?
|
tx.OrderCoupons.UserCouponID.Eq(order.CouponID),
|
||||||
`, oc.AppliedAmount, order.CouponID, order.ID)
|
).Scan(&oc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if res.RowsAffected > 0 {
|
// 兜底:order_coupons 无记录时,从流水中回推预扣金额
|
||||||
// 记录退还流水
|
if oc.AppliedAmount <= 0 {
|
||||||
_ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
|
if err := tx.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
|
||||||
UserID: userID,
|
SELECT COALESCE(SUM(CASE WHEN change_amount < 0 THEN -change_amount ELSE 0 END), 0) AS applied_amount
|
||||||
UserCouponID: order.CouponID,
|
FROM user_coupon_ledger
|
||||||
ChangeAmount: oc.AppliedAmount,
|
WHERE user_id = ? AND user_coupon_id = ? AND order_id = ? AND action IN ('reserve', 'usage')
|
||||||
OrderID: order.ID,
|
`, userID, order.CouponID, order.ID).Scan(&oc).Error; err != nil {
|
||||||
Action: "cancel_refund",
|
return err
|
||||||
CreatedAt: time.Now(),
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if oc.AppliedAmount > 0 {
|
||||||
|
// 恢复余额 + 重置状态(不依赖 used_order_id)
|
||||||
|
res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
|
||||||
|
UPDATE user_coupons
|
||||||
|
SET balance_amount = balance_amount + ?,
|
||||||
|
status = 1,
|
||||||
|
used_order_id = 0,
|
||||||
|
used_at = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
`, oc.AppliedAmount, order.CouponID)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
if err := tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
|
||||||
|
UserID: userID,
|
||||||
|
UserCouponID: order.CouponID,
|
||||||
|
ChangeAmount: oc.AppliedAmount,
|
||||||
|
OrderID: order.ID,
|
||||||
|
Action: "cancel_refund",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
migrations/20260319_fragment_synthesis.sql
Normal file
40
migrations/20260319_fragment_synthesis.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
-- 碎片合成功能
|
||||||
|
|
||||||
|
-- 1. product_categories 新增碎片标记
|
||||||
|
ALTER TABLE product_categories ADD COLUMN is_fragment TINYINT NOT NULL DEFAULT 0 COMMENT '是否碎片分类:0否 1是';
|
||||||
|
|
||||||
|
-- 2. 碎片合成配方表(主表)
|
||||||
|
CREATE TABLE fragment_synthesis_recipes (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT '合成配方名称',
|
||||||
|
description VARCHAR(500) NOT NULL DEFAULT '' COMMENT '合成配方描述',
|
||||||
|
target_product_id BIGINT NOT NULL COMMENT '合成目标商品ID(products.id)',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 2禁用',
|
||||||
|
deleted_at DATETIME(3) NULL,
|
||||||
|
INDEX idx_target_product (target_product_id)
|
||||||
|
) COMMENT '碎片合成配方';
|
||||||
|
|
||||||
|
-- 3. 碎片合成配方材料表(子表)
|
||||||
|
CREATE TABLE fragment_synthesis_recipe_materials (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
recipe_id BIGINT NOT NULL COMMENT '配方ID(fragment_synthesis_recipes.id)',
|
||||||
|
fragment_product_id BIGINT NOT NULL COMMENT '碎片商品ID(products.id)',
|
||||||
|
required_count INT NOT NULL DEFAULT 1 COMMENT '该碎片所需数量',
|
||||||
|
INDEX idx_recipe (recipe_id),
|
||||||
|
INDEX idx_fragment_product (fragment_product_id)
|
||||||
|
) COMMENT '碎片合成配方材料';
|
||||||
|
|
||||||
|
-- 4. 碎片合成日志表
|
||||||
|
CREATE TABLE fragment_synthesis_logs (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
|
recipe_id BIGINT NOT NULL COMMENT '配方ID',
|
||||||
|
consumed_inventory_ids JSON NOT NULL COMMENT '消耗的碎片资产ID列表',
|
||||||
|
produced_inventory_id BIGINT NOT NULL COMMENT '合成产出的资产ID(user_inventory.id)',
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_recipe (recipe_id)
|
||||||
|
) COMMENT '碎片合成日志';
|
||||||
Loading…
x
Reference in New Issue
Block a user