bindbox-game/internal/api/public/livestream_public.go
2026-02-01 00:27:38 +08:00

455 lines
14 KiB
Go
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 public
import (
"fmt"
"net/http"
"time"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
douyinsvc "bindbox-game/internal/service/douyin"
gamesvc "bindbox-game/internal/service/game"
livestreamsvc "bindbox-game/internal/service/livestream"
"go.uber.org/zap"
"gorm.io/gorm"
)
type handler struct {
logger logger.CustomLogger
repo mysql.Repo
livestream livestreamsvc.Service
douyin douyinsvc.Service
}
// New 创建公开接口处理器
func New(l logger.CustomLogger, repo mysql.Repo, douyin douyinsvc.Service) *handler {
ticketSvc := gamesvc.NewTicketService(l, repo)
return &handler{
logger: l,
repo: repo,
livestream: livestreamsvc.New(l, repo, ticketSvc),
douyin: douyin,
}
}
// ========== 直播间公开接口 ==========
type publicActivityResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
StreamerName string `json:"streamer_name"`
Status int32 `json:"status"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Prizes []publicPrizeResponse `json:"prizes"`
}
type publicPrizeResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Level int32 `json:"level"`
Remaining int32 `json:"remaining"`
Probability string `json:"probability"`
Weight int32 `json:"weight"`
}
// GetLivestreamByAccessCode 根据访问码获取直播间活动详情
// @Summary 获取直播间活动详情(公开)
// @Description 根据访问码获取直播间活动和奖品信息,无需登录
// @Tags 公开接口.直播间
// @Accept json
// @Produce json
// @Param access_code path string true "访问码"
// @Success 200 {object} publicActivityResponse
// @Failure 404 {object} code.Failure
// @Router /api/public/livestream/{access_code} [get]
func (h *handler) GetLivestreamByAccessCode() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在或已结束"))
return
}
prizes, _ := h.livestream.ListPrizes(ctx.RequestContext(), activity.ID)
res := &publicActivityResponse{
ID: activity.ID,
Name: activity.Name,
StreamerName: activity.StreamerName,
Status: activity.Status,
Prizes: make([]publicPrizeResponse, len(prizes)),
}
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")
}
// 计算总权重 (仅统计有库存的)
var totalWeight int64
for _, p := range prizes {
if p.Remaining != 0 {
totalWeight += int64(p.Weight)
}
}
for i, p := range prizes {
probStr := "0%"
if p.Remaining != 0 && totalWeight > 0 {
prob := (float64(p.Weight) / float64(totalWeight)) * 100
probStr = fmt.Sprintf("%.2f%%", prob)
}
res.Prizes[i] = publicPrizeResponse{
ID: p.ID,
Name: p.Name,
Image: p.Image,
Level: p.Level,
Remaining: p.Remaining,
Probability: probStr,
Weight: p.Weight,
}
}
ctx.Payload(res)
}
}
type publicDrawLogResponse struct {
PrizeName string `json:"prize_name"`
Level int32 `json:"level"`
DouyinUserID string `json:"douyin_user_id"`
CreatedAt string `json:"created_at"`
}
type listPublicDrawLogsResponse struct {
List []publicDrawLogResponse `json:"list"`
Total int64 `json:"total"`
}
// GetLivestreamWinners 获取中奖记录(公开)
// @Summary 获取直播间中奖记录(公开)
// @Description 根据访问码获取直播间中奖历史,无需登录
// @Tags 公开接口.直播间
// @Accept json
// @Produce json
// @Param access_code path string true "访问码"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} listPublicDrawLogsResponse
// @Failure 404 {object} code.Failure
// @Router /api/public/livestream/{access_code}/winners [get]
func (h *handler) GetLivestreamWinners() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在"))
return
}
page := 1 // Default page 1
pageSize := 20 // Default pageSize 20
var startTime, endTime *time.Time
params := ctx.RequestInputParams()
if stStr := params.Get("start_time"); stStr != "" {
if t, err := time.Parse(time.RFC3339, stStr); err == nil {
startTime = &t
}
}
if etStr := params.Get("end_time"); etStr != "" {
if t, err := time.Parse(time.RFC3339, etStr); err == nil {
endTime = &t
}
}
logs, total, err := h.livestream.ListDrawLogs(ctx.RequestContext(), activity.ID, page, pageSize, startTime, endTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
res := &listPublicDrawLogsResponse{
List: make([]publicDrawLogResponse, len(logs)),
Total: total,
}
for i, log := range logs {
// 隐藏部分抖音ID
maskedID := log.DouyinUserID
if len(maskedID) > 4 {
maskedID = maskedID[:2] + "****" + maskedID[len(maskedID)-2:]
}
res.List[i] = publicDrawLogResponse{
PrizeName: log.PrizeName,
Level: log.Level,
DouyinUserID: maskedID,
CreatedAt: log.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(res)
}
}
// ========== 直播间抽奖接口 ==========
type drawRequest struct {
DouyinOrderID string `json:"shop_order_id" binding:"required"` // 店铺订单号
DouyinUserID string `json:"douyin_user_id"` // 可选,兼容旧逻辑
}
type drawReceipt struct {
SeedVersion int32 `json:"seed_version"`
Timestamp int64 `json:"timestamp"`
Nonce int64 `json:"nonce"`
Signature string `json:"signature"`
Algorithm string `json:"algorithm"`
}
type drawResponse struct {
PrizeID int64 `json:"prize_id"`
PrizeName string `json:"prize_name"`
PrizeImage string `json:"prize_image"`
Level int32 `json:"level"`
SeedHash string `json:"seed_hash"`
UserNickname string `json:"user_nickname"`
Receipt *drawReceipt `json:"receipt,omitempty"`
}
// DrawLivestream 执行直播间抽奖
// @Summary 执行直播间抽奖(公开)
// @Description 根据访问码执行抽奖需提供抖音用户ID
// @Tags 公开接口.直播间
// @Accept json
// @Produce json
// @Param access_code path string true "访问码"
// @Param body body drawRequest true "抽奖参数"
// @Success 200 {object} drawResponse
// @Failure 400 {object} code.Failure
// @Failure 404 {object} code.Failure
// @Router /api/public/livestream/{access_code}/draw [post]
func (h *handler) DrawLivestream() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
var req drawRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10002, "参数格式错误"))
return
}
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10003, "活动不存在或已结束"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10004, "活动未开始"))
return
}
// 1. [核心重构] 根据店铺订单号查出本地记录并核销
var order model.DouyinOrders
db := h.repo.GetDbW().WithContext(ctx.RequestContext())
err = db.Where("shop_order_id = ?", req.DouyinOrderID).First(&order).Error
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10005, "订单不存在"))
return
}
if order.RewardGranted >= int32(order.ProductCount) {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, "该订单已完成抽奖,请勿重复操作"))
return
}
// 执行抽奖
result, err := h.livestream.Draw(ctx.RequestContext(), livestreamsvc.DrawInput{
ActivityID: activity.ID,
DouyinOrderID: order.ID,
ShopOrderID: order.ShopOrderID,
DouyinUserID: order.DouyinUserID,
UserNickname: order.UserNickname,
})
if err != nil {
// 检查是否为黑名单错误
if err.Error() == "该用户已被列入黑名单,无法开奖" {
ctx.AbortWithError(core.Error(http.StatusForbidden, 10008, err.Error()))
return
}
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10007, err.Error()))
return
}
// 标记订单已核销 (增加已发放计数)
// 使用 GORM 表达式更新,确保并发安全
// update douyin_orders set reward_granted = reward_granted + 1, updated_at = now() where id = ?
if err := db.Model(&order).Update("reward_granted", gorm.Expr("reward_granted + 1")).Error; err != nil {
h.logger.Error("[Draw] 更新订单发放状态失败", zap.String("order_id", order.ShopOrderID), zap.Error(err))
// 注意:这里虽然更新失败,但已执行抽奖,可能会导致用户少一次抽奖机会(计数没加),但为了防止超发,宁可少发。
// 理想情况是放在事务中,但 livestream.Draw 内部可能有独立事务。
}
res := &drawResponse{
PrizeID: result.Prize.ID,
PrizeName: result.Prize.Name,
PrizeImage: result.Prize.Image,
Level: result.Prize.Level,
SeedHash: result.SeedHash,
UserNickname: order.UserNickname,
}
// 填充凭证信息
if result.Receipt != nil {
res.Receipt = &drawReceipt{
SeedVersion: result.Receipt.SeedVersion,
Timestamp: result.Receipt.Timestamp,
Nonce: result.Receipt.Nonce,
Signature: result.Receipt.Signature,
Algorithm: result.Receipt.Algorithm,
}
}
ctx.Payload(res)
}
}
// SyncLivestreamOrders 触发全店订单同步并尝试发奖
func (h *handler) SyncLivestreamOrders() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
// 调用服务执行全量扫描 (基于时间更新覆盖最近1小时变化)
result, err := h.douyin.SyncAllOrders(ctx.RequestContext(), 1*time.Hour)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10004, err.Error()))
return
}
ctx.Payload(map[string]any{
"message": "同步完成",
"total_fetched": result.TotalFetched,
"new_orders": result.NewOrders,
"matched_users": result.MatchedUsers,
})
}
}
// GetLivestreamPendingOrders 获取当前活动的待抽奖订单(严格模式:防止窜台)
func (h *handler) GetLivestreamPendingOrders() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
// ✅ 新增获取活动信息获取绑定的产品ID
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在或已结束"))
return
}
// ✅ 严格模式如果活动未绑定产品ID返回空列表防止"窜台"
if activity.DouyinProductID == "" {
h.logger.Warn("[GetPendingOrders] 活动未绑定产品ID返回空列表防止窜台",
zap.String("access_code", accessCode),
zap.Int64("activity_id", activity.ID))
// 返回空列表
type OrderWithBlacklist struct {
model.DouyinOrders
IsBlacklisted bool `json:"is_blacklisted"`
}
ctx.Payload([]OrderWithBlacklist{})
return
}
// [核心优化] 自动同步:每次拉取待抽奖列表前,静默执行一次快速全局扫描 (最近 10 分钟)
_, _ = h.douyin.SyncAllOrders(ctx.RequestContext(), 10*time.Minute)
// ✅ 修改添加产品ID过滤条件核心修复防止不同活动订单窜台
var pendingOrders []model.DouyinOrders
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
err = db.Where("order_status = 2 AND reward_granted < product_count AND douyin_product_id = ?",
activity.DouyinProductID).
Find(&pendingOrders).Error
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
// 查询黑名单用户
blacklistMap := make(map[string]bool)
if len(pendingOrders) > 0 {
var douyinUserIDs []string
for _, order := range pendingOrders {
if order.DouyinUserID != "" {
douyinUserIDs = append(douyinUserIDs, order.DouyinUserID)
}
}
if len(douyinUserIDs) > 0 {
var blacklistUsers []model.DouyinBlacklist
db.Table("douyin_blacklist").
Where("douyin_user_id IN ? AND status = 1", douyinUserIDs).
Find(&blacklistUsers)
for _, bl := range blacklistUsers {
blacklistMap[bl.DouyinUserID] = true
}
}
}
// 构造响应,包含黑名单状态
type OrderWithBlacklist struct {
model.DouyinOrders
IsBlacklisted bool `json:"is_blacklisted"`
}
result := make([]OrderWithBlacklist, len(pendingOrders))
for i, order := range pendingOrders {
result[i] = OrderWithBlacklist{
DouyinOrders: order,
IsBlacklisted: blacklistMap[order.DouyinUserID],
}
}
ctx.Payload(result)
}
}