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"
|
||||
productsvc "bindbox-game/internal/service/product"
|
||||
snapshotsvc "bindbox-game/internal/service/snapshot"
|
||||
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
||||
titlesvc "bindbox-game/internal/service/title"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
@ -37,6 +38,7 @@ type handler struct {
|
||||
rollbackSvc snapshotsvc.RollbackService
|
||||
douyinSvc douyinsvc.Service
|
||||
livestream livestreamsvc.Service
|
||||
synthesis synthesissvc.Service
|
||||
}
|
||||
|
||||
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,
|
||||
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
||||
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
||||
synthesis: synthesissvc.New(db),
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type createProductCategoryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
IsFragment *int32 `json:"is_fragment"`
|
||||
}
|
||||
|
||||
type createProductCategoryResponse struct {
|
||||
@ -44,7 +45,7 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
@ -57,9 +58,10 @@ func (h *handler) CreateProductCategory() core.HandlerFunc {
|
||||
}
|
||||
|
||||
type modifyProductCategoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
Status *int32 `json:"status"`
|
||||
Name *string `json:"name"`
|
||||
ParentID *int64 `json:"parent_id"`
|
||||
Status *int32 `json:"status"`
|
||||
IsFragment *int32 `json:"is_fragment"`
|
||||
}
|
||||
|
||||
type pcSimpleMessage struct {
|
||||
@ -90,7 +92,7 @@ func (h *handler) ModifyProductCategory() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
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()))
|
||||
return
|
||||
}
|
||||
@ -132,10 +134,11 @@ type listProductCategoriesRequest struct {
|
||||
}
|
||||
|
||||
type productCategoryListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
Status int32 `json:"status"`
|
||||
IsFragment int32 `json:"is_fragment"`
|
||||
}
|
||||
type listProductCategoriesResponse struct {
|
||||
Page int `json:"page"`
|
||||
@ -175,7 +178,7 @@ func (h *handler) ListProductCategories() core.HandlerFunc {
|
||||
res.Total = total
|
||||
res.List = make([]productCategoryListItem, len(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)
|
||||
}
|
||||
|
||||
@ -132,6 +132,7 @@ type listProductsRequest struct {
|
||||
Name string `form:"name"`
|
||||
CategoryID *int64 `form:"category_id"`
|
||||
Status *int32 `form:"status"`
|
||||
IsFragment *int32 `form:"is_fragment"`
|
||||
Page int `form:"page"`
|
||||
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)))
|
||||
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 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
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"
|
||||
gamesvc "bindbox-game/internal/service/game"
|
||||
"bindbox-game/internal/service/sysconfig"
|
||||
synthesissvc "bindbox-game/internal/service/synthesis"
|
||||
tasksvc "bindbox-game/internal/service/task_center"
|
||||
titlesvc "bindbox-game/internal/service/title"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
@ -18,8 +19,9 @@ type handler struct {
|
||||
readDB *dao.Query
|
||||
user usersvc.Service
|
||||
task tasksvc.Service
|
||||
douyin douyin.Service
|
||||
repo mysql.Repo
|
||||
douyin douyin.Service
|
||||
repo mysql.Repo
|
||||
synthesis synthesissvc.Service
|
||||
}
|
||||
|
||||
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()),
|
||||
user: userSvc,
|
||||
task: taskSvc,
|
||||
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
||||
repo: db,
|
||||
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
|
||||
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"` // 更新时间
|
||||
Name string `gorm:"column:name;not null;comment:分类名称" json:"name"` // 分类名称
|
||||
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禁用
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
||||
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 2禁用" json:"status"` // 状态:1启用 2禁用
|
||||
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
|
||||
|
||||
@ -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.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.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())
|
||||
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())
|
||||
}
|
||||
|
||||
// 碎片合成
|
||||
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查过)
|
||||
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
||||
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
||||
|
||||
@ -33,24 +33,27 @@ type service struct {
|
||||
logger logger.CustomLogger
|
||||
readDB *dao.Query
|
||||
writeDB *dao.Query
|
||||
repo mysql.Repo
|
||||
listCache map[string]cachedList
|
||||
detailCache map[int64]cachedDetail
|
||||
}
|
||||
|
||||
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 {
|
||||
Name string
|
||||
ParentID int64
|
||||
Status int32
|
||||
Name string
|
||||
ParentID int64
|
||||
Status int32
|
||||
IsFragment *int32
|
||||
}
|
||||
|
||||
type ModifyCategoryInput struct {
|
||||
Name *string
|
||||
ParentID *int64
|
||||
Status *int32
|
||||
Name *string
|
||||
ParentID *int64
|
||||
Status *int32
|
||||
IsFragment *int32
|
||||
}
|
||||
|
||||
type ListCategoriesInput struct {
|
||||
@ -62,6 +65,9 @@ type ListCategoriesInput struct {
|
||||
|
||||
func (s *service) CreateCategory(ctx context.Context, in CreateCategoryInput) (*model.ProductCategories, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -80,6 +86,9 @@ func (s *service) ModifyCategory(ctx context.Context, id int64, in ModifyCategor
|
||||
if in.Status != nil {
|
||||
set["status"] = *in.Status
|
||||
}
|
||||
if in.IsFragment != nil {
|
||||
set["is_fragment"] = *in.IsFragment
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -140,6 +149,7 @@ type ListProductsInput struct {
|
||||
Name string
|
||||
CategoryID *int64
|
||||
Status *int32
|
||||
IsFragment *int32
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
@ -245,6 +255,17 @@ func (s *service) ListProducts(ctx context.Context, in ListProductsInput) (items
|
||||
if in.Status != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
@ -422,6 +426,9 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
||||
productIDs := make([]int64, 0, len(uniq))
|
||||
productIDSet := make(map[int64]struct{})
|
||||
|
||||
// 预先获取碎片分类的商品ID集合
|
||||
fragmentPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||
|
||||
for _, id := range uniq {
|
||||
inv := invMap[id]
|
||||
if inv == nil {
|
||||
@ -459,6 +466,13 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
|
||||
}{ID: id, Reason: "invalid_status"})
|
||||
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)
|
||||
if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 {
|
||||
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")
|
||||
}
|
||||
|
||||
// 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. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||
productIDs := make([]int64, 0, len(invList))
|
||||
productIDSet := make(map[int64]struct{})
|
||||
@ -788,3 +817,51 @@ func (s *service) VoidUserInventory(ctx context.Context, adminID int64, userID i
|
||||
_ = adminID
|
||||
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"`
|
||||
Status int32 `json:"status"` // 用于区分 1持有 3已处理
|
||||
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) {
|
||||
@ -262,6 +263,8 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
pagedGroups := groupResults[start:end]
|
||||
|
||||
// 2. 获取商品详情和发货状态
|
||||
// 预加载碎片分类商品ID
|
||||
fragPIDs := s.getFragmentProductIDs(ctx, nil)
|
||||
items = make([]*AggregatedInventory, 0, len(pagedGroups))
|
||||
for _, g := range pagedGroups {
|
||||
// 查询该分组下的所有 inventory id
|
||||
@ -302,6 +305,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
}
|
||||
}
|
||||
|
||||
_, isFrag := fragPIDs[g.ProductID]
|
||||
items = append(items, &AggregatedInventory{
|
||||
ProductID: g.ProductID,
|
||||
ProductName: name,
|
||||
@ -313,6 +317,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
ShippingStatus: shipStatus,
|
||||
Status: g.Status,
|
||||
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