Merge remote-tracking branch 'origin/zuncle'

This commit is contained in:
win 2026-03-19 22:37:56 +08:00
commit eaf4af4ba4
27 changed files with 1295 additions and 88 deletions

View File

@ -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),
}
}

View File

@ -22,18 +22,20 @@ type cardsRequest struct {
}
type cardStatResponse struct {
ItemCardSales int64 `json:"itemCardSales"`
DrawCount int64 `json:"drawCount"`
NewUsers int64 `json:"newUsers"`
TotalPoints float64 `json:"totalPoints"`
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
TotalCoupons int64 `json:"totalCoupons"`
TotalItemCards int64 `json:"totalItemCards"`
TotalGamePasses int64 `json:"totalGamePasses"`
ItemCardChange string `json:"itemCardChange"`
DrawChange string `json:"drawChange"`
NewUserChange string `json:"newUserChange"`
PointsChange string `json:"pointsChange"`
ItemCardSales int64 `json:"itemCardSales"`
DrawCount int64 `json:"drawCount"`
NewUsers int64 `json:"newUsers"`
TotalPoints float64 `json:"totalPoints"`
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
TotalCoupons int64 `json:"totalCoupons"` // 存量优惠券数量
TotalCouponValue int64 `json:"totalCouponValue"` // 优惠券总价值(分)
TotalItemCards int64 `json:"totalItemCards"` // 存量道具卡
TotalGamePasses int64 `json:"totalGamePasses"` // 存量次卡(余次)
TotalGamePassValue int64 `json:"totalGamePassValue"` // 次卡总价值(分)
ItemCardChange string `json:"itemCardChange"`
DrawChange string `json:"drawChange"`
NewUserChange string `json:"newUserChange"`
PointsChange string `json:"pointsChange"`
}
func (h *handler) DashboardCards() core.HandlerFunc {
@ -141,11 +143,23 @@ func (h *handler) DashboardCards() core.HandlerFunc {
prevDelta = prevDeltaRows[0].Sum
}
// 批量:存量优惠券 (未使用)
// 批量:存量优惠券 (未使用) 及优惠券总价值
tcCur, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.UserCoupons.Status.Eq(1)).
Count()
// 计算优惠券总价值关联system_coupons表获取面值
var tcValue int64
tcValueResult := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Raw(`
SELECT COALESCE(SUM(sc.discount_value), 0) as total_value
FROM user_coupons uc
JOIN system_coupons sc ON sc.id = uc.coupon_id
WHERE uc.status = 1
`).Scan(&tcValue)
if tcValueResult.Error == nil {
// tcValue已经通过Scan赋值
}
// 批量:存量道具卡 (有效)
ticCur, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.UserItemCards.Status.Eq(1)).
@ -156,7 +170,7 @@ func (h *handler) DashboardCards() core.HandlerFunc {
Where(h.readDB.UserInventory.Status.Eq(1)).
Count()
// 批量:存量次卡 (剩余次数)
// 批量:存量次卡 (剩余次数) 及次卡总价值
var tgpRows []struct{ Sum int64 }
_ = h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.UserGamePasses.ExpiredAt.Gt(time.Now())).
@ -168,14 +182,28 @@ func (h *handler) DashboardCards() core.HandlerFunc {
tgpCur = tgpRows[0].Sum
}
// 计算次卡总价值关联activities表获取单次价格
var tgpValue int64
tgpValueResult := h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).UnderlyingDB().Raw(`
SELECT COALESCE(SUM(ugp.remaining * COALESCE(a.price_draw, 0)), 0) as total_value
FROM user_game_passes ugp
LEFT JOIN activities a ON a.id = ugp.activity_id
WHERE ugp.expired_at > NOW() OR ugp.expired_at IS NULL OR ugp.expired_at = '0000-00-00 00:00:00'
`).Scan(&tgpValue)
if tgpValueResult.Error != nil {
tgpValue = 0
}
rsp.ItemCardSales = icCur
rsp.DrawCount = dlCur
rsp.NewUsers = nuCur
rsp.TotalPoints = h.userSvc.CentsToPointsFloat(ctx.RequestContext(), tpCur)
rsp.TotalInventory = tinvCur
rsp.TotalCoupons = tcCur
rsp.TotalCouponValue = tcValue
rsp.TotalItemCards = ticCur
rsp.TotalGamePasses = tgpCur
rsp.TotalGamePassValue = tgpValue
rsp.ItemCardChange = percentChange(icPrev, icCur)
rsp.DrawChange = percentChange(dlPrev, dlCur)
rsp.NewUserChange = percentChange(nuPrev, nuCur)

