Zuncle 0a397adf41 feat: 支持批量合成并收口不包邮运费规则
本次提交同时完成碎片批量合成与盒柜发货运费规则改造,减少用户重复操作,并确保不包邮商品在任何件数下都必须支付运费。

- 合成功能:新增批量合成接口与前端一键合成入口,按配方可合成上限批量消耗碎片并生成对应资产,同时补充批量合成测试
- 运费规则:后端新增统一运费判定逻辑,命中 category_id 14/15 的商品时整单强制收取运费,否则继续沿用少于 5 件收运费的旧规则
- 发货流程:新增运费检查接口,前端发货前先向后端确认是否需要支付运费,并根据“件数不足”或“包含不包邮商品”展示不同提示文案
- 接口校验:运费预下单与批量申请发货统一复用后端判定逻辑,避免前端规则被绕过
2026-04-21 02:06:56 +08:00

551 lines
18 KiB
Go

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
}