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 }