From 47c36b43cd3ff8db7f5b1021a4b3907cc1bfde88 Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Thu, 19 Mar 2026 16:26:36 +0800 Subject: [PATCH] feat(fragment): add synthesis flow and fragment restrictions with tests --- internal/api/admin/admin.go | 3 + internal/api/admin/product_category_create.go | 29 +- internal/api/admin/product_create.go | 3 +- internal/api/admin/synthesis_admin.go | 114 +++++ internal/api/user/app.go | 11 +- internal/api/user/synthesis_app.go | 60 +++ .../model/fragment_synthesis_logs.gen.go | 20 + ...fragment_synthesis_recipe_materials.gen.go | 19 + .../model/fragment_synthesis_recipes.gen.go | 24 + .../mysql/model/product_categories.gen.go | 5 +- internal/router/router.go | 13 + internal/service/product/product.go | 35 +- .../product/product_fragment_filter_test.go | 92 ++++ internal/service/synthesis/synthesis.go | 451 ++++++++++++++++++ internal/service/synthesis/synthesis_test.go | 95 ++++ internal/service/user/address_share.go | 77 +++ internal/service/user/inventory_list.go | 5 + migrations/20260319_fragment_synthesis.sql | 40 ++ 18 files changed, 1069 insertions(+), 27 deletions(-) create mode 100644 internal/api/admin/synthesis_admin.go create mode 100644 internal/api/user/synthesis_app.go create mode 100644 internal/repository/mysql/model/fragment_synthesis_logs.gen.go create mode 100644 internal/repository/mysql/model/fragment_synthesis_recipe_materials.gen.go create mode 100644 internal/repository/mysql/model/fragment_synthesis_recipes.gen.go create mode 100644 internal/service/product/product_fragment_filter_test.go create mode 100644 internal/service/synthesis/synthesis.go create mode 100644 internal/service/synthesis/synthesis_test.go create mode 100644 migrations/20260319_fragment_synthesis.sql 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/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/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/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/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 3a5de3d..ee9345f 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -366,6 +366,14 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er 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()) adminAuthApiRouter.GET("/pay/refunds/:refund_no", intc.RequireAdminAction("refund:view"), adminHandler.GetRefundDetail()) @@ -530,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/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/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/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 '碎片合成日志';