455 lines
14 KiB
Go
455 lines
14 KiB
Go
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)
|
||
}
|
||
}
|