Merge remote-tracking branch 'origin/zuncle'
This commit is contained in:
commit
eaf4af4ba4
@ -13,6 +13,7 @@ import (
|
||||
livestreamsvc "bindbox-game/internal/service/livestream"
|
||||
productsvc "bindbox-game/internal/service/product"
|
||||
snapshotsvc "bindbox-game/internal/service/snapshot"
|
||||
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
||||
titlesvc "bindbox-game/internal/service/title"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
@ -37,6 +38,7 @@ type handler struct {
|
||||
rollbackSvc snapshotsvc.RollbackService
|
||||
douyinSvc douyinsvc.Service
|
||||
livestream livestreamsvc.Service
|
||||
synthesis synthesissvc.Service
|
||||
}
|
||||
|
||||
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,
|
||||
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
||||
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
||||
synthesis: synthesissvc.New(db),
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,18 +22,20 @@ type cardsRequest struct {
|
||||
}
|
||||
|
||||
type cardStatResponse struct {
|
||||
ItemCardSales int64 `json:"itemCardSales"`
|
||||
DrawCount int64 `json:"drawCount"`
|
||||
NewUsers int64 `json:"newUsers"`
|
||||
TotalPoints float64 `json:"totalPoints"`
|
||||
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
|
||||
TotalCoupons int64 `json:"totalCoupons"`
|
||||
TotalItemCards int64 `json:"totalItemCards"`
|
||||
TotalGamePasses int64 `json:"totalGamePasses"`
|
||||
ItemCardChange string `json:"itemCardChange"`
|
||||
DrawChange string `json:"drawChange"`
|
||||
NewUserChange string `json:"newUserChange"`
|
||||
PointsChange string `json:"pointsChange"`
|
||||
ItemCardSales int64 `json:"itemCardSales"`
|
||||
DrawCount int64 `json:"drawCount"`
|
||||
NewUsers int64 `json:"newUsers"`
|
||||
TotalPoints float64 `json:"totalPoints"`
|
||||
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
|
||||
TotalCoupons int64 `json:"totalCoupons"` // 存量优惠券数量
|
||||
TotalCouponValue int64 `json:"totalCouponValue"` // 优惠券总价值(分)
|
||||
TotalItemCards int64 `json:"totalItemCards"` // 存量道具卡
|
||||
TotalGamePasses int64 `json:"totalGamePasses"` // 存量次卡(余次)
|
||||
TotalGamePassValue int64 `json:"totalGamePassValue"` // 次卡总价值(分)
|
||||
ItemCardChange string `json:"itemCardChange"`
|
||||
DrawChange string `json:"drawChange"`
|
||||
NewUserChange string `json:"newUserChange"`
|
||||
PointsChange string `json:"pointsChange"`
|
||||
}
|
||||
|
||||
func (h *handler) DashboardCards() core.HandlerFunc {
|
||||
@ -141,11 +143,23 @@ func (h *handler) DashboardCards() core.HandlerFunc {
|
||||
prevDelta = prevDeltaRows[0].Sum
|
||||
}
|
||||
|
||||
// 批量:存量优惠券 (未使用)
|
||||
// 批量:存量优惠券 (未使用) 及优惠券总价值
|
||||
tcCur, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
||||
Where(h.readDB.UserCoupons.Status.Eq(1)).
|
||||
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().
|
||||
Where(h.readDB.UserItemCards.Status.Eq(1)).
|
||||
@ -156,7 +170,7 @@ func (h *handler) DashboardCards() core.HandlerFunc {
|
||||
Where(h.readDB.UserInventory.Status.Eq(1)).
|
||||
Count()
|
||||
|
||||
// 批量:存量次卡 (剩余次数)
|
||||
// 批量:存量次卡 (剩余次数) 及次卡总价值
|
||||
var tgpRows []struct{ Sum int64 }
|
||||
_ = h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
|
||||
Where(h.readDB.UserGamePasses.ExpiredAt.Gt(time.Now())).
|
||||
@ -168,14 +182,28 @@ func (h *handler) DashboardCards() core.HandlerFunc {
|
||||
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.DrawCount = dlCur
|
||||
rsp.NewUsers = nuCur
|
||||
rsp.TotalPoints = h.userSvc.CentsToPointsFloat(ctx.RequestContext(), tpCur)
|
||||
rsp.TotalInventory = tinvCur
|
||||
rsp.TotalCoupons = tcCur
|
||||
rsp.TotalCouponValue = tcValue
|
||||
rsp.TotalItemCards = ticCur
|
||||
rsp.TotalGamePasses = tgpCur
|
||||
rsp.TotalGamePassValue = tgpValue
|
||||
rsp.ItemCardChange = percentChange(icPrev, icCur)
|
||||
rsp.DrawChange = percentChange(dlPrev, dlCur)
|
||||
rsp.NewUserChange = percentChange(nuPrev, nuCur)
|
||||
|
||||
@ -1031,11 +1031,7 @@ func (h *handler) CancelOrder() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21006, "订单不存在"))
|
||||
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{
|
||||
h.readDB.Orders.Status.ColumnName().String(): 3,
|
||||
h.readDB.Orders.CancelledAt.ColumnName().String(): time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
if _, err = h.userSvc.CancelOrder(ctx.RequestContext(), order.UserID, order.ID, "admin_cancel"); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@ -11,9 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type createProductCategoryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
IsFragment *int32 `json:"is_fragment"`
|
||||
}
|
||||
|
||||
type createProductCategoryResponse struct {
|
||||
@ -44,7 +45,7 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
@ -57,9 +58,10 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
||||
}
|
||||
|
||||
type modifyProductCategoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
Status *int32 `json:"status"`
|
||||
Name *string `json:"name"`
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
Status *int32 `json:"status"`
|
||||
IsFragment *int32 `json:"is_fragment"`
|
||||
}
|
||||
|
||||
type pcSimpleMessage struct {
|
||||
@ -90,7 +92,7 @@ func (h *handler) ModifyProductCategory() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
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()))
|
||||
return
|
||||
}
|
||||
@ -132,10 +134,11 @@ type listProductCategoriesRequest struct {
|
||||
}
|
||||
|
||||
type productCategoryListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
IsFragment int32 `json:"is_fragment"`
|
||||
}
|
||||
type listProductCategoriesResponse struct {
|
||||
Page int `json:"page"`
|
||||
@ -175,7 +178,7 @@ func (h *handler) ListProductCategories() core.HandlerFunc {
|
||||
res.Total = total
|
||||
res.List = make([]productCategoryListItem, len(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)
|
||||
}
|
||||
|
||||
@ -132,6 +132,7 @@ type listProductsRequest struct {
|
||||
Name string `form:"name"`
|
||||
CategoryID *int64 `form:"category_id"`
|
||||
Status *int32 `form:"status"`
|
||||
IsFragment *int32 `form:"is_fragment"`
|
||||
Page int `form:"page"`
|
||||
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)))
|
||||
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 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@ -8,6 +9,7 @@ import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
@ -429,3 +431,91 @@ func (h *handler) GetShippingOrderDetail() core.HandlerFunc {
|
||||
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
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"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}
|
||||
}
|
||||
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 != "" {
|
||||
q = q.Where(h.readDB.SystemCoupons.Name.Like("%" + req.Keyword + "%"))
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"bindbox-game/internal/service/douyin"
|
||||
gamesvc "bindbox-game/internal/service/game"
|
||||
"bindbox-game/internal/service/sysconfig"
|
||||
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||
tasksvc "bindbox-game/internal/service/task_center"
|
||||
titlesvc "bindbox-game/internal/service/title"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
@ -18,8 +19,9 @@ type handler struct {
|
||||
readDB *dao.Query
|
||||
user usersvc.Service
|
||||
task tasksvc.Service
|
||||
douyin douyin.Service
|
||||
repo mysql.Repo
|
||||
douyin douyin.Service
|
||||
repo mysql.Repo
|
||||
synthesis synthesissvc.Service
|
||||
}
|
||||
|
||||
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()),
|
||||
user: userSvc,
|
||||
task: taskSvc,
|
||||
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
||||
repo: db,
|
||||
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
||||
repo: db,
|
||||
synthesis: synthesissvc.New(db),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type redeemCouponRequest struct {
|
||||
@ -52,6 +54,10 @@ func (h *handler) RedeemPointsToCoupon() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, "only amount coupons supported"))
|
||||
return
|
||||
}
|
||||
if !sc.ValidEnd.IsZero() && sc.ValidEnd.Before(time.Now()) {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150005, "该优惠券模板已过期,无法兑换"))
|
||||
return
|
||||
}
|
||||
// sc.DiscountValue 是优惠券面值(分),直接用于扣除
|
||||
// 例如:30 元优惠券 = 3000 分
|
||||
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"` // 更新时间
|
||||
Name string `gorm:"column:name;not null;comment:分类名称" json:"name"` // 分类名称
|
||||
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禁用
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
||||
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 2禁用" json:"status"` // 状态:1启用 2禁用
|
||||
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
|
||||
|
||||
@ -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/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail())
|
||||
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.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())
|
||||
}
|
||||
|
||||
// 碎片合成
|
||||
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查过)
|
||||
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
||||
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
||||
|
||||
@ -229,8 +229,7 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
|
||||
// 如果是金额券,status=1。
|
||||
// 如果是满减券,status=1。
|
||||
if uc.Status != 1 {
|
||||
fmt.Printf("[优惠券] 状态不可用 id=%d status=%d\n", userCouponID, uc.Status)
|
||||
return 0, nil, nil
|
||||
return 0, nil, fmt.Errorf("优惠券不可用")
|
||||
}
|
||||
|
||||
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()
|
||||
now := time.Now()
|
||||
if sc == nil {
|
||||
fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID)
|
||||
return 0, nil, nil
|
||||
return 0, nil, fmt.Errorf("优惠券模板不存在")
|
||||
}
|
||||
if uc.ValidStart.After(now) {
|
||||
fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID)
|
||||
return 0, nil, nil
|
||||
return 0, nil, fmt.Errorf("优惠券未到使用时间")
|
||||
}
|
||||
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
|
||||
fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID)
|
||||
return 0, nil, nil
|
||||
return 0, nil, fmt.Errorf("优惠券已过期")
|
||||
}
|
||||
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
|
||||
if !scopeOK {
|
||||
fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType)
|
||||
return 0, nil, nil
|
||||
return 0, nil, fmt.Errorf("优惠券不适用于当前活动")
|
||||
}
|
||||
if order.TotalAmount < sc.MinSpend {
|
||||
fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount)
|
||||
return 0, nil, nil
|
||||
return 0, nil, fmt.Errorf("订单金额未达优惠券使用门槛")
|
||||
}
|
||||
|
||||
// 50% 封顶
|
||||
|
||||
@ -33,24 +33,27 @@ type service struct {
|
||||
logger logger.CustomLogger
|
||||
readDB *dao.Query
|
||||
writeDB *dao.Query
|
||||
repo mysql.Repo
|
||||
listCache map[string]cachedList
|
||||
detailCache map[int64]cachedDetail
|
||||
}
|
||||
|
||||
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 {
|
||||
Name string
|
||||
ParentID int64
|
||||
Status int32
|
||||
Name string
|
||||
ParentID int64
|
||||
Status int32
|
||||
IsFragment *int32
|
||||
}
|
||||
|
||||
type ModifyCategoryInput struct {
|
||||
Name *string
|
||||
ParentID *int64
|
||||
Status *int32
|
||||
Name *string
|
||||
ParentID *int64
|
||||
Status *int32
|
||||
IsFragment *int32
|
||||
}
|
||||
|
||||
type ListCategoriesInput struct {
|
||||
@ -62,6 +65,9 @@ type ListCategoriesInput struct {
|
||||
|
||||
func (s *service) CreateCategory(ctx context.Context, in CreateCategoryInput) (*model.ProductCategories, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -80,6 +86,9 @@ func (s *service) ModifyCategory(ctx context.Context, id int64, in ModifyCategor
|
||||
if in.Status != nil {
|
||||
set["status"] = *in.Status
|
||||
}
|
||||
if in.IsFragment != nil {
|
||||
set["is_fragment"] = *in.IsFragment
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -140,6 +149,7 @@ type ListProductsInput struct {
|
||||
Name string
|
||||
CategoryID *int64
|
||||
Status *int32
|
||||
IsFragment *int32
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
@ -245,6 +255,17 @@ func (s *service) ListProducts(ctx context.Context, in ListProductsInput) (items
|
||||
if in.Status != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
@ -422,6 +426,9 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
||||
productIDs := make([]int64, 0, len(uniq))
|
||||
productIDSet := make(map[int64]struct{})
|
||||
|
||||
// 预先获取碎片分类的商品ID集合
|
||||
fragmentPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||
|
||||
for _, id := range uniq {
|
||||
inv := invMap[id]
|
||||
if inv == nil {
|
||||
@ -459,6 +466,13 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
||||
}{ID: id, Reason: "invalid_status"})
|
||||
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)
|
||||
if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 {
|
||||
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")
|
||||
}
|
||||
|
||||
// 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. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||
productIDs := make([]int64, 0, len(invList))
|
||||
productIDSet := make(map[int64]struct{})
|
||||
@ -788,3 +817,51 @@ func (s *service) VoidUserInventory(ctx context.Context, adminID int64, userID i
|
||||
_ = adminID
|
||||
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")
|
||||
}
|
||||
if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) {
|
||||
return errors.New("coupon template expired")
|
||||
}
|
||||
// 配额检查:若 TotalQuantity > 0 则限制发放总量
|
||||
if tpl.TotalQuantity > 0 {
|
||||
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)
|
||||
|
||||
switch status {
|
||||
case 1: // 有效:余额 > 0 且 未过期
|
||||
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
|
||||
case 1: // 有效:余额 > 0 且 未过期(NULL/零值 valid_end 视为永久有效)
|
||||
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 已过截止时间
|
||||
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:
|
||||
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 {
|
||||
|
||||
@ -27,6 +27,7 @@ type AggregatedInventory struct {
|
||||
ShippingStatus int32 `json:"shipping_status"`
|
||||
Status int32 `json:"status"` // 用于区分 1持有 3已处理
|
||||
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) {
|
||||
@ -262,6 +263,8 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
pagedGroups := groupResults[start:end]
|
||||
|
||||
// 2. 获取商品详情和发货状态
|
||||
// 预加载碎片分类商品ID
|
||||
fragPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||
items = make([]*AggregatedInventory, 0, len(pagedGroups))
|
||||
for _, g := range pagedGroups {
|
||||
// 查询该分组下的所有 inventory id
|
||||
@ -302,6 +305,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
}
|
||||
}
|
||||
|
||||
_, isFrag := fragPIDs[g.ProductID]
|
||||
items = append(items, &AggregatedInventory{
|
||||
ProductID: g.ProductID,
|
||||
ProductName: name,
|
||||
@ -313,6 +317,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
ShippingStatus: shipStatus,
|
||||
Status: g.Status,
|
||||
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. 退还优惠券(恢复预扣的余额和状态)
|
||||
if order.CouponID > 0 {
|
||||
var oc struct {
|
||||
AppliedAmount int64
|
||||
// 幂等校验:若已记录过 cancel_refund 流水则跳过
|
||||
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)
|
||||
|
||||
// 执行原子回退:增加余额 + 重置状态 + 清除占用订单
|
||||
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 = ? AND used_order_id = ?
|
||||
`, oc.AppliedAmount, order.CouponID, order.ID)
|
||||
if refundExists == 0 {
|
||||
var oc struct {
|
||||
AppliedAmount int64
|
||||
}
|
||||
// 优先从 order_coupons 获取实际抵扣金额
|
||||
if err := tx.OrderCoupons.WithContext(ctx).Where(
|
||||
tx.OrderCoupons.OrderID.Eq(order.ID),
|
||||
tx.OrderCoupons.UserCouponID.Eq(order.CouponID),
|
||||
).Scan(&oc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
// 记录退还流水
|
||||
_ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
|
||||
UserID: userID,
|
||||
UserCouponID: order.CouponID,
|
||||
ChangeAmount: oc.AppliedAmount,
|
||||
OrderID: order.ID,
|
||||
Action: "cancel_refund",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
// 兜底:order_coupons 无记录时,从流水中回推预扣金额
|
||||
if oc.AppliedAmount <= 0 {
|
||||
if err := tx.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
|
||||
SELECT COALESCE(SUM(CASE WHEN change_amount < 0 THEN -change_amount ELSE 0 END), 0) AS applied_amount
|
||||
FROM user_coupon_ledger
|
||||
WHERE user_id = ? AND user_coupon_id = ? AND order_id = ? AND action IN ('reserve', 'usage')
|
||||
`, userID, order.CouponID, order.ID).Scan(&oc).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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