package synthesis import ( "context" "encoding/json" "fmt" "time" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) 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) BatchSynthesize(ctx context.Context, userID int64, recipeID int64) (*BatchSynthesizeResult, 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"` MaxSynthesizeCount int64 `json:"max_synthesize_count"` Materials []UserMaterialView `json:"materials"` } type BatchSynthesizeResult struct { RecipeID int64 `json:"recipe_id"` TargetProductID int64 `json:"target_product_id"` TargetProductName string `json:"target_product_name"` SynthesizedCount int64 `json:"synthesized_count"` ProducedInventoryIDs []int64 `json:"produced_inventory_ids"` ConsumedInventoryCount int `json:"consumed_inventory_count"` } 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, Materials: make([]UserMaterialView, 0, len(materials)), } maxSynthesizeCount := int64(0) initialized := false 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) currentCount := int64(0) if m.RequiredCount > 0 { currentCount = ownedCount / int64(m.RequiredCount) } if !initialized || currentCount < maxSynthesizeCount { maxSynthesizeCount = currentCount initialized = true } 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, }) } view.MaxSynthesizeCount = maxSynthesizeCount view.CanSynthesize = maxSynthesizeCount > 0 result = append(result, view) } return result, nil } func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error) { result, err := s.batchSynthesize(ctx, userID, recipeID, 1) if err != nil { return nil, err } if len(result.ProducedInventoryIDs) == 0 { return nil, fmt.Errorf("synthesis_failed") } var newInv model.UserInventory if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", result.ProducedInventoryIDs[0]).First(&newInv).Error; err != nil { return nil, err } return &newInv, nil } func (s *service) BatchSynthesize(ctx context.Context, userID int64, recipeID int64) (*BatchSynthesizeResult, error) { return s.batchSynthesize(ctx, userID, recipeID, 0) } func (s *service) batchSynthesize(ctx context.Context, userID int64, recipeID int64, limitTimes int64) (*BatchSynthesizeResult, 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)) maxTimes := int64(0) initialized := false for _, m := range materials { var ownedCount int64 db.WithContext(ctx).Model(&model.UserInventory{}). Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID). Count(&ownedCount) currentTimes := int64(0) if m.RequiredCount > 0 { currentTimes = ownedCount / int64(m.RequiredCount) } if !initialized || currentTimes < maxTimes { maxTimes = currentTimes initialized = true } } if limitTimes > 0 && maxTimes > limitTimes { maxTimes = limitTimes } if maxTimes <= 0 { return nil, fmt.Errorf("insufficient_fragments") } for _, m := range materials { requiredTotal := int(m.RequiredCount) * int(maxTimes) var invList []*model.UserInventory db.WithContext(ctx). Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID). Order("id ASC"). Limit(requiredTotal). Find(&invList) if len(invList) < requiredTotal { 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, }) } result := &BatchSynthesizeResult{ RecipeID: recipeID, TargetProductID: recipe.TargetProductID, TargetProductName: targetProduct.Name, SynthesizedCount: maxTimes, } wdb := s.repo.GetDbW() err := wdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { consumedByRound := make([][]int64, int(maxTimes)) allConsumedCount := 0 for _, mc := range toConsume { var locked []model.UserInventory query := tx.WithContext(ctx).Where("id IN ? AND user_id = ? AND status = 1", mc.InventoryIDs, userID) if tx.Dialector.Name() != "sqlite" { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := query.Find(&locked).Error; err != nil { return err } if len(locked) < len(mc.InventoryIDs) { return fmt.Errorf("insufficient_fragments") } updates := map[string]interface{}{ "status": 2, "updated_at": time.Now(), } if tx.Dialector.Name() == "sqlite" { updates["remark"] = gorm.Expr("COALESCE(remark, '') || ?", fmt.Sprintf("|synthesis_consumed:recipe_%d", recipeID)) } else { updates["remark"] = gorm.Expr("CONCAT(IFNULL(remark,''), ?)", fmt.Sprintf("|synthesis_consumed:recipe_%d", recipeID)) } if err := tx.Model(&model.UserInventory{}). Where("id IN ? AND user_id = ? AND status = 1", mc.InventoryIDs, userID). Updates(updates).Error; err != nil { return err } allConsumedCount += len(mc.InventoryIDs) for round := int64(0); round < maxTimes; round++ { start := int(round) * int(mc.Required) end := start + int(mc.Required) consumedByRound[round] = append(consumedByRound[round], mc.InventoryIDs[start:end]...) } } result.ConsumedInventoryCount = allConsumedCount result.ProducedInventoryIDs = make([]int64, 0, int(maxTimes)) for round := int64(0); round < maxTimes; round++ { newInv := model.UserInventory{ UserID: userID, ProductID: recipe.TargetProductID, ValueCents: targetProduct.Price, Status: 1, Remark: fmt.Sprintf("batch_synthesis_produced:recipe_%d:round_%d", recipeID, round+1), } if err := tx.Omit("ValueSnapshotAt", "ShippingNo").Create(&newInv).Error; err != nil { return err } result.ProducedInventoryIDs = append(result.ProducedInventoryIDs, newInv.ID) consumedJSON, _ := json.Marshal(consumedByRound[round]) log := &model.FragmentSynthesisLogs{ UserID: userID, RecipeID: recipeID, ConsumedInventoryIDs: string(consumedJSON), ProducedInventoryID: newInv.ID, } if err := tx.Create(log).Error; err != nil { return err } } return nil }) if err != nil { return nil, err } return result, 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 }