feat(fragment): add synthesis flow and fragment restrictions with tests
This commit is contained in:
parent
4ffd8e8326
commit
47c36b43cd
@ -13,6 +13,7 @@ import (
|
|||||||
livestreamsvc "bindbox-game/internal/service/livestream"
|
livestreamsvc "bindbox-game/internal/service/livestream"
|
||||||
productsvc "bindbox-game/internal/service/product"
|
productsvc "bindbox-game/internal/service/product"
|
||||||
snapshotsvc "bindbox-game/internal/service/snapshot"
|
snapshotsvc "bindbox-game/internal/service/snapshot"
|
||||||
|
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||||
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
||||||
titlesvc "bindbox-game/internal/service/title"
|
titlesvc "bindbox-game/internal/service/title"
|
||||||
usersvc "bindbox-game/internal/service/user"
|
usersvc "bindbox-game/internal/service/user"
|
||||||
@ -37,6 +38,7 @@ type handler struct {
|
|||||||
rollbackSvc snapshotsvc.RollbackService
|
rollbackSvc snapshotsvc.RollbackService
|
||||||
douyinSvc douyinsvc.Service
|
douyinSvc douyinsvc.Service
|
||||||
livestream livestreamsvc.Service
|
livestream livestreamsvc.Service
|
||||||
|
synthesis synthesissvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
|
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,
|
rollbackSvc: rollbackSvc,
|
||||||
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
||||||
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
||||||
|
synthesis: synthesissvc.New(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,9 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type createProductCategoryRequest struct {
|
type createProductCategoryRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ParentID int64 `json:"parent_id"`
|
ParentID int64 `json:"parent_id"`
|
||||||
Status int32 `json:"status"`
|
Status int32 `json:"status"`
|
||||||
|
IsFragment *int32 `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createProductCategoryResponse struct {
|
type createProductCategoryResponse struct {
|
||||||
@ -44,7 +45,7 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.product.CreateCategory(ctx.RequestContext(), prodsvc.CreateCategoryInput{
|
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 {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
@ -57,9 +58,10 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modifyProductCategoryRequest struct {
|
type modifyProductCategoryRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
ParentID *int64 `json:"parent_id"`
|
ParentID *int64 `json:"parent_id"`
|
||||||
Status *int32 `json:"status"`
|
Status *int32 `json:"status"`
|
||||||
|
IsFragment *int32 `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type pcSimpleMessage struct {
|
type pcSimpleMessage struct {
|
||||||
@ -90,7 +92,7 @@ func (h *handler) ModifyProductCategory() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||||
return
|
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()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -132,10 +134,11 @@ type listProductCategoriesRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type productCategoryListItem struct {
|
type productCategoryListItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ParentID int64 `json:"parent_id"`
|
ParentID int64 `json:"parent_id"`
|
||||||
Status int32 `json:"status"`
|
Status int32 `json:"status"`
|
||||||
|
IsFragment int32 `json:"is_fragment"`
|
||||||
}
|
}
|
||||||
type listProductCategoriesResponse struct {
|
type listProductCategoriesResponse struct {
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
@ -175,7 +178,7 @@ func (h *handler) ListProductCategories() core.HandlerFunc {
|
|||||||
res.Total = total
|
res.Total = total
|
||||||
res.List = make([]productCategoryListItem, len(items))
|
res.List = make([]productCategoryListItem, len(items))
|
||||||
for i, it := range 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)
|
ctx.Payload(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@ type listProductsRequest struct {
|
|||||||
Name string `form:"name"`
|
Name string `form:"name"`
|
||||||
CategoryID *int64 `form:"category_id"`
|
CategoryID *int64 `form:"category_id"`
|
||||||
Status *int32 `form:"status"`
|
Status *int32 `form:"status"`
|
||||||
|
IsFragment *int32 `form:"is_fragment"`
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"page_size"`
|
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)))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
114
internal/api/admin/synthesis_admin.go
Normal file
114
internal/api/admin/synthesis_admin.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"bindbox-game/internal/service/douyin"
|
"bindbox-game/internal/service/douyin"
|
||||||
gamesvc "bindbox-game/internal/service/game"
|
gamesvc "bindbox-game/internal/service/game"
|
||||||
"bindbox-game/internal/service/sysconfig"
|
"bindbox-game/internal/service/sysconfig"
|
||||||
|
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||||
tasksvc "bindbox-game/internal/service/task_center"
|
tasksvc "bindbox-game/internal/service/task_center"
|
||||||
titlesvc "bindbox-game/internal/service/title"
|
titlesvc "bindbox-game/internal/service/title"
|
||||||
usersvc "bindbox-game/internal/service/user"
|
usersvc "bindbox-game/internal/service/user"
|
||||||
@ -18,8 +19,9 @@ type handler struct {
|
|||||||
readDB *dao.Query
|
readDB *dao.Query
|
||||||
user usersvc.Service
|
user usersvc.Service
|
||||||
task tasksvc.Service
|
task tasksvc.Service
|
||||||
douyin douyin.Service
|
douyin douyin.Service
|
||||||
repo mysql.Repo
|
repo mysql.Repo
|
||||||
|
synthesis synthesissvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
|
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()),
|
readDB: dao.Use(db.GetDbR()),
|
||||||
user: userSvc,
|
user: userSvc,
|
||||||
task: taskSvc,
|
task: taskSvc,
|
||||||
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
||||||
repo: db,
|
repo: db,
|
||||||
|
synthesis: synthesissvc.New(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
internal/api/user/synthesis_app.go
Normal file
60
internal/api/user/synthesis_app.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -19,8 +19,9 @@ type ProductCategories struct {
|
|||||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_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"` // 分类名称
|
Name string `gorm:"column:name;not null;comment:分类名称" json:"name"` // 分类名称
|
||||||
ParentID int64 `gorm:"column:parent_id;comment:父分类ID(可空)" json:"parent_id"` // 父分类ID(可空)
|
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禁用
|
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"`
|
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
|
// TableName ProductCategories's table name
|
||||||
|
|||||||
@ -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.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch())
|
||||||
adminAuthApiRouter.POST("/shipping/orders/cancel", intc.RequireAdminAction("shipping:modify"), adminHandler.AdminCancelShipping())
|
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.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund())
|
||||||
adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())
|
adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())
|
||||||
adminAuthApiRouter.GET("/pay/refunds/:refund_no", intc.RequireAdminAction("refund:view"), adminHandler.GetRefundDetail())
|
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())
|
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查过)
|
// 对对碰其他接口(不需要严查黑名单,或者已在preorder查过)
|
||||||
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
||||||
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
||||||
|
|||||||
@ -33,24 +33,27 @@ type service struct {
|
|||||||
logger logger.CustomLogger
|
logger logger.CustomLogger
|
||||||
readDB *dao.Query
|
readDB *dao.Query
|
||||||
writeDB *dao.Query
|
writeDB *dao.Query
|
||||||
|
repo mysql.Repo
|
||||||
listCache map[string]cachedList
|
listCache map[string]cachedList
|
||||||
detailCache map[int64]cachedDetail
|
detailCache map[int64]cachedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
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 {
|
type CreateCategoryInput struct {
|
||||||
Name string
|
Name string
|
||||||
ParentID int64
|
ParentID int64
|
||||||
Status int32
|
Status int32
|
||||||
|
IsFragment *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModifyCategoryInput struct {
|
type ModifyCategoryInput struct {
|
||||||
Name *string
|
Name *string
|
||||||
ParentID *int64
|
ParentID *int64
|
||||||
Status *int32
|
Status *int32
|
||||||
|
IsFragment *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListCategoriesInput struct {
|
type ListCategoriesInput struct {
|
||||||
@ -62,6 +65,9 @@ type ListCategoriesInput struct {
|
|||||||
|
|
||||||
func (s *service) CreateCategory(ctx context.Context, in CreateCategoryInput) (*model.ProductCategories, error) {
|
func (s *service) CreateCategory(ctx context.Context, in CreateCategoryInput) (*model.ProductCategories, error) {
|
||||||
m := &model.ProductCategories{Name: in.Name, ParentID: in.ParentID, Status: in.Status}
|
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 {
|
if err := s.writeDB.ProductCategories.WithContext(ctx).Create(m); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -80,6 +86,9 @@ func (s *service) ModifyCategory(ctx context.Context, id int64, in ModifyCategor
|
|||||||
if in.Status != nil {
|
if in.Status != nil {
|
||||||
set["status"] = *in.Status
|
set["status"] = *in.Status
|
||||||
}
|
}
|
||||||
|
if in.IsFragment != nil {
|
||||||
|
set["is_fragment"] = *in.IsFragment
|
||||||
|
}
|
||||||
if len(set) == 0 {
|
if len(set) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -140,6 +149,7 @@ type ListProductsInput struct {
|
|||||||
Name string
|
Name string
|
||||||
CategoryID *int64
|
CategoryID *int64
|
||||||
Status *int32
|
Status *int32
|
||||||
|
IsFragment *int32
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
}
|
}
|
||||||
@ -245,6 +255,17 @@ func (s *service) ListProducts(ctx context.Context, in ListProductsInput) (items
|
|||||||
if in.Status != nil {
|
if in.Status != nil {
|
||||||
q = q.Where(s.readDB.Products.Status.Eq(*in.Status))
|
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()
|
total, err = q.Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
92
internal/service/product/product_fragment_filter_test.go
Normal file
92
internal/service/product/product_fragment_filter_test.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
451
internal/service/synthesis/synthesis.go
Normal file
451
internal/service/synthesis/synthesis.go
Normal file
@ -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
|
||||||
|
}
|
||||||
95
internal/service/synthesis/synthesis_test.go
Normal file
95
internal/service/synthesis/synthesis_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -51,6 +51,10 @@ func (s *service) CreateAddressShare(ctx context.Context, userID int64, inventor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", time.Time{}, err
|
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)
|
token, err := signShareToken(userID, inventoryID, expiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", time.Time{}, err
|
return "", "", time.Time{}, err
|
||||||
@ -422,6 +426,9 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
|||||||
productIDs := make([]int64, 0, len(uniq))
|
productIDs := make([]int64, 0, len(uniq))
|
||||||
productIDSet := make(map[int64]struct{})
|
productIDSet := make(map[int64]struct{})
|
||||||
|
|
||||||
|
// 预先获取碎片分类的商品ID集合
|
||||||
|
fragmentPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||||
|
|
||||||
for _, id := range uniq {
|
for _, id := range uniq {
|
||||||
inv := invMap[id]
|
inv := invMap[id]
|
||||||
if inv == nil {
|
if inv == nil {
|
||||||
@ -459,6 +466,13 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
|||||||
}{ID: id, Reason: "invalid_status"})
|
}{ID: id, Reason: "invalid_status"})
|
||||||
continue
|
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)
|
validInvs = append(validInvs, inv)
|
||||||
if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 {
|
if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 {
|
||||||
productIDSet[inv.ProductID] = struct{}{}
|
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")
|
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. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||||
productIDs := make([]int64, 0, len(invList))
|
productIDs := make([]int64, 0, len(invList))
|
||||||
productIDSet := make(map[int64]struct{})
|
productIDSet := make(map[int64]struct{})
|
||||||
@ -788,3 +817,51 @@ func (s *service) VoidUserInventory(ctx context.Context, adminID int64, userID i
|
|||||||
_ = adminID
|
_ = adminID
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type AggregatedInventory struct {
|
|||||||
ShippingStatus int32 `json:"shipping_status"`
|
ShippingStatus int32 `json:"shipping_status"`
|
||||||
Status int32 `json:"status"` // 用于区分 1持有 3已处理
|
Status int32 `json:"status"` // 用于区分 1持有 3已处理
|
||||||
UpdatedAt string `json:"updated_at"`
|
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) {
|
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]
|
pagedGroups := groupResults[start:end]
|
||||||
|
|
||||||
// 2. 获取商品详情和发货状态
|
// 2. 获取商品详情和发货状态
|
||||||
|
// 预加载碎片分类商品ID
|
||||||
|
fragPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||||
items = make([]*AggregatedInventory, 0, len(pagedGroups))
|
items = make([]*AggregatedInventory, 0, len(pagedGroups))
|
||||||
for _, g := range pagedGroups {
|
for _, g := range pagedGroups {
|
||||||
// 查询该分组下的所有 inventory id
|
// 查询该分组下的所有 inventory id
|
||||||
@ -302,6 +305,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, isFrag := fragPIDs[g.ProductID]
|
||||||
items = append(items, &AggregatedInventory{
|
items = append(items, &AggregatedInventory{
|
||||||
ProductID: g.ProductID,
|
ProductID: g.ProductID,
|
||||||
ProductName: name,
|
ProductName: name,
|
||||||
@ -313,6 +317,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
ShippingStatus: shipStatus,
|
ShippingStatus: shipStatus,
|
||||||
Status: g.Status,
|
Status: g.Status,
|
||||||
UpdatedAt: g.UpdatedAt.Format("2006-01-02 15:04:05"),
|
UpdatedAt: g.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
IsFragment: isFrag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
migrations/20260319_fragment_synthesis.sql
Normal file
40
migrations/20260319_fragment_synthesis.sql
Normal file
@ -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 '碎片合成日志';
|
||||||
Loading…
x
Reference in New Issue
Block a user