452 lines
14 KiB
Go

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
}