1086 lines
36 KiB
Go
Executable File
1086 lines
36 KiB
Go
Executable File
package admin
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"bindbox-game/internal/code"
|
||
"bindbox-game/internal/pkg/core"
|
||
"bindbox-game/internal/pkg/validation"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
"bindbox-game/internal/service/channel"
|
||
"bindbox-game/internal/service/livestream"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ========== 直播间活动管理 ==========
|
||
|
||
type createLivestreamActivityRequest struct {
|
||
Name string `json:"name" binding:"required"`
|
||
StreamerName string `json:"streamer_name"`
|
||
StreamerContact string `json:"streamer_contact"`
|
||
ChannelID *int64 `json:"channel_id"`
|
||
DouyinProductID string `json:"douyin_product_id"`
|
||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
|
||
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
|
||
TicketPrice int64 `json:"ticket_price"`
|
||
StartTime string `json:"start_time"`
|
||
EndTime string `json:"end_time"`
|
||
}
|
||
|
||
type livestreamActivityResponse struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
StreamerName string `json:"streamer_name"`
|
||
StreamerContact string `json:"streamer_contact"`
|
||
ChannelID int64 `json:"channel_id"`
|
||
ChannelCode string `json:"channel_code"`
|
||
ChannelName string `json:"channel_name"`
|
||
AccessCode string `json:"access_code"`
|
||
DouyinProductID string `json:"douyin_product_id"`
|
||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量
|
||
TicketPrice int64 `json:"ticket_price"`
|
||
Status int32 `json:"status"`
|
||
StartTime string `json:"start_time,omitempty"`
|
||
EndTime string `json:"end_time,omitempty"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// CreateLivestreamActivity 创建直播间活动
|
||
// @Summary 创建直播间活动
|
||
// @Description 创建新的直播间活动,自动生成唯一访问码
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param RequestBody body createLivestreamActivityRequest true "请求参数"
|
||
// @Success 200 {object} livestreamActivityResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities [post]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(createLivestreamActivityRequest)
|
||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
var channelCode string
|
||
var channelName string
|
||
if req.ChannelID != nil && *req.ChannelID > 0 {
|
||
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
|
||
channelCode = ch.Code
|
||
channelName = ch.Name
|
||
if req.StreamerName == "" {
|
||
req.StreamerName = ch.Name
|
||
}
|
||
} else if err == channel.ErrChannelNotFound {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
|
||
return
|
||
} else {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
}
|
||
|
||
input := livestream.CreateActivityInput{
|
||
Name: req.Name,
|
||
StreamerName: req.StreamerName,
|
||
StreamerContact: req.StreamerContact,
|
||
ChannelID: func() int64 {
|
||
if req.ChannelID != nil {
|
||
return *req.ChannelID
|
||
}
|
||
return 0
|
||
}(),
|
||
ChannelCode: channelCode,
|
||
DouyinProductID: req.DouyinProductID,
|
||
OrderRewardType: req.OrderRewardType,
|
||
OrderRewardQuantity: req.OrderRewardQuantity,
|
||
TicketPrice: req.TicketPrice,
|
||
}
|
||
|
||
if req.StartTime != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
|
||
input.StartTime = &t
|
||
}
|
||
}
|
||
if req.EndTime != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
|
||
input.EndTime = &t
|
||
}
|
||
}
|
||
|
||
activity, err := h.livestream.CreateActivity(ctx.RequestContext(), input)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
displayChannelName := channelName
|
||
if displayChannelName == "" && activity.ChannelCode != "" {
|
||
displayChannelName = activity.ChannelCode
|
||
}
|
||
|
||
ctx.Payload(&livestreamActivityResponse{
|
||
ID: activity.ID,
|
||
Name: activity.Name,
|
||
StreamerName: activity.StreamerName,
|
||
StreamerContact: activity.StreamerContact,
|
||
ChannelID: activity.ChannelID,
|
||
ChannelCode: activity.ChannelCode,
|
||
ChannelName: displayChannelName,
|
||
AccessCode: activity.AccessCode,
|
||
DouyinProductID: activity.DouyinProductID,
|
||
OrderRewardType: activity.OrderRewardType,
|
||
OrderRewardQuantity: activity.OrderRewardQuantity,
|
||
TicketPrice: int64(activity.TicketPrice),
|
||
Status: activity.Status,
|
||
CreatedAt: activity.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
})
|
||
}
|
||
}
|
||
|
||
type updateLivestreamActivityRequest struct {
|
||
Name string `json:"name"`
|
||
StreamerName string `json:"streamer_name"`
|
||
StreamerContact string `json:"streamer_contact"`
|
||
ChannelID *int64 `json:"channel_id"`
|
||
DouyinProductID string `json:"douyin_product_id"`
|
||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
|
||
TicketPrice *int64 `json:"ticket_price"`
|
||
Status *int32 `json:"status"`
|
||
StartTime string `json:"start_time"`
|
||
EndTime string `json:"end_time"`
|
||
}
|
||
|
||
// UpdateLivestreamActivity 更新直播间活动
|
||
// @Summary 更新直播间活动
|
||
// @Description 更新直播间活动信息
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path integer true "活动ID"
|
||
// @Param RequestBody body updateLivestreamActivityRequest true "请求参数"
|
||
// @Success 200 {object} simpleMessageResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{id} [put]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
req := new(updateLivestreamActivityRequest)
|
||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
var channelCodeValue string
|
||
var channelCodePtr *string
|
||
if req.ChannelID != nil {
|
||
if *req.ChannelID > 0 {
|
||
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
|
||
channelCodeValue = ch.Code
|
||
channelCodePtr = &channelCodeValue
|
||
if req.StreamerName == "" {
|
||
req.StreamerName = ch.Name
|
||
}
|
||
} else if err == channel.ErrChannelNotFound {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
|
||
return
|
||
} else {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
} else {
|
||
channelCodeValue = ""
|
||
channelCodePtr = &channelCodeValue
|
||
}
|
||
}
|
||
|
||
input := livestream.UpdateActivityInput{
|
||
Name: req.Name,
|
||
StreamerName: req.StreamerName,
|
||
StreamerContact: req.StreamerContact,
|
||
DouyinProductID: req.DouyinProductID,
|
||
OrderRewardType: req.OrderRewardType,
|
||
OrderRewardQuantity: req.OrderRewardQuantity,
|
||
TicketPrice: req.TicketPrice,
|
||
Status: req.Status,
|
||
ChannelID: req.ChannelID,
|
||
ChannelCode: channelCodePtr,
|
||
}
|
||
|
||
if req.StartTime != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
|
||
input.StartTime = &t
|
||
}
|
||
}
|
||
if req.EndTime != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
|
||
input.EndTime = &t
|
||
}
|
||
}
|
||
|
||
if err := h.livestream.UpdateActivity(ctx.RequestContext(), id, input); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&simpleMessageResponse{Message: "操作成功"})
|
||
}
|
||
}
|
||
|
||
type listLivestreamActivitiesRequest struct {
|
||
Page int `form:"page"`
|
||
PageSize int `form:"page_size"`
|
||
Status *int32 `form:"status"`
|
||
}
|
||
|
||
type listLivestreamActivitiesResponse struct {
|
||
List []livestreamActivityResponse `json:"list"`
|
||
Total int64 `json:"total"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"page_size"`
|
||
}
|
||
|
||
// ListLivestreamActivities 直播间活动列表
|
||
// @Summary 直播间活动列表
|
||
// @Description 获取直播间活动列表
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param page query int false "页码" default(1)
|
||
// @Param page_size query int false "每页数量" default(20)
|
||
// @Param status query int false "状态过滤"
|
||
// @Success 200 {object} listLivestreamActivitiesResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities [get]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(listLivestreamActivitiesRequest)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
if req.Page <= 0 {
|
||
req.Page = 1
|
||
}
|
||
if req.PageSize <= 0 {
|
||
req.PageSize = 20
|
||
}
|
||
|
||
list, total, err := h.livestream.ListActivities(ctx.RequestContext(), req.Page, req.PageSize, req.Status)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
channelIDs := make([]int64, 0, len(list))
|
||
for _, a := range list {
|
||
if a.ChannelID > 0 {
|
||
channelIDs = append(channelIDs, a.ChannelID)
|
||
}
|
||
}
|
||
channelNameMap := h.loadChannelNames(ctx.RequestContext(), channelIDs)
|
||
|
||
res := &listLivestreamActivitiesResponse{
|
||
List: make([]livestreamActivityResponse, len(list)),
|
||
Total: total,
|
||
Page: req.Page,
|
||
PageSize: req.PageSize,
|
||
}
|
||
|
||
for i, a := range list {
|
||
item := livestreamActivityResponse{
|
||
ID: a.ID,
|
||
Name: a.Name,
|
||
StreamerName: a.StreamerName,
|
||
StreamerContact: a.StreamerContact,
|
||
AccessCode: a.AccessCode,
|
||
DouyinProductID: a.DouyinProductID,
|
||
OrderRewardType: a.OrderRewardType,
|
||
OrderRewardQuantity: a.OrderRewardQuantity,
|
||
TicketPrice: int64(a.TicketPrice),
|
||
Status: a.Status,
|
||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
}
|
||
item.ChannelID = a.ChannelID
|
||
item.ChannelCode = a.ChannelCode
|
||
if name := channelNameMap[a.ChannelID]; name != "" {
|
||
item.ChannelName = name
|
||
} else if a.ChannelCode != "" {
|
||
item.ChannelName = a.ChannelCode
|
||
}
|
||
if !a.StartTime.IsZero() {
|
||
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
if !a.EndTime.IsZero() {
|
||
item.EndTime = a.EndTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
res.List[i] = item
|
||
}
|
||
|
||
ctx.Payload(res)
|
||
}
|
||
}
|
||
|
||
// GetLivestreamActivity 获取直播间活动详情
|
||
// @Summary 获取直播间活动详情
|
||
// @Description 根据ID获取直播间活动详情
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path integer true "活动ID"
|
||
// @Success 200 {object} livestreamActivityResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{id} [get]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) GetLivestreamActivity() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
activity, err := h.livestream.GetActivity(ctx.RequestContext(), id)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
||
return
|
||
}
|
||
|
||
channelName := ""
|
||
if activity.ChannelID > 0 {
|
||
if names := h.loadChannelNames(ctx.RequestContext(), []int64{activity.ChannelID}); len(names) > 0 {
|
||
channelName = names[activity.ChannelID]
|
||
}
|
||
}
|
||
if channelName == "" && activity.ChannelCode != "" {
|
||
channelName = activity.ChannelCode
|
||
}
|
||
|
||
res := &livestreamActivityResponse{
|
||
ID: activity.ID,
|
||
Name: activity.Name,
|
||
StreamerName: activity.StreamerName,
|
||
StreamerContact: activity.StreamerContact,
|
||
ChannelID: activity.ChannelID,
|
||
ChannelCode: activity.ChannelCode,
|
||
ChannelName: channelName,
|
||
AccessCode: activity.AccessCode,
|
||
DouyinProductID: activity.DouyinProductID,
|
||
OrderRewardType: activity.OrderRewardType,
|
||
OrderRewardQuantity: activity.OrderRewardQuantity,
|
||
TicketPrice: int64(activity.TicketPrice),
|
||
Status: activity.Status,
|
||
CreatedAt: activity.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
}
|
||
if !activity.StartTime.IsZero() {
|
||
res.StartTime = activity.StartTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
if !activity.EndTime.IsZero() {
|
||
res.EndTime = activity.EndTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
ctx.Payload(res)
|
||
}
|
||
}
|
||
|
||
// DeleteLivestreamActivity 删除直播间活动
|
||
// @Summary 删除直播间活动
|
||
// @Description 删除指定直播间活动
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path integer true "活动ID"
|
||
// @Success 200 {object} simpleMessageResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{id} [delete]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) DeleteLivestreamActivity() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
if err := h.livestream.DeleteActivity(ctx.RequestContext(), id); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&simpleMessageResponse{Message: "删除成功"})
|
||
}
|
||
}
|
||
|
||
func (h *handler) loadChannelNames(ctx context.Context, ids []int64) map[int64]string {
|
||
result := make(map[int64]string)
|
||
if len(ids) == 0 {
|
||
return result
|
||
}
|
||
|
||
unique := make([]int64, 0, len(ids))
|
||
seen := make(map[int64]struct{})
|
||
for _, id := range ids {
|
||
if id <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
unique = append(unique, id)
|
||
}
|
||
if len(unique) == 0 {
|
||
return result
|
||
}
|
||
|
||
channels, err := h.readDB.Channels.WithContext(ctx).
|
||
Select(h.readDB.Channels.ID, h.readDB.Channels.Name).
|
||
Where(h.readDB.Channels.ID.In(unique...)).
|
||
Find()
|
||
if err != nil {
|
||
return result
|
||
}
|
||
for _, ch := range channels {
|
||
result[ch.ID] = ch.Name
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ========== 直播间奖品管理 ==========
|
||
|
||
type createLivestreamPrizeRequest struct {
|
||
Name string `json:"name"`
|
||
Image string `json:"image"`
|
||
Level int32 `json:"level"`
|
||
Weight int32 `json:"weight"`
|
||
Quantity int32 `json:"quantity"`
|
||
ProductID int64 `json:"product_id"`
|
||
CostPrice int64 `json:"cost_price"`
|
||
}
|
||
|
||
type livestreamPrizeResponse struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Image string `json:"image"`
|
||
Level int32 `json:"level"`
|
||
Weight int32 `json:"weight"`
|
||
Quantity int32 `json:"quantity"`
|
||
Remaining int64 `json:"remaining"`
|
||
ProductID int64 `json:"product_id"`
|
||
CostPrice int64 `json:"cost_price"`
|
||
Sort int32 `json:"sort"`
|
||
}
|
||
|
||
// CreateLivestreamPrizes 批量创建奖品
|
||
// @Summary 批量创建直播间奖品
|
||
// @Description 为指定活动批量创建奖品
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param activity_id path integer true "活动ID"
|
||
// @Param RequestBody body []createLivestreamPrizeRequest true "奖品列表"
|
||
// @Success 200 {object} simpleMessageResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{activity_id}/prizes [post]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) CreateLivestreamPrizes() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || activityID <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
var req []createLivestreamPrizeRequest
|
||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
var inputs []livestream.CreatePrizeInput
|
||
for _, p := range req {
|
||
inputs = append(inputs, livestream.CreatePrizeInput{
|
||
Name: p.Name,
|
||
Image: p.Image,
|
||
Weight: p.Weight,
|
||
Quantity: p.Quantity,
|
||
Level: p.Level,
|
||
ProductID: p.ProductID,
|
||
CostPrice: p.CostPrice,
|
||
})
|
||
}
|
||
|
||
if err := h.livestream.CreatePrizes(ctx.RequestContext(), activityID, inputs); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&simpleMessageResponse{Message: "创建成功"})
|
||
}
|
||
}
|
||
|
||
// ListLivestreamPrizes 获取活动奖品列表
|
||
// @Summary 获取直播间活动奖品列表
|
||
// @Description 获取指定活动的所有奖品
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param activity_id path integer true "活动ID"
|
||
// @Success 200 {object} []livestreamPrizeResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{activity_id}/prizes [get]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) ListLivestreamPrizes() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || activityID <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
prizes, err := h.livestream.ListPrizes(ctx.RequestContext(), activityID)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
res := make([]livestreamPrizeResponse, len(prizes))
|
||
for i, p := range prizes {
|
||
res[i] = livestreamPrizeResponse{
|
||
ID: p.ID,
|
||
Name: p.Name,
|
||
Image: p.Image,
|
||
Weight: p.Weight,
|
||
Quantity: p.Quantity,
|
||
Remaining: int64(p.Remaining),
|
||
Level: p.Level,
|
||
ProductID: p.ProductID,
|
||
CostPrice: p.CostPrice,
|
||
Sort: p.Sort,
|
||
}
|
||
}
|
||
|
||
ctx.Payload(res)
|
||
}
|
||
}
|
||
|
||
// DeleteLivestreamPrize 删除奖品
|
||
// @Summary 删除直播间奖品
|
||
// @Description 删除指定奖品
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param prize_id path integer true "奖品ID"
|
||
// @Success 200 {object} simpleMessageResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/prizes/{prize_id} [delete]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) DeleteLivestreamPrize() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
prizeID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || prizeID <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的奖品ID"))
|
||
return
|
||
}
|
||
|
||
if err := h.livestream.DeletePrize(ctx.RequestContext(), prizeID); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&simpleMessageResponse{Message: "删除成功"})
|
||
}
|
||
}
|
||
|
||
// UpdateLivestreamPrizeSortOrder 更新奖品排序
|
||
// @Summary 更新直播间奖品排序
|
||
// @Description 批量更新奖品的排序顺序
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param activity_id path integer true "活动ID"
|
||
// @Param RequestBody body map[string][]int64 true "奖品ID数组 {\"prize_ids\": [3,1,2]}"
|
||
// @Success 200 {object} simpleMessageResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{activity_id}/prizes/sort [put]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) UpdateLivestreamPrizeSortOrder() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || activityID <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
PrizeIDs []int64 `json:"prize_ids" binding:"required"`
|
||
}
|
||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
if err := h.livestream.UpdatePrizeSortOrder(ctx.RequestContext(), activityID, req.PrizeIDs); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&simpleMessageResponse{Message: "排序更新成功"})
|
||
}
|
||
}
|
||
|
||
// UpdateLivestreamPrize 更新单个奖品
|
||
// @Summary 更新直播间奖品
|
||
// @Description 更新指定奖品的信息
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param prize_id path integer true "奖品ID"
|
||
// @Param RequestBody body createLivestreamPrizeRequest true "奖品信息"
|
||
// @Success 200 {object} simpleMessageResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/prizes/{prize_id} [put]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) UpdateLivestreamPrize() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
prizeID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || prizeID <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的奖品ID"))
|
||
return
|
||
}
|
||
|
||
req := new(createLivestreamPrizeRequest)
|
||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
input := livestream.UpdatePrizeInput{
|
||
Name: req.Name,
|
||
Image: req.Image,
|
||
Weight: req.Weight,
|
||
Quantity: req.Quantity,
|
||
Level: req.Level,
|
||
ProductID: req.ProductID,
|
||
CostPrice: req.CostPrice,
|
||
}
|
||
|
||
if err := h.livestream.UpdatePrize(ctx.RequestContext(), prizeID, input); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&simpleMessageResponse{Message: "更新成功"})
|
||
}
|
||
}
|
||
|
||
// ========== 直播间中奖记录 ==========
|
||
|
||
type livestreamDrawLogResponse struct {
|
||
ID int64 `json:"id"`
|
||
ActivityID int64 `json:"activity_id"`
|
||
PrizeID int64 `json:"prize_id"`
|
||
PrizeName string `json:"prize_name"`
|
||
Level int32 `json:"level"`
|
||
DouyinOrderID int64 `json:"douyin_order_id"` // 关联ID
|
||
ShopOrderID string `json:"shop_order_id"` // 店铺订单号
|
||
LocalUserID int64 `json:"local_user_id"`
|
||
DouyinUserID string `json:"douyin_user_id"`
|
||
UserNickname string `json:"user_nickname"` // 用户昵称
|
||
SeedHash string `json:"seed_hash"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
type listLivestreamDrawLogsResponse struct {
|
||
List []livestreamDrawLogResponse `json:"list"`
|
||
Total int64 `json:"total"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"page_size"`
|
||
Stats *livestreamDrawLogsStats `json:"stats,omitempty"`
|
||
}
|
||
|
||
type livestreamDrawLogsStats struct {
|
||
UserCount int64 `json:"user_count"`
|
||
OrderCount int64 `json:"order_count"`
|
||
TotalRev int64 `json:"total_revenue"` // 总流水
|
||
TotalRefund int64 `json:"total_refund"`
|
||
TotalCost int64 `json:"total_cost"`
|
||
NetProfit int64 `json:"net_profit"`
|
||
}
|
||
|
||
type listLivestreamDrawLogsRequest struct {
|
||
Page int `form:"page"`
|
||
PageSize int `form:"page_size"`
|
||
StartTime string `form:"start_time"`
|
||
EndTime string `form:"end_time"`
|
||
Keyword string `form:"keyword"`
|
||
ExcludeUserIDs string `form:"exclude_user_ids"` // 逗号分隔的 UserIDs
|
||
}
|
||
|
||
// ListLivestreamDrawLogs 获取中奖记录
|
||
// @Summary 获取直播间中奖记录
|
||
// @Description 获取指定活动的中奖记录,支持时间范围和关键词筛选
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param activity_id path integer true "活动ID"
|
||
// @Param page query int false "页码" default(1)
|
||
// @Param page_size query int false "每页数量" default(20)
|
||
// @Param start_time query string false "开始时间 (YYYY-MM-DD)"
|
||
// @Param end_time query string false "结束时间 (YYYY-MM-DD)"
|
||
// @Param keyword query string false "搜索关键词 (昵称/订单号/奖品名称)"
|
||
// @Success 200 {object} listLivestreamDrawLogsResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{activity_id}/draw_logs [get]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || activityID <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
var activity model.LivestreamActivities
|
||
if err := h.repo.GetDbR().Select("id, ticket_price").Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "活动不存在"))
|
||
} else {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
}
|
||
return
|
||
}
|
||
ticketPrice := int64(activity.TicketPrice)
|
||
|
||
req := new(listLivestreamDrawLogsRequest)
|
||
_ = ctx.ShouldBindForm(req)
|
||
|
||
page := req.Page
|
||
pageSize := req.PageSize
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
|
||
// 解析时间范围 (支持 YYYY-MM-DD HH:mm:ss 和 YYYY-MM-DD)
|
||
var startTime, endTime *time.Time
|
||
if req.StartTime != "" {
|
||
// 尝试解析完整时间
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
|
||
startTime = &t
|
||
} else if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
|
||
// 只有日期,默认 00:00:00
|
||
startTime = &t
|
||
}
|
||
}
|
||
if req.EndTime != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
|
||
endTime = &t
|
||
} else if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
|
||
// 只有日期,设为当天结束 23:59:59.999
|
||
end := t.Add(24*time.Hour - time.Nanosecond)
|
||
endTime = &end
|
||
}
|
||
}
|
||
|
||
// 解析排除用户ID
|
||
var excludeUIDs []int64
|
||
if req.ExcludeUserIDs != "" {
|
||
parts := strings.Split(req.ExcludeUserIDs, ",")
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if val, err := strconv.ParseInt(p, 10, 64); err == nil && val > 0 {
|
||
excludeUIDs = append(excludeUIDs, val)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用底层 GORM 直接查询以支持 keyword
|
||
db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID)
|
||
|
||
if startTime != nil {
|
||
db = db.Where("created_at >= ?", startTime)
|
||
}
|
||
if endTime != nil {
|
||
db = db.Where("created_at <= ?", endTime)
|
||
}
|
||
if req.Keyword != "" {
|
||
keyword := "%" + req.Keyword + "%"
|
||
db = db.Where("(user_nickname LIKE ? OR shop_order_id LIKE ? OR prize_name LIKE ?)", keyword, keyword, keyword)
|
||
}
|
||
if len(excludeUIDs) > 0 {
|
||
db = db.Where("local_user_id NOT IN ?", excludeUIDs)
|
||
}
|
||
|
||
var total int64
|
||
db.Count(&total)
|
||
|
||
// 计算统计数据 (仅当有数据时)
|
||
var stats *livestreamDrawLogsStats
|
||
if total > 0 {
|
||
stats = &livestreamDrawLogsStats{}
|
||
// 1. 统计用户数
|
||
// 使用 Session() 避免污染主 db 对象
|
||
db.Session(&gorm.Session{}).Select("COUNT(DISTINCT douyin_user_id)").Scan(&stats.UserCount)
|
||
|
||
// 2. 获取所有相关的 douyin_order_id 和 prize_id,用于在内存中聚合金额和成本
|
||
// 注意:如果数据量极大,这里可能有性能隐患。但考虑到这是后台查询且通常带有筛选,暂且全量拉取 ID。
|
||
// 优化:只查需要的字段
|
||
type logMeta struct {
|
||
DouyinOrderID int64
|
||
PrizeID int64
|
||
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
||
LocalUserID int64
|
||
}
|
||
var metas []logMeta
|
||
// 使用不带分页的 db 克隆
|
||
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil {
|
||
orderIDs := make([]int64, 0, len(metas))
|
||
distinctOrderIDs := make(map[int64]bool)
|
||
prizeIDCount := make(map[int64]int64)
|
||
|
||
for _, m := range metas {
|
||
if !distinctOrderIDs[m.DouyinOrderID] {
|
||
distinctOrderIDs[m.DouyinOrderID] = true
|
||
orderIDs = append(orderIDs, m.DouyinOrderID)
|
||
}
|
||
}
|
||
stats.OrderCount = int64(len(orderIDs))
|
||
|
||
// 3. 查询订单金额和退款状态
|
||
if len(orderIDs) > 0 {
|
||
var orders []model.DouyinOrders
|
||
// 分批查询防止 IN 子句过长? 暂时假设量级可控
|
||
h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count").
|
||
Where("id IN ?", orderIDs).Find(&orders)
|
||
|
||
orderRefundMap := make(map[int64]bool)
|
||
|
||
for _, o := range orders {
|
||
// 统计营收 (总流水)
|
||
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
|
||
stats.TotalRev += orderAmount
|
||
|
||
if o.OrderStatus == 4 { // 已退款
|
||
stats.TotalRefund += orderAmount
|
||
orderRefundMap[o.ID] = true
|
||
}
|
||
}
|
||
|
||
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
|
||
for _, m := range metas {
|
||
if !orderRefundMap[m.DouyinOrderID] {
|
||
prizeIDCount[m.PrizeID]++
|
||
}
|
||
}
|
||
|
||
prizeCostMap := make(map[int64]int64)
|
||
if len(prizeIDCount) > 0 {
|
||
prizeIDs := make([]int64, 0, len(prizeIDCount))
|
||
for pid := range prizeIDCount {
|
||
prizeIDs = append(prizeIDs, pid)
|
||
}
|
||
var prizes []model.LivestreamPrizes
|
||
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
|
||
for _, p := range prizes {
|
||
prizeCostMap[p.ID] = p.CostPrice
|
||
}
|
||
}
|
||
|
||
// 预加载用户资产快照用于 shop_order_id 命中
|
||
type invRow struct {
|
||
UserID int64
|
||
ValueCents int64
|
||
Remark string
|
||
}
|
||
var invRows []invRow
|
||
_ = h.repo.GetDbR().Table("user_inventory").
|
||
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||
Where("user_inventory.status IN (1,3)").
|
||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||
Where("user_inventory.user_id > 0").
|
||
Scan(&invRows).Error
|
||
invByUser := make(map[int64][]invRow)
|
||
for _, v := range invRows {
|
||
invByUser[v.UserID] = append(invByUser[v.UserID], v)
|
||
}
|
||
metasByKey := make(map[string][]logMeta)
|
||
keyUser := make(map[string]int64)
|
||
keyOrder := make(map[string]string)
|
||
for _, m := range metas {
|
||
if orderRefundMap[m.DouyinOrderID] {
|
||
continue
|
||
}
|
||
key := fmt.Sprintf("%d|%s", m.LocalUserID, m.ShopOrderID)
|
||
metasByKey[key] = append(metasByKey[key], m)
|
||
keyUser[key] = m.LocalUserID
|
||
keyOrder[key] = m.ShopOrderID
|
||
}
|
||
|
||
for key, rows := range metasByKey {
|
||
if len(rows) == 0 {
|
||
continue
|
||
}
|
||
uid := keyUser[key]
|
||
shopOrderID := keyOrder[key]
|
||
|
||
var snapshotSum int64
|
||
if uid > 0 && shopOrderID != "" {
|
||
for _, inv := range invByUser[uid] {
|
||
if strings.Contains(inv.Remark, shopOrderID) {
|
||
snapshotSum += inv.ValueCents
|
||
}
|
||
}
|
||
}
|
||
|
||
if snapshotSum > 0 {
|
||
stats.TotalCost += snapshotSum
|
||
continue
|
||
}
|
||
|
||
for _, r := range rows {
|
||
stats.TotalCost += prizeCostMap[r.PrizeID]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
stats.NetProfit = (stats.TotalRev - stats.TotalRefund) - stats.TotalCost
|
||
}
|
||
|
||
var logs []model.LivestreamDrawLogs
|
||
// 重置 Select,确保查询 logs 时获取所有字段 (或者指定 default fields)
|
||
// db 对象如果被污染,这里需要显式清除 Select。使用 Session 应该能避免。
|
||
// 安全起见,这里也可以用 db.Session(&gorm.Session{})
|
||
if err := db.Session(&gorm.Session{}).Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error; err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
res := &listLivestreamDrawLogsResponse{
|
||
List: make([]livestreamDrawLogResponse, len(logs)),
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
Stats: stats,
|
||
}
|
||
|
||
for i, log := range logs {
|
||
res.List[i] = livestreamDrawLogResponse{
|
||
ID: log.ID,
|
||
ActivityID: log.ActivityID,
|
||
PrizeID: log.PrizeID,
|
||
PrizeName: log.PrizeName,
|
||
Level: log.Level,
|
||
DouyinOrderID: log.DouyinOrderID,
|
||
ShopOrderID: log.ShopOrderID,
|
||
LocalUserID: log.LocalUserID,
|
||
DouyinUserID: log.DouyinUserID,
|
||
UserNickname: log.UserNickname,
|
||
SeedHash: log.SeedHash,
|
||
CreatedAt: log.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
}
|
||
}
|
||
|
||
ctx.Payload(res)
|
||
}
|
||
}
|
||
|
||
// ========== 直播间承诺管理 ==========
|
||
|
||
type livestreamCommitmentSummaryResponse struct {
|
||
SeedVersion int32 `json:"seed_version"`
|
||
Algo string `json:"algo"`
|
||
HasSeed bool `json:"has_seed"`
|
||
LenSeed int `json:"len_seed_master"`
|
||
LenHash int `json:"len_seed_hash"`
|
||
SeedHashHex string `json:"seed_hash_hex"` // 种子哈希的十六进制表示(可公开复制)
|
||
}
|
||
|
||
// GenerateLivestreamCommitment 生成直播间活动承诺
|
||
// @Summary 生成直播间活动承诺
|
||
// @Description 为直播间活动生成可验证的承诺种子
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path integer true "活动ID"
|
||
// @Success 200 {object} map[string]int32
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{id}/commitment/generate [post]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) GenerateLivestreamCommitment() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
version, err := h.livestream.GenerateCommitment(ctx.RequestContext(), id)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(map[string]int32{"seed_version": version})
|
||
}
|
||
}
|
||
|
||
// GetLivestreamCommitmentSummary 获取直播间活动承诺摘要
|
||
// @Summary 获取直播间活动承诺摘要
|
||
// @Description 获取直播间活动的承诺状态信息
|
||
// @Tags 管理端.直播间
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path integer true "活动ID"
|
||
// @Success 200 {object} livestreamCommitmentSummaryResponse
|
||
// @Failure 400 {object} code.Failure
|
||
// @Router /api/admin/livestream/activities/{id}/commitment/summary [get]
|
||
// @Security LoginVerifyToken
|
||
func (h *handler) GetLivestreamCommitmentSummary() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
|
||
return
|
||
}
|
||
|
||
summary, err := h.livestream.GetCommitmentSummary(ctx.RequestContext(), id)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
ctx.Payload(&livestreamCommitmentSummaryResponse{
|
||
SeedVersion: summary.SeedVersion,
|
||
Algo: summary.Algo,
|
||
HasSeed: summary.HasSeed,
|
||
LenSeed: summary.LenSeed,
|
||
LenHash: summary.LenHash,
|
||
SeedHashHex: summary.SeedHashHex,
|
||
})
|
||
}
|
||
}
|