View File

@ -1031,11 +1031,7 @@ func (h *handler) CancelOrder() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21006, "订单不存在"))
return
}
_, err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(order.ID), h.readDB.Orders.Status.Eq(1)).Updates(map[string]any{
h.readDB.Orders.Status.ColumnName().String(): 3,
h.readDB.Orders.CancelledAt.ColumnName().String(): time.Now(),
})
if err != nil {
if _, err = h.userSvc.CancelOrder(ctx.RequestContext(), order.UserID, order.ID, "admin_cancel"); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error()))
return
}

View File

@ -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)
}

View File

@ -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

View File

@ -1,6 +1,7 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"time"
@ -8,6 +9,7 @@ import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
)
@ -429,3 +431,91 @@ func (h *handler) GetShippingOrderDetail() core.HandlerFunc {
ctx.Payload(rsp)
}
}
type cancelShippingRequest struct {
RecordIDs []int64 `json:"record_ids"`
}
type cancelShippingResponse struct {
CancelledCount int64 `json:"cancelled_count"`
}
// AdminCancelShipping 管理端撤销发货申请
// @Summary 管理端撤销发货申请
// @Description 将待发货(status=1)的记录撤销为已取消(status=5),并恢复对应库存状态
// @Tags 管理端.发货管理
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body cancelShippingRequest true "请求参数"
// @Success 200 {object} cancelShippingResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/shipping/orders/cancel [post]
func (h *handler) AdminCancelShipping() core.HandlerFunc {
return func(ctx core.Context) {
req := new(cancelShippingRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.RecordIDs) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "record_ids不能为空"))
return
}
if len(req.RecordIDs) > 100 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "单次最多处理100条记录"))
return
}
adminID := ctx.SessionUserInfo().Id
var cancelledCount int64
err := h.writeDB.Transaction(func(tx *dao.Query) error {
records, err := tx.ShippingRecords.WithContext(ctx.RequestContext()).
Select(tx.ShippingRecords.ID, tx.ShippingRecords.InventoryID, tx.ShippingRecords.UserID).
Where(tx.ShippingRecords.ID.In(req.RecordIDs...)).
Where(tx.ShippingRecords.Status.Eq(1)).
Find()
if err != nil {
return fmt.Errorf("query shipping records failed: %w", err)
}
if len(records) == 0 {
return fmt.Errorf("没有找到待发货记录,可能已被处理")
}
for _, rec := range records {
res, err := tx.ShippingRecords.WithContext(ctx.RequestContext()).
Where(tx.ShippingRecords.ID.Eq(rec.ID)).
Where(tx.ShippingRecords.Status.Eq(1)).
Update(tx.ShippingRecords.Status, 5)
if err != nil {
return fmt.Errorf("update shipping record failed: %w", err)
}
if res.RowsAffected == 0 {
continue
}
remark := fmt.Sprintf("|shipping_cancelled_by_admin:%d", adminID)
dbResult := tx.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().Exec(
"UPDATE user_inventory SET status=1, shipping_no='', remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
remark, rec.InventoryID, rec.UserID,
)
if dbResult.Error != nil {
return fmt.Errorf("restore inventory failed: %w", dbResult.Error)
}
if dbResult.RowsAffected == 0 {
return fmt.Errorf("restore inventory failed: inventory id=%d not matched", rec.InventoryID)
}
cancelledCount++
}
return nil
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return
}
ctx.Payload(&cancelShippingResponse{CancelledCount: cancelledCount})
}
}

