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" 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 { return &handler{ logger: l, repo: repo, livestream: livestreamsvc.New(l, repo), 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 获取当前用户在该活动下的待抽奖订单 (Status 2 且未 Grant) 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 } // [核心优化] 自动同步:每次拉取待抽奖列表前,静默执行一次快速全局扫描 (最近 10 分钟) _, _ = h.douyin.SyncAllOrders(ctx.RequestContext(), 10*time.Minute) // 查询全店范围内所有待抽奖记录 (Status 2 且未核销完: reward_granted < product_count) var pendingOrders []model.DouyinOrders db := h.repo.GetDbR().WithContext(ctx.RequestContext()) err := db.Where("order_status = 2 AND reward_granted < product_count"). 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) } }