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