From e8e3a3a72bddbe72fe39fd6ca4701efa224c216b Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Thu, 16 Jul 2026 23:36:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(#4):=20=E5=90=8E=E5=8F=B0=E6=8A=BD?= =?UTF-8?q?=E5=A5=96=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8=20GET=20/admin/lo?= =?UTF-8?q?ttery/draws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分页列出 lottery_draw,join 奖品快照 + 用户邮箱 + 发放账本(grant_ledger) - 支持 activity/user/win/dispatch_state/prize_type/时间窗过滤 - 展示中奖人/奖品/发放状态/发放结果(如"已新建订阅并加 30 天") Co-Authored-By: Claude Opus 4.8 (1M context) --- apis/admin/lottery.api | 4 + .../admin/lottery/admin_claims_handler.go | 11 + internal/handler/lottery_routes.go | 3 + internal/logic/admin/lottery/admin_draws.go | 204 ++++++++++++++++++ internal/types/lottery.go | 53 +++++ 5 files changed, 275 insertions(+) create mode 100644 internal/logic/admin/lottery/admin_draws.go diff --git a/apis/admin/lottery.api b/apis/admin/lottery.api index b83d400..6d66871 100644 --- a/apis/admin/lottery.api +++ b/apis/admin/lottery.api @@ -83,4 +83,8 @@ service ppanel { @doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)" @handler MarkPaidLotteryClaim post /claims/mark-paid (AdminMarkPaidClaimRequest) + + @doc "List lottery draws (grant records)" + @handler ListLotteryDraws + get /draws (ListAdminLotteryDrawsRequest) returns (ListAdminLotteryDrawsResponse) } diff --git a/internal/handler/admin/lottery/admin_claims_handler.go b/internal/handler/admin/lottery/admin_claims_handler.go index ca562cb..aa6e859 100644 --- a/internal/handler/admin/lottery/admin_claims_handler.go +++ b/internal/handler/admin/lottery/admin_claims_handler.go @@ -72,3 +72,14 @@ func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context result.HttpResult(c, resp, err) } } + +// ListLotteryDrawsHandler GET /v1/admin/lottery/draws +func ListLotteryDrawsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.ListAdminLotteryDrawsRequest + _ = c.ShouldBind(&req) + l := adminlottery.NewListLotteryDrawsLogic(c.Request.Context(), svcCtx) + resp, err := l.ListLotteryDraws(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/lottery_routes.go b/internal/handler/lottery_routes.go index ec38cee..c88d0cd 100644 --- a/internal/handler/lottery_routes.go +++ b/internal/handler/lottery_routes.go @@ -47,5 +47,8 @@ func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) { adminGroup.POST("/claims/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx)) adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx)) adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(serverCtx)) + + // Stage 3: 抽奖记录(发放流水) + adminGroup.GET("/draws", adminLottery.ListLotteryDrawsHandler(serverCtx)) } } diff --git a/internal/logic/admin/lottery/admin_draws.go b/internal/logic/admin/lottery/admin_draws.go new file mode 100644 index 0000000..bb17719 --- /dev/null +++ b/internal/logic/admin/lottery/admin_draws.go @@ -0,0 +1,204 @@ +// admin_draws.go 实现后台抽奖记录(发放流水)接口: +// +// GET /v1/admin/lottery/draws — 分页列表(谁/何时/中了什么/发放状态与结果) +// +// 自动奖(vpn_duration/commission)的实际发放结果取自 lottery_grant_ledger.payload; +// 人工奖(crypto/physical/manual_other)的领奖进展在 /claims 里看。 +package lottery + +import ( + "context" + "encoding/json" + "strings" + "time" + + modelLottery "github.com/perfect-panel/server/internal/model/lottery" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type ListLotteryDrawsLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListLotteryDrawsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryDrawsLogic { + return &ListLotteryDrawsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// ListLotteryDraws 按 activity/user/win/dispatch_state/prize_type/时间窗过滤, +// 附带奖品快照 + 用户邮箱 + 发放账本结果,避免前端 N+1。 +func (l *ListLotteryDrawsLogic) ListLotteryDraws(req *types.ListAdminLotteryDrawsRequest) (*types.ListAdminLotteryDrawsResponse, error) { + if currentAdminId(l.ctx) == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + page, size := req.Page, req.Size + if page <= 0 { + page = 1 + } + if size <= 0 || size > 200 { + size = 20 + } + + db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Draw{}) + if req.ActivityId > 0 { + db = db.Where("activity_id = ?", req.ActivityId) + } + if req.UserId > 0 { + db = db.Where("user_id = ?", req.UserId) + } + switch strings.TrimSpace(req.Win) { + case "1": + db = db.Where("is_win = ?", true) + case "0": + db = db.Where("is_win = ?", false) + } + if s := strings.TrimSpace(req.DispatchState); s != "" { + db = db.Where("dispatch_state = ?", s) + } + // prize_type 挂在快照表上,用 EXISTS 子查询过滤(避免 JOIN 影响分页去重)。 + if pt := strings.TrimSpace(req.PrizeType); pt != "" { + db = db.Where("EXISTS (SELECT 1 FROM lottery_prize_snapshot s WHERE s.draw_id = lottery_draw.id AND s.type = ?)", pt) + } + if req.From > 0 { + db = db.Where("drawn_at >= ?", time.Unix(req.From, 0)) + } + if req.To > 0 { + db = db.Where("drawn_at < ?", time.Unix(req.To, 0)) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + var rows []modelLottery.Draw + if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + + drawIds := make([]int64, 0, len(rows)) + userIds := make([]int64, 0, len(rows)) + for _, d := range rows { + drawIds = append(drawIds, d.Id) + userIds = append(userIds, d.UserId) + } + snaps, err := l.loadSnapshots(drawIds) + if err != nil { + return nil, err + } + ledgers, err := l.loadLedgers(drawIds) + if err != nil { + return nil, err + } + users, err := l.loadUsers(userIds) + if err != nil { + return nil, err + } + + resp := &types.ListAdminLotteryDrawsResponse{Total: total, List: make([]types.AdminLotteryDraw, 0, len(rows))} + for _, d := range rows { + resp.List = append(resp.List, drawToAdminView(d, snaps[d.Id], ledgers[d.Id], users[d.UserId])) + } + return resp, nil +} + +func (l *ListLotteryDrawsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) { + if len(drawIds) == 0 { + return nil, nil + } + var snaps []modelLottery.PrizeSnapshot + if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps)) + for _, s := range snaps { + out[s.DrawId] = s + } + return out, nil +} + +func (l *ListLotteryDrawsLogic) loadLedgers(drawIds []int64) (map[int64]modelLottery.GrantLedger, error) { + if len(drawIds) == 0 { + return nil, nil + } + var rows []modelLottery.GrantLedger + if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&rows).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + out := make(map[int64]modelLottery.GrantLedger, len(rows)) + for _, r := range rows { + out[r.DrawId] = r // draw ↔ ledger 一对一(一次抽奖至多一条发放) + } + return out, nil +} + +func (l *ListLotteryDrawsLogic) loadUsers(ids []int64) (map[int64]string, error) { + if len(ids) == 0 { + return nil, nil + } + type row struct { + UserId int64 + Email string + } + var rows []row + if err := l.svcCtx.DB.WithContext(l.ctx). + Table("user_auth_methods"). + Select("user_id AS user_id, auth_identifier AS email"). + Where("auth_type = ? AND user_id IN ?", "email", ids). + Order("created_at ASC"). + Scan(&rows).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + out := make(map[int64]string, len(rows)) + for _, r := range rows { + if _, ok := out[r.UserId]; ok { + continue + } + out[r.UserId] = r.Email + } + return out, nil +} + +// drawToAdminView 组装单条抽奖记录后台视图。 +func drawToAdminView(d modelLottery.Draw, snap modelLottery.PrizeSnapshot, ledger modelLottery.GrantLedger, email string) types.AdminLotteryDraw { + view := types.AdminLotteryDraw{ + DrawId: d.Id, + ActivityId: d.ActivityId, + IsWin: d.IsWin, + DispatchState: d.DispatchState, + DispatchError: d.DispatchError, + DrawnAt: d.DrawnAt.Unix(), + CreatedAt: d.CreatedAt.Unix(), + User: types.AdminLotteryDrawUser{ + Id: d.UserId, + Email: email, + }, + } + if d.DispatchedAt != nil { + view.DispatchedAt = d.DispatchedAt.Unix() + } + if snap.DrawId != 0 || snap.Type != "" { + view.Prize = &types.AdminLotteryDrawPrize{ + Slot: snap.Slot, + Type: snap.Type, + Name: snap.Name, + Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")), + } + } + if ledger.Id != 0 { + view.GrantAmount = ledger.Amount + if ledger.Payload != "" { + var p struct { + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(ledger.Payload), &p); err == nil { + view.GrantMessage = p.Message + } + } + } + return view +} diff --git a/internal/types/lottery.go b/internal/types/lottery.go index a8b0ebc..bfec706 100644 --- a/internal/types/lottery.go +++ b/internal/types/lottery.go @@ -388,3 +388,56 @@ type AdminLotteryClaimsStatusCount struct { Reviewing int64 `json:"reviewing"` Paying int64 `json:"paying"` } + +// ---- Stage 3 后台抽奖记录(发放流水)-------------------------------------- + +// ListAdminLotteryDrawsRequest 列出抽奖记录,支持活动/用户/中奖/发放状态/奖品类型/时间窗过滤。 +type ListAdminLotteryDrawsRequest struct { + ActivityId int64 `form:"activity_id,omitempty"` + UserId int64 `form:"user_id,omitempty"` + PrizeType string `form:"prize_type,omitempty"` + DispatchState string `form:"dispatch_state,omitempty"` + Win string `form:"win,omitempty"` // "1"=只看中奖, "0"=只看未中奖, ""=全部 + From int64 `form:"from,omitempty"` + To int64 `form:"to,omitempty"` + Page int `form:"page"` + Size int `form:"size"` +} + +// AdminLotteryDrawUser 抽奖记录里附带的用户简况。 +type AdminLotteryDrawUser struct { + Id int64 `json:"id"` + Email string `json:"email,omitempty"` +} + +// AdminLotteryDrawPrize 抽奖记录里的奖品快照(抽奖时刻冻结)。 +type AdminLotteryDrawPrize struct { + Slot int `json:"slot"` + Type string `json:"type"` + Name string `json:"name"` + Config json.RawMessage `json:"config"` +} + +// AdminLotteryDraw 是后台抽奖记录视图:谁、何时、中了什么、发放状态与结果。 +type AdminLotteryDraw struct { + DrawId int64 `json:"draw_id"` + ActivityId int64 `json:"activity_id"` + User AdminLotteryDrawUser `json:"user"` + IsWin bool `json:"is_win"` + Prize *AdminLotteryDrawPrize `json:"prize,omitempty"` + DispatchState string `json:"dispatch_state"` + DispatchError string `json:"dispatch_error,omitempty"` + // GrantAmount / GrantMessage 来自 lottery_grant_ledger(自动奖发放结果): + // vpn_duration=天数,commission=分;message 如"已新建订阅并加 30 天"。 + GrantAmount int64 `json:"grant_amount,omitempty"` + GrantMessage string `json:"grant_message,omitempty"` + DrawnAt int64 `json:"drawn_at"` + DispatchedAt int64 `json:"dispatched_at,omitempty"` + CreatedAt int64 `json:"created_at"` +} + +// ListAdminLotteryDrawsResponse 分页。 +type ListAdminLotteryDrawsResponse struct { + Total int64 `json:"total"` + List []AdminLotteryDraw `json:"list"` +}