View 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})
}
}

View File

@ -1,6 +1,8 @@
package app
import (
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
@ -120,7 +122,8 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, PointsRequired: pts, Status: it.Status, Supported: true}
}
case "coupon":
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1), h.readDB.SystemCoupons.ShowInMiniapp.Eq(1))
now := time.Now()
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1), h.readDB.SystemCoupons.ShowInMiniapp.Eq(1), h.readDB.SystemCoupons.ValidEnd.Gt(now))
// 关键词筛选
if req.Keyword != "" {
q = q.Where(h.readDB.SystemCoupons.Name.Like("%" + req.Keyword + "%"))

View File

@ -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),
}
}

View File

@ -1,11 +1,13 @@
package app
import (
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"net/http"
"strconv"
)
type redeemCouponRequest struct {
@ -52,6 +54,10 @@ func (h *handler) RedeemPointsToCoupon() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, "only amount coupons supported"))
return
}
if !sc.ValidEnd.IsZero() && sc.ValidEnd.Before(time.Now()) {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150005, "该优惠券模板已过期,无法兑换"))
return
}
// sc.DiscountValue 是优惠券面值(分),直接用于扣除
// 例如30 元优惠券 = 3000 分
needCents := sc.DiscountValue

View 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})
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -364,6 +364,15 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.GET("/shipping/orders", intc.RequireAdminAction("shipping:view"), adminHandler.ListShippingOrders())
adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail())
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())
@ -529,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())

View File

@ -229,8 +229,7 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
// 如果是金额券status=1。
// 如果是满减券status=1。
if uc.Status != 1 {
fmt.Printf("[优惠券] 状态不可用 id=%d status=%d\n", userCouponID, uc.Status)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券不可用")
}
fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s 余额=%d\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string {
@ -243,25 +242,20 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First()
now := time.Now()
if sc == nil {
fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券模板不存在")
}
if uc.ValidStart.After(now) {
fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券未到使用时间")
}
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券已过期")
}
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
if !scopeOK {
fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券不适用于当前活动")
}
if order.TotalAmount < sc.MinSpend {
fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount)
return 0, nil, nil
return 0, nil, fmt.Errorf("订单金额未达优惠券使用门槛")
}
// 50% 封顶

View File

@ -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

View 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))
}
}

View 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
}

View 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)
}
}

View File

@ -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
}

View File

@ -26,6 +26,9 @@ func (s *service) AddCoupon(ctx context.Context, userID int64, couponID int64) e
}())
return errors.New("coupon not found or disabled")
}
if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) {
return errors.New("coupon template expired")
}
// 配额检查:若 TotalQuantity > 0 则限制发放总量
if tpl.TotalQuantity > 0 {
issued, ierr := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.CouponID.Eq(couponID)).Count()

View File

@ -53,12 +53,22 @@ func (s *service) ListAppCoupons(ctx context.Context, userID int64, status int32
Where("`"+tableName+"`.user_id = ?", userID)
switch status {
case 1: // 有效:余额 > 0 且 未过期
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
case 1: // 有效:余额 > 0 且 未过期NULL/零值 valid_end 视为永久有效)
db = db.Where(
tableName+".balance_amount > ? AND "+
"("+tableName+".valid_end IS NULL OR "+tableName+".valid_end = ? OR "+tableName+".valid_end > ?) AND "+
tableName+".status IN (?, ?, ?)",
0, time.Time{}, now, 1, 2, 4,
)
case 2: // 已失效:余额用完 OR 已标记过期 OR 已过截止时间
db = db.Where("("+tableName+".balance_amount = ?) OR "+tableName+".status = ? OR "+tableName+".valid_end <= ?", 0, 3, now)
db = db.Where("("+tableName+".balance_amount = ?) OR "+tableName+".status = ? OR ("+tableName+".valid_end IS NOT NULL AND "+tableName+".valid_end != ? AND "+tableName+".valid_end <= ?)", 0, 3, time.Time{}, now)
default:
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
db = db.Where(
tableName+".balance_amount > ? AND "+
"("+tableName+".valid_end IS NULL OR "+tableName+".valid_end = ? OR "+tableName+".valid_end > ?) AND "+
tableName+".status IN (?, ?, ?)",
0, time.Time{}, now, 1, 2, 4,
)
}
if err = db.Count(&total).Error; err != nil {

View File

@ -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,
})
}

