diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go index 35ed3bf..4dfdab2 100755 --- a/internal/api/admin/admin.go +++ b/internal/api/admin/admin.go @@ -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), } } diff --git a/internal/api/admin/dashboard_admin.go b/internal/api/admin/dashboard_admin.go index 5fe835c..826c69e 100755 --- a/internal/api/admin/dashboard_admin.go +++ b/internal/api/admin/dashboard_admin.go @@ -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) diff --git a/internal/api/admin/pay_orders_admin.go b/internal/api/admin/pay_orders_admin.go index ec5ca3b..384e3a0 100755 --- a/internal/api/admin/pay_orders_admin.go +++ b/internal/api/admin/pay_orders_admin.go @@ -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 } diff --git a/internal/api/admin/product_category_create.go b/internal/api/admin/product_category_create.go index 7fdd262..1cfd87a 100755 --- a/internal/api/admin/product_category_create.go +++ b/internal/api/admin/product_category_create.go @@ -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) } diff --git a/internal/api/admin/product_create.go b/internal/api/admin/product_create.go index f7319ad..8159653 100755 --- a/internal/api/admin/product_create.go +++ b/internal/api/admin/product_create.go @@ -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 diff --git a/internal/api/admin/shipping_orders_admin.go b/internal/api/admin/shipping_orders_admin.go index 512cc56..5ce931b 100755 --- a/internal/api/admin/shipping_orders_admin.go +++ b/internal/api/admin/shipping_orders_admin.go @@ -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}) + } +} diff --git a/internal/api/admin/synthesis_admin.go b/internal/api/admin/synthesis_admin.go new file mode 100644 index 0000000..6a1e756 --- /dev/null +++ b/internal/api/admin/synthesis_admin.go @@ -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}) + } +} diff --git a/internal/api/app/store.go b/internal/api/app/store.go index 70b9b64..6ff833b 100755 --- a/internal/api/app/store.go +++ b/internal/api/app/store.go @@ -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 + "%")) diff --git a/internal/api/user/app.go b/internal/api/user/app.go index 572d47e..b34a601 100755 --- a/internal/api/user/app.go +++ b/internal/api/user/app.go @@ -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), } } diff --git a/internal/api/user/points_redeem_coupon_app.go b/internal/api/user/points_redeem_coupon_app.go index 651c1c8..964849b 100755 --- a/internal/api/user/points_redeem_coupon_app.go +++ b/internal/api/user/points_redeem_coupon_app.go @@ -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 diff --git a/internal/api/user/synthesis_app.go b/internal/api/user/synthesis_app.go new file mode 100644 index 0000000..d9d487a --- /dev/null +++ b/internal/api/user/synthesis_app.go @@ -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}) + } +} diff --git a/internal/repository/mysql/model/fragment_synthesis_logs.gen.go b/internal/repository/mysql/model/fragment_synthesis_logs.gen.go new file mode 100644 index 0000000..b505d06 --- /dev/null +++ b/internal/repository/mysql/model/fragment_synthesis_logs.gen.go @@ -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 +} diff --git a/internal/repository/mysql/model/fragment_synthesis_recipe_materials.gen.go b/internal/repository/mysql/model/fragment_synthesis_recipe_materials.gen.go new file mode 100644 index 0000000..73508e2 --- /dev/null +++ b/internal/repository/mysql/model/fragment_synthesis_recipe_materials.gen.go @@ -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 +} diff --git a/internal/repository/mysql/model/fragment_synthesis_recipes.gen.go b/internal/repository/mysql/model/fragment_synthesis_recipes.gen.go new file mode 100644 index 0000000..37c0135 --- /dev/null +++ b/internal/repository/mysql/model/fragment_synthesis_recipes.gen.go @@ -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 +} diff --git a/internal/repository/mysql/model/product_categories.gen.go b/internal/repository/mysql/model/product_categories.gen.go index 1462063..9194d44 100755 --- a/internal/repository/mysql/model/product_categories.gen.go +++ b/internal/repository/mysql/model/product_categories.gen.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index a737272..ee9345f 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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()) diff --git a/internal/service/activity/activity_order_service.go b/internal/service/activity/activity_order_service.go index 133249c..adc55ef 100755 --- a/internal/service/activity/activity_order_service.go +++ b/internal/service/activity/activity_order_service.go @@ -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% 封顶 diff --git a/internal/service/product/product.go b/internal/service/product/product.go index c08bd59..b3ba8e7 100755 --- a/internal/service/product/product.go +++ b/internal/service/product/product.go @@ -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 diff --git a/internal/service/product/product_fragment_filter_test.go b/internal/service/product/product_fragment_filter_test.go new file mode 100644 index 0000000..de4f273 --- /dev/null +++ b/internal/service/product/product_fragment_filter_test.go @@ -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)) + } +} diff --git a/internal/service/synthesis/synthesis.go b/internal/service/synthesis/synthesis.go new file mode 100644 index 0000000..51c6bec --- /dev/null +++ b/internal/service/synthesis/synthesis.go @@ -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 +} diff --git a/internal/service/synthesis/synthesis_test.go b/internal/service/synthesis/synthesis_test.go new file mode 100644 index 0000000..9ec963e --- /dev/null +++ b/internal/service/synthesis/synthesis_test.go @@ -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) + } +} + diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go index fdb20d2..e3d98ff 100755 --- a/internal/service/user/address_share.go +++ b/internal/service/user/address_share.go @@ -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 +} diff --git a/internal/service/user/coupon_add.go b/internal/service/user/coupon_add.go index 87c6df8..1cbcdd2 100755 --- a/internal/service/user/coupon_add.go +++ b/internal/service/user/coupon_add.go @@ -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() diff --git a/internal/service/user/coupons_list.go b/internal/service/user/coupons_list.go index 1a4befc..42c1f14 100755 --- a/internal/service/user/coupons_list.go +++ b/internal/service/user/coupons_list.go @@ -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 { diff --git a/internal/service/user/inventory_list.go b/internal/service/user/inventory_list.go index c0b7200..2e58174 100755 --- a/internal/service/user/inventory_list.go +++ b/internal/service/user/inventory_list.go @@ -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, }) } diff --git a/internal/service/user/orders_action.go b/internal/service/user/orders_action.go index 9f61d0e..2ef69c5 100755 --- a/internal/service/user/orders_action.go +++ b/internal/service/user/orders_action.go @@ -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 + } + } + } } } diff --git a/migrations/20260319_fragment_synthesis.sql b/migrations/20260319_fragment_synthesis.sql new file mode 100644 index 0000000..078e671 --- /dev/null +++ b/migrations/20260319_fragment_synthesis.sql @@ -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 '碎片合成日志';