bindbox-game/internal/api/admin/livestream_admin.go
2026-02-27 00:08:02 +08:00

1086 lines
36 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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