View File

@ -59,32 +59,66 @@ func (s *service) CancelOrder(ctx context.Context, userID int64, orderID int64,
// 4. 退还优惠券(恢复预扣的余额和状态)
if order.CouponID > 0 {
var oc struct {
AppliedAmount int64
// 幂等校验:若已记录过 cancel_refund 流水则跳过
refundExists, err := tx.UserCouponLedger.WithContext(ctx).Where(
tx.UserCouponLedger.UserCouponID.Eq(order.CouponID),
tx.UserCouponLedger.OrderID.Eq(order.ID),
tx.UserCouponLedger.Action.Eq("cancel_refund"),
).Count()
if err != nil {
return err
}
// 获取该订单实际扣减的优惠券金额
_ = tx.OrderCoupons.WithContext(ctx).Where(tx.OrderCoupons.OrderID.Eq(order.ID), tx.OrderCoupons.UserCouponID.Eq(order.CouponID)).Scan(&oc)
// 执行原子回退:增加余额 + 重置状态 + 清除占用订单
res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount + ?,
status = 1,
used_order_id = 0,
used_at = NULL
WHERE id = ? AND used_order_id = ?
`, oc.AppliedAmount, order.CouponID, order.ID)
if refundExists == 0 {
var oc struct {
AppliedAmount int64
}
// 优先从 order_coupons 获取实际抵扣金额
if err := tx.OrderCoupons.WithContext(ctx).Where(
tx.OrderCoupons.OrderID.Eq(order.ID),
tx.OrderCoupons.UserCouponID.Eq(order.CouponID),
).Scan(&oc); err != nil {
return err
}
if res.RowsAffected > 0 {
// 记录退还流水
_ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
UserID: userID,
UserCouponID: order.CouponID,
ChangeAmount: oc.AppliedAmount,
OrderID: order.ID,
Action: "cancel_refund",
CreatedAt: time.Now(),
})
// 兜底order_coupons 无记录时,从流水中回推预扣金额
if oc.AppliedAmount <= 0 {
if err := tx.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
SELECT COALESCE(SUM(CASE WHEN change_amount < 0 THEN -change_amount ELSE 0 END), 0) AS applied_amount
FROM user_coupon_ledger
WHERE user_id = ? AND user_coupon_id = ? AND order_id = ? AND action IN ('reserve', 'usage')
`, userID, order.CouponID, order.ID).Scan(&oc).Error; err != nil {
return err
}
}
if oc.AppliedAmount > 0 {
// 恢复余额 + 重置状态(不依赖 used_order_id
res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount + ?,
status = 1,
used_order_id = 0,
used_at = NULL
WHERE id = ?
`, oc.AppliedAmount, order.CouponID)
if res.Error != nil {
return res.Error
}
if res.RowsAffected > 0 {
if err := tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
UserID: userID,
UserCouponID: order.CouponID,
ChangeAmount: oc.AppliedAmount,
OrderID: order.ID,
Action: "cancel_refund",
CreatedAt: time.Now(),
}); err != nil {
return err
}
}
}
}
}

View 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 '合成目标商品IDproducts.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 '配方IDfragment_synthesis_recipes.id',
fragment_product_id BIGINT NOT NULL COMMENT '碎片商品IDproducts.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 '合成产出的资产IDuser_inventory.id',
INDEX idx_user (user_id),
INDEX idx_recipe (recipe_id)
) COMMENT '碎片合成日志';