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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user