feat(#4): 后台抽奖记录列表 GET /admin/lottery/draws

- 分页列出 lottery_draw,join 奖品快照 + 用户邮箱 + 发放账本(grant_ledger)
- 支持 activity/user/win/dispatch_state/prize_type/时间窗过滤
- 展示中奖人/奖品/发放状态/发放结果(如"已新建订阅并加 30 天")

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:36:53 -07:00
parent 58c346abec
commit e8e3a3a72b
5 changed files with 275 additions and 0 deletions
+4
View File
@@ -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)
}
@@ -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)
}
}
+3
View File
@@ -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))
}
}
+204
View File
@@ -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
}
+53
View File
@@ -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"`
}