feat(activity): 重构福利活动并支持统一奖池

对齐福利活动新库表结构,支持商品、道具卡和优惠券统一建奖、开奖与中奖记录。
同时新增福利活动测试命令行工具,便于模拟消费、参与活动并验证完整开奖链路。
This commit is contained in:
Zuncle 2026-04-29 17:21:11 +08:00
parent 45ea70760b
commit 3db52af4b6
15 changed files with 2033 additions and 0 deletions

View File

@ -0,0 +1,284 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"strconv"
"strings"
"bindbox-game/configs"
"bindbox-game/internal/pkg/env"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity"
"github.com/eiannone/keyboard"
)
func main() {
operator := flag.String("operator", "cli", "操作人标识")
flag.Parse()
env.Active()
configs.Init()
logg, err := logger.NewCustomLogger(logger.WithOutputInConsole(), logger.WithDebugLevel())
if err != nil {
fmt.Printf("[ERR] 初始化日志失败: %v\n", err)
os.Exit(1)
}
repo, err := mysql.New()
if err != nil {
fmt.Printf("[ERR] 初始化 MySQL 失败: %v\n", err)
os.Exit(1)
}
defer repo.DbRClose()
defer repo.DbWClose()
userSvc := usersvc.New(logg, repo)
welfareSvc := welfaresvc.New(logg, repo)
ctx := context.Background()
reader := bufio.NewReader(os.Stdin)
fmt.Printf("[WARN] 当前环境: %s\n", env.Active().Value())
fmt.Println("[WARN] 该工具会写入真实已支付测试订单,仅用于 dev/fat/uat")
userID, err := promptInt64(reader, "请输入用户ID")
if err != nil {
fmt.Printf("[ERR] 读取用户ID失败: %v\n", err)
os.Exit(1)
}
user, err := userSvc.GetProfile(ctx, userID)
if err != nil || user == nil {
fmt.Printf("[ERR] 用户不存在: %v\n", err)
os.Exit(1)
}
fmt.Println("[INFO] 用户信息")
fmt.Printf("- 用户名: %s\n", fallback(user.Nickname, "-"))
fmt.Printf("- 用户ID: %d\n", user.ID)
fmt.Printf("- 手机号: %s\n", fallback(user.Mobile, "-"))
packages, err := loadActivePackages(ctx, repo)
if err != nil {
fmt.Printf("[ERR] 加载活动次卡套餐失败: %v\n", err)
os.Exit(1)
}
if len(packages) == 0 {
fmt.Println("[WARN] 当前没有可用的活动次卡套餐")
return
}
selectedPackage, err := selectPackage(packages)
if err != nil {
fmt.Printf("[ERR] 选择套餐失败: %v\n", err)
os.Exit(1)
}
count, err := promptInt32(reader, "请输入购买次数")
if err != nil {
fmt.Printf("[ERR] 读取购买次数失败: %v\n", err)
os.Exit(1)
}
purchase, err := userSvc.CreatePaidGamePassOrderForTest(ctx, usersvc.CreatePaidGamePassOrderForTestInput{
UserID: user.ID,
PackageID: selectedPackage.ID,
Count: count,
Operator: *operator,
})
if err != nil {
fmt.Printf("[ERR] 模拟消费失败: %v\n", err)
os.Exit(1)
}
fmt.Println("[OK] 模拟消费成功")
fmt.Printf("- 订单号: %s\n", purchase.OrderNo)
fmt.Printf("- 套餐: %s\n", purchase.PackageName)
fmt.Printf("- 次数: %d\n", purchase.Count)
fmt.Printf("- 支付金额: %s\n", formatAmount(purchase.TotalAmount))
fmt.Printf("- 支付时间: %s\n", purchase.PaidAt.Format("2006-01-02 15:04:05"))
activities, err := welfareSvc.ListJoinableActivitiesForUser(ctx, user.ID)
if err != nil {
fmt.Printf("[ERR] 加载福利活动失败: %v\n", err)
os.Exit(1)
}
if len(activities) == 0 {
fmt.Println("[WARN] 当前没有可选的进行中福利活<E588A9><E6B4BB>")
return
}
selected, err := selectActivity(activities)
if err != nil {
fmt.Printf("[ERR] 选择活动失败: %v\n", err)
os.Exit(1)
}
fmt.Println("[INFO] 已选择福利活动")
fmt.Printf("- 活动ID: %d\n", selected.ActivityID)
fmt.Printf("- 活动名称: %s\n", selected.Title)
fmt.Printf("- 当前消费/门槛: %s/%s\n", formatAmount(selected.CurrentPaid), formatAmount(selected.ThresholdAmount))
if err := welfareSvc.Join(ctx, selected.ActivityID, user.ID); err != nil {
fmt.Printf("[ERR] 参与失败: %v\n", err)
os.Exit(1)
}
fmt.Println("[OK] 参与成功")
fmt.Printf("- 用户ID: %d\n", user.ID)
fmt.Printf("- 活动ID: %d\n", selected.ActivityID)
fmt.Printf("- 开奖时间: %s\n", selected.DrawTime.Format("2006-01-02 15:04:05"))
fmt.Println("[INFO] 后续请等待系统自动开奖,再登录验证中奖、订单、背包和中奖名单")
}
func promptInt64(reader *bufio.Reader, label string) (int64, error) {
fmt.Printf("%s: ", label)
text, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(text), 10, 64)
}
func promptInt32(reader *bufio.Reader, label string) (int32, error) {
value, err := promptInt64(reader, label)
if err != nil {
return 0, err
}
return int32(value), nil
}
func loadActivePackages(ctx context.Context, repo mysql.Repo) ([]*model.GamePassPackages, error) {
var packages []*model.GamePassPackages
err := repo.GetDbR().WithContext(ctx).
Where("status = 1 AND deleted_at IS NULL").
Order("sort_order DESC, id ASC").
Find(&packages).Error
return packages, err
}
func selectPackage(items []*model.GamePassPackages) (*model.GamePassPackages, error) {
if err := keyboard.Open(); err != nil {
return nil, err
}
defer keyboard.Close()
index := 0
for {
fmt.Println()
fmt.Println("请选择活动次卡套餐(↑↓ 选择Enter 确认Esc 退出)")
for i, item := range items {
prefix := " "
if i == index {
prefix = "> "
}
activityScope := "全局"
if item.ActivityID > 0 {
activityScope = fmt.Sprintf("活动ID:%d", item.ActivityID)
}
fmt.Printf("%s%s | 套餐ID:%d | %s | %d次 | %s\n", prefix, item.Name, item.ID, activityScope, item.PassCount, formatAmount(item.Price))
}
char, key, err := keyboard.GetKey()
if err != nil {
return nil, err
}
switch key {
case keyboard.KeyArrowUp:
if index > 0 {
index--
}
case keyboard.KeyArrowDown:
if index < len(items)-1 {
index++
}
case keyboard.KeyEnter:
return items[index], nil
case keyboard.KeyEsc, keyboard.KeyCtrlC:
return nil, fmt.Errorf("已取消选择")
default:
if char == 0 {
continue
}
}
}
}
func selectActivity(items []welfaresvc.JoinableActivityItem) (*welfaresvc.JoinableActivityItem, error) {
if err := keyboard.Open(); err != nil {
return nil, err
}
defer keyboard.Close()
index := 0
for {
fmt.Println()
fmt.Println("请选择福利活动(↑↓ 选择Enter 确认Esc 退出)")
for i, item := range items {
prefix := " "
if i == index {
prefix = "> "
}
status := "未达标"
if item.Joined {
status = "已参与"
} else if item.CanJoin {
status = "可参与"
}
fmt.Printf("%s%s %s %s/%s %s\n", prefix, item.Title, typeLabel(item.Type), formatAmount(item.CurrentPaid), formatAmount(item.ThresholdAmount), status)
}
char, key, err := keyboard.GetKey()
if err != nil {
return nil, err
}
switch key {
case keyboard.KeyArrowUp:
if index > 0 {
index--
}
case keyboard.KeyArrowDown:
if index < len(items)-1 {
index++
}
case keyboard.KeyEnter:
return &items[index], nil
case keyboard.KeyEsc, keyboard.KeyCtrlC:
return nil, fmt.Errorf("已取消选择")
default:
if char == 0 {
continue
}
}
}
}
func formatAmount(cents int64) string {
return fmt.Sprintf("%.2f", float64(cents)/100)
}
func typeLabel(t string) string {
switch t {
case welfaresvc.TypeWeekly:
return "每周福利"
case welfaresvc.TypeMonthly:
return "每月福利"
default:
return "每日福利"
}
}
func fallback(v, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return v
}

1
go.mod
View File

@ -7,6 +7,7 @@ toolchain go1.24.2
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/DanPlayer/randomname v1.0.1
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3
github.com/alibabacloud-go/tea v1.3.14

2
go.sum
View File

@ -145,6 +145,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=

View File

@ -0,0 +1,119 @@
package app
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
welfaresvc "bindbox-game/internal/service/welfare_activity"
)
type listWelfareActivitiesRequest struct {
Type string `form:"type"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listWelfareParticipantsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listWelfareWinnersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
func (h *handler) ListWelfareActivities() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listWelfareActivitiesRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
status := req.Status
if status == "" {
status = welfaresvc.StatusActive
}
res, err := h.welfare.ListActivities(ctx.RequestContext(), welfaresvc.ListActivitiesRequest{Type: req.Type, Status: status, Page: req.Page, PageSize: req.PageSize})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
userID := int64(ctx.SessionUserInfo().Id)
res, err := h.welfare.GetActivity(ctx.RequestContext(), id, userID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) JoinWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
info := ctx.SessionUserInfo()
userID := int64(info.Id)
if userID <= 0 {
ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AuthorizationError, "请先登录"))
return
}
if err := h.welfare.Join(ctx.RequestContext(), id, userID); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(map[string]string{"message": "参与成功"})
}
}
func (h *handler) ListWelfareParticipants() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
req := new(listWelfareParticipantsRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListParticipants(ctx.RequestContext(), id, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) ListWelfareWinners() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
req := new(listWelfareWinnersRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListWinners(ctx.RequestContext(), id, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}

View File

@ -0,0 +1,311 @@
package admin
import (
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
welfaresvc "bindbox-game/internal/service/welfare_activity"
)
type saveWelfareActivityRequest struct {
Title string `json:"title" binding:"required"`
Type string `json:"type" binding:"required"`
ThresholdAmount int64 `json:"threshold_amount"`
StartTime string `json:"start_time" binding:"required"`
EndTime string `json:"end_time" binding:"required"`
DrawTime string `json:"draw_time" binding:"required"`
Status string `json:"status"`
Description string `json:"description"`
CoverImage string `json:"cover_image"`
Prizes []welfaresvc.PrizeInput `json:"prizes"`
}
func (h *handler) CreateWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
req := new(saveWelfareActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
input, err := req.toInput()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
item, err := h.welfare.CreateActivity(ctx.RequestContext(), input)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateActivityError, err.Error()))
return
}
ctx.Payload(map[string]interface{}{"id": item.ID, "message": "操作成功"})
}
}
func (h *handler) UpdateWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(saveWelfareActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
input, err := req.toInput()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
if err := h.welfare.UpdateActivity(ctx.RequestContext(), id, input); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type listAdminWelfareActivitiesRequest struct {
Title string `form:"title"`
Type string `form:"type"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listAdminWelfareWinnersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type welfareCostSummaryRequest struct {
StartTime string `form:"start_time"`
EndTime string `form:"end_time"`
}
func (h *handler) ListWelfareActivities() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listAdminWelfareActivitiesRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListActivities(ctx.RequestContext(), welfaresvc.ListActivitiesRequest{Type: req.Type, Status: req.Status, Title: req.Title, Page: req.Page, PageSize: req.PageSize})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
res, err := h.welfare.GetActivity(ctx.RequestContext(), id, 0)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) DeleteWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
if err := h.welfare.DeleteActivity(ctx.RequestContext(), id); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type copyWelfareActivityRequest struct {
Title string `json:"title"`
Type string `json:"type" binding:"required"`
ThresholdAmount int64 `json:"threshold_amount"`
StartTime string `json:"start_time" binding:"required"`
EndTime string `json:"end_time" binding:"required"`
DrawTime string `json:"draw_time" binding:"required"`
Status string `json:"status"`
}
func (h *handler) CopyWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(copyWelfareActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
start, err := parseRequiredTime(req.StartTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
end, err := parseRequiredTime(req.EndTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
draw, err := parseRequiredTime(req.DrawTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
newID, err := h.welfare.CopyActivity(ctx.RequestContext(), id, welfaresvc.SaveActivityRequest{
Title: req.Title,
Type: req.Type,
ThresholdAmount: req.ThresholdAmount,
StartTime: start,
EndTime: end,
DrawTime: draw,
Status: req.Status,
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateActivityError, err.Error()))
return
}
ctx.Payload(map[string]interface{}{"new_activity_id": newID, "status": "success"})
}
}
func (h *handler) ListWelfareParticipants() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
res, err := h.welfare.ListParticipants(ctx.RequestContext(), id, 1, 100)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) ListWelfareWinners() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
req := new(listAdminWelfareWinnersRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListWinners(ctx.RequestContext(), id, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) DrawWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err := h.welfare.Draw(ctx.RequestContext(), id); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
func (h *handler) GetWelfareCost() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
res, err := h.welfare.GetCost(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetWelfareCostSummary() core.HandlerFunc {
return func(ctx core.Context) {
req := new(welfareCostSummaryRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
start, _ := parseOptionalTime(req.StartTime)
end, _ := parseOptionalTime(req.EndTime)
res, err := h.welfare.GetCostSummary(ctx.RequestContext(), start, end)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (r *saveWelfareActivityRequest) toInput() (welfaresvc.SaveActivityRequest, error) {
start, err := parseRequiredTime(r.StartTime)
if err != nil {
return welfaresvc.SaveActivityRequest{}, err
}
end, err := parseRequiredTime(r.EndTime)
if err != nil {
return welfaresvc.SaveActivityRequest{}, err
}
if sameDay(start, end) && end.Hour() == 0 && end.Minute() == 0 && end.Second() == 0 {
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, end.Location())
}
draw, err := parseRequiredTime(r.DrawTime)
if err != nil {
return welfaresvc.SaveActivityRequest{}, err
}
prizes := make([]welfaresvc.PrizeInput, 0, len(r.Prizes))
for _, prize := range r.Prizes {
if prize.RewardType == "" && prize.ProductID > 0 {
prize.RewardType = welfaresvc.RewardTypeProduct
prize.RewardRefID = prize.ProductID
}
prizes = append(prizes, prize)
}
return welfaresvc.SaveActivityRequest{Title: r.Title, Type: r.Type, ThresholdAmount: r.ThresholdAmount, StartTime: start, EndTime: end, DrawTime: draw, Status: r.Status, Description: r.Description, CoverImage: r.CoverImage, Prizes: prizes}, nil
}
func parseRequiredTime(v string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
return time.ParseInLocation("2006-01-02 15:04:05", v, time.Local)
}
func parseOptionalTime(v string) (*time.Time, error) {
if v == "" {
return nil, nil
}
t, err := parseRequiredTime(v)
if err != nil {
return nil, err
}
return &t, nil
}
func sameDay(a, b time.Time) bool {
y1, m1, d1 := a.Date()
y2, m2, d2 := b.Date()
return y1 == y2 && m1 == m2 && d1 == d2
}

View File

@ -0,0 +1,108 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"bindbox-game/internal/pkg/env"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
type CreatePaidGamePassOrderForTestInput struct {
UserID int64
PackageID int64
Count int32
Operator string
}
type CreatePaidGamePassOrderForTestOutput struct {
OrderNo string
PackageName string
PackageID int64
Count int32
TotalAmount int64
PaidAt time.Time
UserID int64
ActivityID int64
PassesGranted int32
}
func (s *service) CreatePaidGamePassOrderForTest(ctx context.Context, input CreatePaidGamePassOrderForTestInput) (*CreatePaidGamePassOrderForTestOutput, error) {
if env.Active().IsPro() {
return nil, errors.New("正式环境禁止执行福利测试购买工具")
}
if input.UserID <= 0 {
return nil, errors.New("用户ID无效")
}
if input.PackageID <= 0 {
return nil, errors.New("套餐ID无效")
}
if input.Count <= 0 {
return nil, errors.New("购买次数必须大于0")
}
if input.Count > 999 {
return nil, errors.New("购买次数过大")
}
pkg, err := s.readDB.GamePassPackages.WithContext(ctx).
Where(s.readDB.GamePassPackages.ID.Eq(input.PackageID)).
Where(s.readDB.GamePassPackages.Status.Eq(1)).
First()
if err != nil || pkg == nil {
return nil, errors.New("套餐不存在或已下架")
}
totalPrice := pkg.Price * int64(input.Count)
now := time.Now()
orderNo := fmt.Sprintf("GPTEST%s%04d", now.Format("20060102150405"), now.UnixNano()%10000)
operator := strings.TrimSpace(input.Operator)
if operator == "" {
operator = "cli"
}
remark := fmt.Sprintf("game_pass_package:%s|pkg_id:%d|count:%d|test:welfare_cmd|operator:%s", pkg.Name, pkg.ID, input.Count, operator)
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
err = s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order := &model.Orders{
UserID: input.UserID,
OrderNo: orderNo,
SourceType: 4,
TotalAmount: totalPrice,
DiscountAmount: 0,
PointsAmount: 0,
ActualAmount: totalPrice,
Status: 2,
PaidAt: now,
CancelledAt: minValidTime,
IsConsumed: 0,
Remark: remark,
ExtOrderID: "",
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return err
}
return s.GrantGamePass(ctx, input.UserID, pkg.ID, input.Count, orderNo)
})
if err != nil {
return nil, err
}
return &CreatePaidGamePassOrderForTestOutput{
OrderNo: orderNo,
PackageName: pkg.Name,
PackageID: pkg.ID,
Count: input.Count,
TotalAmount: totalPrice,
PaidAt: now,
UserID: input.UserID,
ActivityID: pkg.ActivityID,
PassesGranted: pkg.PassCount * input.Count,
}, nil
}

View File

@ -81,6 +81,7 @@ type Service interface {
SendSmsCode(ctx context.Context, mobile string) error
LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginOutput, error)
GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error
CreatePaidGamePassOrderForTest(ctx context.Context, input CreatePaidGamePassOrderForTestInput) (*CreatePaidGamePassOrderForTestOutput, error)
// 邀请人绑定
BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error)
// 管理端强制绑定/修改/解绑邀请人

View File

@ -0,0 +1,344 @@
package welfare_activity
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
func (s *service) CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error) {
if err := validateActivity(req); err != nil {
return nil, err
}
item := &Activity{
Title: normalizeActivityTitle(req.Title),
Type: req.Type,
ThresholdAmount: req.ThresholdAmount,
StartTime: req.StartTime,
EndTime: req.EndTime,
DrawTime: req.DrawTime,
Status: normalizeStatus(req.Status),
Description: req.Description,
CoverImage: req.CoverImage,
}
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(item).Error; err != nil {
return err
}
return s.replacePrizes(ctx, tx, item.ID, req.Prizes)
})
return item, err
}
func (s *service) UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error {
if id <= 0 {
return errors.New("活动ID无效")
}
if err := validateActivity(req); err != nil {
return err
}
var existing Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil {
return err
}
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
status := req.Status
if strings.TrimSpace(status) == "" {
status = existing.Status
}
updates := map[string]interface{}{
"title": normalizeActivityTitle(req.Title),
"type": req.Type,
"threshold_amount": req.ThresholdAmount,
"start_time": req.StartTime,
"end_time": req.EndTime,
"draw_time": req.DrawTime,
"status": normalizeStatus(status),
"description": req.Description,
"cover_image": req.CoverImage,
}
var current Activity
if err := tx.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&current).Error; err != nil {
return err
}
if current.Status == StatusFinished {
return errors.New("已结束活动不可编辑")
}
if err := tx.Model(&Activity{}).Where("id = ? AND deleted_at IS NULL", id).Updates(updates).Error; err != nil {
return err
}
return s.replacePrizes(ctx, tx, id, req.Prizes)
})
}
func (s *service) CopyActivity(ctx context.Context, id int64, req SaveActivityRequest) (int64, error) {
var src Activity
db := s.repo.GetDbR().WithContext(ctx)
if err := db.Where("id = ? AND deleted_at IS NULL", id).First(&src).Error; err != nil {
return 0, err
}
var prizes []Prize
if err := db.Where("activity_id = ?", id).Order("sort ASC, id ASC").Find(&prizes).Error; err != nil {
return 0, err
}
copyReq := SaveActivityRequest{
Title: req.Title,
Type: req.Type,
ThresholdAmount: req.ThresholdAmount,
StartTime: req.StartTime,
EndTime: req.EndTime,
DrawTime: req.DrawTime,
Status: normalizeStatus(req.Status),
CoverImage: src.CoverImage,
}
for _, p := range prizes {
copyReq.Prizes = append(copyReq.Prizes, PrizeInput{
RewardType: p.RewardType,
RewardRefID: p.RewardRefID,
Quantity: p.Quantity,
Sort: p.Sort,
})
}
item, err := s.CreateActivity(ctx, copyReq)
if err != nil {
return 0, err
}
return item.ID, nil
}
func (s *service) ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
db := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}).Where("deleted_at IS NULL")
if req.Type != "" {
db = db.Where("type = ?", req.Type)
}
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if strings.TrimSpace(req.Title) != "" {
db = db.Where("title LIKE ?", "%"+strings.TrimSpace(req.Title)+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []Activity
if err := db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&rows).Error; err != nil {
return nil, err
}
list := make([]ActivityListItem, 0, len(rows))
for _, row := range rows {
item := ActivityListItem{Activity: row}
s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_participants").Where("activity_id = ?", row.ID).Count(&item.ParticipantCount)
s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").Where("activity_id = ?", row.ID).Count(&item.WinnerCount)
s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").Select("COALESCE(SUM(cost_cents),0)").Where("activity_id = ?", row.ID).Scan(&item.CostCents)
list = append(list, item)
}
return &ListActivitiesResponse{Page: req.Page, PageSize: req.PageSize, Total: total, List: list}, nil
}
func (s *service) GetActivity(ctx context.Context, id int64, userID int64) (*ActivityDetail, error) {
var item Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&item).Error; err != nil {
return nil, err
}
detail := &ActivityDetail{Activity: item}
s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", id).Order("sort ASC, id ASC").Find(&detail.Prizes)
s.fillPrizeMeta(ctx, detail.Prizes)
participants, _ := s.ListParticipants(ctx, id, 1, 20)
if participants != nil {
detail.ParticipantCount = participants.Total
detail.Participants = participants.List
}
winners, _ := s.ListWinners(ctx, id, 1, 20)
if winners != nil {
detail.Winners = winners.List
}
if userID > 0 {
start, end, period := periodRange(item.Type, time.Now())
detail.CurrentPaid, _ = s.sumPaidAmount(ctx, userID, start, end)
detail.CanJoin = detail.CurrentPaid >= item.ThresholdAmount && item.Status == StatusActive && time.Now().Before(item.DrawTime) && time.Now().Before(item.EndTime)
var count int64
s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", id, userID, period).Count(&count)
detail.Joined = count > 0
}
return detail, nil
}
func (s *service) DeleteActivity(ctx context.Context, id int64) error {
if id <= 0 {
return errors.New("活动ID无效")
}
now := time.Now()
return s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error
}
func (s *service) SavePrizes(ctx context.Context, activityID int64, prizes []PrizeInput) error {
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.replacePrizes(ctx, tx, activityID, prizes)
})
}
func (s *service) replacePrizes(ctx context.Context, tx *gorm.DB, activityID int64, inputs []PrizeInput) error {
if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Delete(&Prize{}).Error; err != nil {
return err
}
for _, input := range inputs {
prizeInput, err := normalizePrizeInput(input)
if err != nil {
return err
}
if prizeInput.Quantity <= 0 {
return errors.New("奖品数量不能为空")
}
prize, err := s.buildPrizeSnapshot(ctx, tx, activityID, prizeInput)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(prize).Error; err != nil {
return err
}
}
return nil
}
func validateActivity(req SaveActivityRequest) error {
if strings.TrimSpace(req.Title) == "" {
return errors.New("活动标题不能为空")
}
if req.Type != TypeDaily && req.Type != TypeWeekly && req.Type != TypeMonthly {
return errors.New("活动类型无效")
}
if req.ThresholdAmount < 0 {
return errors.New("参与门槛不能小于0")
}
if req.StartTime.IsZero() || req.EndTime.IsZero() || req.DrawTime.IsZero() {
return errors.New("活动时间和开奖时间不能为空")
}
if !req.EndTime.After(req.StartTime) {
return errors.New("结束时间必须晚于开始时间")
}
if req.DrawTime.Before(req.StartTime) {
return errors.New("开奖时间不能早于开始时间")
}
return nil
}
func normalizeStatus(status string) string {
if status == "" {
return StatusActive
}
if status == StatusFinished {
return StatusFinished
}
return StatusActive
}
func firstProductImage(imagesJSON string) string {
imagesJSON = strings.TrimSpace(imagesJSON)
if imagesJSON == "" {
return ""
}
var images []string
if err := json.Unmarshal([]byte(imagesJSON), &images); err == nil && len(images) > 0 {
return strings.TrimSpace(images[0])
}
return ""
}
func normalizeActivityTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return time.Now().Format("2006-01-02")
}
return title
}
func normalizePrizeInput(input PrizeInput) (PrizeInput, error) {
if input.RewardType == "" && input.ProductID > 0 {
input.RewardType = RewardTypeProduct
input.RewardRefID = input.ProductID
}
if input.RewardType == "" || input.RewardRefID <= 0 {
return PrizeInput{}, errors.New("奖品类型和资源不能为空")
}
switch input.RewardType {
case RewardTypeProduct, RewardTypeItemCard, RewardTypeCoupon:
return input, nil
default:
return PrizeInput{}, errors.New("奖品类型无效")
}
}
func (s *service) buildPrizeSnapshot(ctx context.Context, tx *gorm.DB, activityID int64, input PrizeInput) (*Prize, error) {
prize := &Prize{
ActivityID: activityID,
RewardType: input.RewardType,
RewardRefID: input.RewardRefID,
Quantity: input.Quantity,
RemainingQuantity: input.Quantity,
Sort: input.Sort,
}
switch input.RewardType {
case RewardTypeProduct:
var product model.Products
if err := tx.WithContext(ctx).Where("id = ?", input.RewardRefID).First(&product).Error; err != nil {
return nil, fmt.Errorf("商品不存在: %d", input.RewardRefID)
}
prize.RewardNameSnapshot = product.Name
prize.RewardImageSnapshot = firstProductImage(product.ImagesJSON)
prize.RewardValueSnapshotCents = product.Price
prize.CostSnapshotCents = product.CostPrice
if prize.CostSnapshotCents <= 0 {
prize.CostSnapshotCents = product.Price
}
case RewardTypeItemCard:
var card model.SystemItemCards
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", input.RewardRefID).First(&card).Error; err != nil {
return nil, fmt.Errorf("道具卡不存在或未启用: %d", input.RewardRefID)
}
prize.RewardNameSnapshot = card.Name
prize.RewardImageSnapshot = ""
prize.RewardValueSnapshotCents = card.Price
prize.CostSnapshotCents = card.Price
case RewardTypeCoupon:
var coupon model.SystemCoupons
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", input.RewardRefID).First(&coupon).Error; err != nil {
return nil, fmt.Errorf("优惠券不存在或未启用: %d", input.RewardRefID)
}
prize.RewardNameSnapshot = coupon.Name
prize.RewardImageSnapshot = ""
prize.RewardValueSnapshotCents = coupon.DiscountValue
prize.CostSnapshotCents = coupon.DiscountValue
}
return prize, nil
}
func (s *service) fillPrizeMeta(ctx context.Context, prizes []Prize) {
for i := range prizes {
if prizes[i].RewardNameSnapshot != "" {
prizes[i].Name = prizes[i].RewardNameSnapshot
}
if prizes[i].RewardImageSnapshot != "" {
prizes[i].Image = prizes[i].RewardImageSnapshot
}
if prizes[i].RewardValueSnapshotCents > 0 {
prizes[i].PriceCents = prizes[i].RewardValueSnapshotCents
}
}
}

View File

@ -0,0 +1,329 @@
package welfare_activity
import (
"context"
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"math/rand"
"time"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
type prizeGrantResult struct {
RewardType string
RewardRefID int64
PrizeNameSnapshot string
PrizeImageSnapshot string
PrizeValueSnapshotCents int64
GrantRecordType string
GrantRecordID int64
CostCents int64
}
func (s *service) DrawDueActivities(ctx context.Context) error {
var ids []int64
if err := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}).
Where("deleted_at IS NULL AND status = ? AND draw_time <= ?", StatusActive, time.Now()).
Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := s.Draw(ctx, id); err != nil && s.logger != nil {
s.logger.Warn("welfare activity draw failed", zap.Int64("activity_id", id), zap.Error(err))
}
}
return nil
}
func (s *service) Draw(ctx context.Context, activityID int64) error {
if activityID <= 0 {
return errors.New("活动ID无效")
}
batch := fmt.Sprintf("WA%d-%d", activityID, time.Now().UnixNano())
updated := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).
Where("id = ? AND deleted_at IS NULL AND status = ?", activityID, StatusActive).
Update("draw_batch", batch).RowsAffected
if updated == 0 {
return errors.New("活动不允许开奖或已开奖")
}
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var participants []Participant
if err := tx.Where("activity_id = ?", activityID).Find(&participants).Error; err != nil {
return err
}
if len(participants) == 0 {
return nil
}
var prizes []Prize
if err := tx.Where("activity_id = ? AND remaining_quantity > 0", activityID).Order("sort ASC, id ASC").Find(&prizes).Error; err != nil {
return err
}
if len(prizes) == 0 {
return errors.New("未配置可发放奖品")
}
shuffleParticipants(participants)
prizePool := buildPrizePool(prizes)
shufflePrizes(prizePool)
used := map[int64]bool{}
idx := 0
for _, prize := range prizePool {
for idx < len(participants) && used[participants[idx].UserID] {
idx++
}
if idx >= len(participants) {
break
}
userID := participants[idx].UserID
idx++
used[userID] = true
grantResult, err := s.grantPrizeInTx(ctx, tx, activityID, userID, prize)
if err != nil {
return err
}
winner := &Winner{
ActivityID: activityID,
PrizeID: prize.ID,
RewardType: grantResult.RewardType,
RewardRefID: grantResult.RewardRefID,
PrizeNameSnapshot: grantResult.PrizeNameSnapshot,
PrizeImageSnapshot: grantResult.PrizeImageSnapshot,
PrizeValueSnapshotCents: grantResult.PrizeValueSnapshotCents,
UserID: userID,
GrantRecordType: grantResult.GrantRecordType,
GrantRecordID: grantResult.GrantRecordID,
CostCents: grantResult.CostCents,
DrawBatch: batch,
}
if err := tx.Create(winner).Error; err != nil {
return err
}
if err := tx.Model(&Prize{}).Where("id = ? AND remaining_quantity > 0", prize.ID).Update("remaining_quantity", gorm.Expr("remaining_quantity - 1")).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
if err := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ?", activityID).Updates(map[string]interface{}{"status": StatusFinished, "draw_batch": batch}).Error; err != nil {
return err
}
return nil
}
func (s *service) grantPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
switch prize.RewardType {
case RewardTypeItemCard:
return s.grantItemCardPrizeInTx(ctx, tx, activityID, userID, prize)
case RewardTypeCoupon:
return s.grantCouponPrizeInTx(ctx, tx, activityID, userID, prize)
default:
return s.grantProductPrizeInTx(ctx, tx, activityID, userID, prize)
}
}
func (s *service) grantProductPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
var product model.Products
if err := tx.WithContext(ctx).Where("id = ?", prize.RewardRefID).First(&product).Error; err != nil {
return nil, err
}
if product.Stock <= 0 {
return nil, fmt.Errorf("商品库存不足: %s", product.Name)
}
result := tx.WithContext(ctx).Model(&model.Products{}).Where("id = ? AND stock > 0", product.ID).Update("stock", gorm.Expr("stock - 1"))
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, fmt.Errorf("商品库存不足: %s", product.Name)
}
now := time.Now()
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
order := &model.Orders{OrderNo: fmt.Sprintf("WA%d%d", activityID, now.UnixNano()), UserID: userID, SourceType: 6, Status: 2, PaidAt: now, CancelledAt: minValidTime, Remark: "福利活动中奖发放", CreatedAt: now, UpdatedAt: now}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return nil, err
}
orderItem := &model.OrderItems{OrderID: order.ID, ProductID: product.ID, Title: product.Name, Quantity: 1, ProductImages: product.ImagesJSON, Status: 1}
if err := tx.WithContext(ctx).Create(orderItem).Error; err != nil {
return nil, err
}
value := prize.CostSnapshotCents
if value <= 0 {
value = product.CostPrice
}
if value <= 0 {
value = product.Price
}
inventory := &model.UserInventory{UserID: userID, ProductID: product.ID, ValueCents: value, ValueSource: 1, ValueSnapshotAt: now, OrderID: order.ID, ActivityID: activityID, RewardID: prize.ID, Status: 1, Remark: "福利活动中奖发放"}
if err := tx.WithContext(ctx).Create(inventory).Error; err != nil {
return nil, err
}
return &prizeGrantResult{
RewardType: RewardTypeProduct,
RewardRefID: prize.RewardRefID,
PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, product.Name),
PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, firstProductImage(product.ImagesJSON)),
PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, product.Price),
GrantRecordType: GrantRecordTypeInventory,
GrantRecordID: inventory.ID,
CostCents: prize.CostSnapshotCents,
}, nil
}
func (s *service) grantItemCardPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
var card model.SystemItemCards
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", prize.RewardRefID).First(&card).Error; err != nil {
return nil, err
}
now := time.Now()
item := &model.UserItemCards{UserID: userID, CardID: card.ID, Status: 1, Remark: "福利活动中奖发放"}
if !card.ValidStart.IsZero() {
item.ValidStart = card.ValidStart
} else {
item.ValidStart = now
}
if !card.ValidEnd.IsZero() {
item.ValidEnd = card.ValidEnd
}
do := tx.WithContext(ctx).Omit("used_at", "used_draw_log_id", "used_activity_id", "used_issue_id")
if card.ValidEnd.IsZero() {
do = do.Omit("valid_end")
}
if err := do.Create(item).Error; err != nil {
return nil, err
}
return &prizeGrantResult{
RewardType: RewardTypeItemCard,
RewardRefID: prize.RewardRefID,
PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, card.Name),
PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, ""),
PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, card.Price),
GrantRecordType: GrantRecordTypeItemCard,
GrantRecordID: item.ID,
CostCents: prize.CostSnapshotCents,
}, nil
}
func (s *service) grantCouponPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
var tpl model.SystemCoupons
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", prize.RewardRefID).First(&tpl).Error; err != nil {
return nil, err
}
if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) {
return nil, errors.New("coupon template expired")
}
if tpl.TotalQuantity > 0 {
var issued int64
if err := tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("coupon_id = ?", tpl.ID).Count(&issued).Error; err != nil {
return nil, err
}
if issued >= tpl.TotalQuantity {
return nil, gorm.ErrInvalidData
}
}
item := &model.UserCoupons{UserID: userID, CouponID: tpl.ID, Status: 1}
if !tpl.ValidStart.IsZero() {
item.ValidStart = tpl.ValidStart
} else {
item.ValidStart = time.Now()
}
if !tpl.ValidEnd.IsZero() {
item.ValidEnd = tpl.ValidEnd
}
do := tx.WithContext(ctx).Omit("used_at", "used_order_id")
if tpl.ValidEnd.IsZero() {
do = do.Omit("valid_end")
}
if err := do.Create(item).Error; err != nil {
return nil, err
}
balance := int64(0)
if tpl.DiscountType == 1 && tpl.DiscountValue > 0 {
balance = tpl.DiscountValue
}
if err := tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("id = ?", item.ID).Update("balance_amount", balance).Error; err != nil {
return nil, err
}
return &prizeGrantResult{
RewardType: RewardTypeCoupon,
RewardRefID: prize.RewardRefID,
PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, tpl.Name),
PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, ""),
PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, tpl.DiscountValue),
GrantRecordType: GrantRecordTypeCoupon,
GrantRecordID: item.ID,
CostCents: prize.CostSnapshotCents,
}, nil
}
func fallbackRewardName(snapshot string, fallback string) string {
if snapshot != "" {
return snapshot
}
return fallback
}
func fallbackRewardImage(snapshot string, fallback string) string {
if snapshot != "" {
return snapshot
}
return fallback
}
func fallbackRewardValue(snapshot int64, fallback int64) int64 {
if snapshot > 0 {
return snapshot
}
return fallback
}
func shuffleParticipants(list []Participant) {
seed := time.Now().UnixNano()
var b [8]byte
if _, err := crand.Read(b[:]); err == nil {
seed = int64(binary.LittleEndian.Uint64(b[:]))
}
r := rand.New(rand.NewSource(seed))
r.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] })
}
func buildPrizePool(prizes []Prize) []Prize {
pool := make([]Prize, 0)
for _, prize := range prizes {
for i := 0; i < prize.RemainingQuantity; i++ {
pool = append(pool, prize)
}
}
return pool
}
func shufflePrizes(list []Prize) {
seed := time.Now().UnixNano()
var b [8]byte
if _, err := crand.Read(b[:]); err == nil {
seed = int64(binary.LittleEndian.Uint64(b[:]))
}
r := rand.New(rand.NewSource(seed))
r.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] })
}
func StartScheduledDraw(log logger.CustomLogger, repo mysql.Repo) {
svc := New(log, repo)
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
_ = svc.DrawDueActivities(context.Background())
}
}()
}

View File

@ -0,0 +1,93 @@
package welfare_activity
import (
"context"
"errors"
"fmt"
"time"
)
func (s *service) Join(ctx context.Context, activityID int64, userID int64) error {
if activityID <= 0 || userID <= 0 {
return errors.New("活动或用户无效")
}
var activity Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", activityID).First(&activity).Error; err != nil {
return err
}
now := time.Now()
if activity.Status != StatusActive {
return errors.New("活动未开放参与")
}
if now.Before(activity.StartTime) || now.After(activity.EndTime) || now.After(activity.DrawTime) {
return errors.New("当前不在活动参与时间内")
}
start, end, period := periodRange(activity.Type, now)
paid, err := s.sumPaidAmount(ctx, userID, start, end)
if err != nil {
return err
}
if paid < activity.ThresholdAmount {
return fmt.Errorf("未达到参与门槛,还差%d分", activity.ThresholdAmount-paid)
}
participant := &Participant{ActivityID: activityID, UserID: userID, PeriodKey: period, PaidAmountSnapshot: paid}
return s.repo.GetDbW().WithContext(ctx).Create(participant).Error
}
func (s *service) ListParticipants(ctx context.Context, activityID int64, page int, pageSize int) (*ParticipantResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
var total int64
db := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_participants").Where("activity_id = ?", activityID)
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var list []ParticipantAvatar
err := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_participants p").
Select("p.user_id, COALESCE(u.nickname, '') AS nickname, COALESCE(u.avatar, '') AS avatar").
Joins("LEFT JOIN users u ON u.id = p.user_id").
Where("p.activity_id = ?", activityID).
Order("p.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&list).Error
return &ParticipantResponse{Page: page, PageSize: pageSize, Total: total, List: list}, err
}
func (s *service) sumPaidAmount(ctx context.Context, userID int64, start time.Time, end time.Time) (int64, error) {
var total int64
err := s.repo.GetDbR().WithContext(ctx).Table("orders").
Select("COALESCE(SUM(actual_amount), 0)").
Where("user_id = ? AND status = 2 AND actual_amount > 0", userID).
Where("COALESCE(NULLIF(paid_at, '1970-01-01 00:00:00'), created_at) >= ?", start).
Where("COALESCE(NULLIF(paid_at, '1970-01-01 00:00:00'), created_at) < ?", end).
Scan(&total).Error
return total, err
}
func periodRange(activityType string, now time.Time) (time.Time, time.Time, string) {
loc := now.Location()
switch activityType {
case TypeWeekly:
dayOffset := (int(now.Weekday()) + 6) % 7
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -dayOffset)
end := start.AddDate(0, 0, 7)
y, w := start.ISOWeek()
return start, end, fmt.Sprintf("%04d-W%02d", y, w)
case TypeMonthly:
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end, start.Format("2006-01")
default:
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end, start.Format("2006-01-02")
}
}

View File

@ -0,0 +1,69 @@
package welfare_activity
import (
"context"
"time"
)
func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int64) ([]JoinableActivityItem, error) {
now := time.Now()
var activities []Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("deleted_at IS NULL AND status = ? AND start_time <= ? AND end_time > ? AND draw_time > ?", StatusActive, now, now, now).Order("draw_time ASC, id ASC").Find(&activities).Error; err != nil {
return nil, err
}
if len(activities) == 0 {
return []JoinableActivityItem{}, nil
}
paidByType := map[string]int64{}
periodByType := map[string]string{}
for _, activity := range activities {
if _, ok := paidByType[activity.Type]; ok {
continue
}
start, end, period := periodRange(activity.Type, now)
paid, err := s.sumPaidAmount(ctx, userID, start, end)
if err != nil {
return nil, err
}
paidByType[activity.Type] = paid
periodByType[activity.Type] = period
}
activityIDs := make([]int64, 0, len(activities))
for _, activity := range activities {
activityIDs = append(activityIDs, activity.ID)
}
var participants []Participant
if err := s.repo.GetDbR().WithContext(ctx).Where("user_id = ? AND activity_id IN ?", userID, activityIDs).Find(&participants).Error; err != nil {
return nil, err
}
joinedMap := map[int64]map[string]bool{}
for _, participant := range participants {
if _, ok := joinedMap[participant.ActivityID]; !ok {
joinedMap[participant.ActivityID] = map[string]bool{}
}
joinedMap[participant.ActivityID][participant.PeriodKey] = true
}
items := make([]JoinableActivityItem, 0, len(activities))
for _, activity := range activities {
currentPaid := paidByType[activity.Type]
periodKey := periodByType[activity.Type]
joined := joinedMap[activity.ID][periodKey]
canJoin := !joined && currentPaid >= activity.ThresholdAmount
items = append(items, JoinableActivityItem{
ActivityID: activity.ID,
Title: activity.Title,
Type: activity.Type,
ThresholdAmount: activity.ThresholdAmount,
CurrentPaid: currentPaid,
CanJoin: canJoin,
Joined: joined,
StartTime: activity.StartTime,
EndTime: activity.EndTime,
DrawTime: activity.DrawTime,
})
}
return items, nil
}

View File

@ -0,0 +1,193 @@
package welfare_activity
import "time"
const (
RewardTypeProduct = "product"
RewardTypeItemCard = "item_card"
RewardTypeCoupon = "coupon"
GrantRecordTypeInventory = "inventory"
GrantRecordTypeItemCard = "user_item_card"
GrantRecordTypeCoupon = "user_coupon"
)
type Activity struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Title string `gorm:"column:title" json:"title"`
Type string `gorm:"column:type" json:"type"`
ThresholdAmount int64 `gorm:"column:threshold_amount" json:"threshold_amount"`
StartTime time.Time `gorm:"column:start_time" json:"start_time"`
EndTime time.Time `gorm:"column:end_time" json:"end_time"`
DrawTime time.Time `gorm:"column:draw_time" json:"draw_time"`
Status string `gorm:"column:status" json:"status"`
Description string `gorm:"column:description" json:"description"`
CoverImage string `gorm:"column:cover_image" json:"cover_image"`
DrawBatch string `gorm:"column:draw_batch" json:"draw_batch"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at,omitempty"`
}
func (*Activity) TableName() string { return "welfare_activities" }
type Prize struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
RewardType string `gorm:"column:reward_type" json:"reward_type"`
RewardRefID int64 `gorm:"column:reward_ref_id" json:"reward_ref_id"`
RewardNameSnapshot string `gorm:"column:reward_name_snapshot" json:"reward_name_snapshot"`
RewardImageSnapshot string `gorm:"column:reward_image_snapshot" json:"reward_image_snapshot"`
RewardValueSnapshotCents int64 `gorm:"column:reward_value_snapshot_cents" json:"reward_value_snapshot_cents"`
CostSnapshotCents int64 `gorm:"column:cost_snapshot_cents" json:"cost_snapshot_cents"`
Quantity int `gorm:"column:quantity" json:"quantity"`
RemainingQuantity int `gorm:"column:remaining_quantity" json:"remaining_quantity"`
Sort int `gorm:"column:sort" json:"sort"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
Name string `gorm:"-" json:"name,omitempty"`
Image string `gorm:"-" json:"image,omitempty"`
PriceCents int64 `gorm:"-" json:"price_cents,omitempty"`
}
func (*Prize) TableName() string { return "welfare_activity_prizes" }
type Participant struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
UserID int64 `gorm:"column:user_id" json:"user_id"`
PeriodKey string `gorm:"column:period_key" json:"period_key"`
PaidAmountSnapshot int64 `gorm:"column:paid_amount_snapshot" json:"paid_amount_snapshot"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (*Participant) TableName() string { return "welfare_activity_participants" }
type Winner struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
PrizeID int64 `gorm:"column:prize_id" json:"prize_id"`
RewardType string `gorm:"column:reward_type" json:"reward_type"`
RewardRefID int64 `gorm:"column:reward_ref_id" json:"reward_ref_id"`
PrizeNameSnapshot string `gorm:"column:prize_name_snapshot" json:"prize_name_snapshot"`
PrizeImageSnapshot string `gorm:"column:prize_image_snapshot" json:"prize_image_snapshot"`
PrizeValueSnapshotCents int64 `gorm:"column:prize_value_snapshot_cents" json:"prize_value_snapshot_cents"`
UserID int64 `gorm:"column:user_id" json:"user_id"`
GrantRecordType string `gorm:"column:grant_record_type" json:"grant_record_type"`
GrantRecordID int64 `gorm:"column:grant_record_id" json:"grant_record_id"`
CostCents int64 `gorm:"column:cost_cents" json:"cost_cents"`
DrawBatch string `gorm:"column:draw_batch" json:"draw_batch"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (*Winner) TableName() string { return "welfare_activity_winners" }
type SaveActivityRequest struct {
Title string `json:"title"`
Type string `json:"type"`
ThresholdAmount int64 `json:"threshold_amount"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DrawTime time.Time `json:"draw_time"`
Status string `json:"status"`
Description string `json:"description"`
CoverImage string `json:"cover_image"`
Prizes []PrizeInput `json:"prizes"`
}
type PrizeInput struct {
RewardType string `json:"reward_type"`
RewardRefID int64 `json:"reward_ref_id"`
ProductID int64 `json:"product_id,omitempty"`
Quantity int `json:"quantity"`
Sort int `json:"sort"`
}
type ListActivitiesRequest struct {
Type string
Status string
Title string
Page int
PageSize int
}
type ActivityListItem struct {
Activity
ParticipantCount int64 `json:"participant_count"`
WinnerCount int64 `json:"winner_count"`
CostCents int64 `json:"cost_cents"`
}
type ListActivitiesResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []ActivityListItem `json:"list"`
}
type ActivityDetail struct {
Activity
Prizes []Prize `json:"prizes"`
CurrentPaid int64 `json:"current_paid"`
CanJoin bool `json:"can_join"`
Joined bool `json:"joined"`
ParticipantCount int64 `json:"participant_count"`
Participants []ParticipantAvatar `json:"participants"`
Winners []WinnerItem `json:"winners"`
}
type ParticipantAvatar struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
type ParticipantResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []ParticipantAvatar `json:"list"`
}
type WinnerItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
PrizeID int64 `json:"prize_id"`
RewardType string `json:"reward_type"`
RewardRefID int64 `json:"reward_ref_id"`
PrizeName string `json:"prize_name"`
PrizeImage string `json:"prize_image"`
PriceCents int64 `json:"price_cents"`
CostCents int64 `json:"cost_cents"`
GrantRecordType string `json:"grant_record_type"`
GrantRecordID int64 `json:"grant_record_id"`
CreatedAt time.Time `json:"created_at"`
}
type WinnerListResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []WinnerItem `json:"list"`
}
type JoinableActivityItem struct {
ActivityID int64 `json:"activity_id"`
Title string `json:"title"`
Type string `json:"type"`
ThresholdAmount int64 `json:"threshold_amount"`
CurrentPaid int64 `json:"current_paid"`
CanJoin bool `json:"can_join"`
Joined bool `json:"joined"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DrawTime time.Time `json:"draw_time"`
}
type CostSummary struct {
CostCents int64 `json:"cost_cents"`
Count int64 `json:"count"`
}

View File

@ -0,0 +1,47 @@
package welfare_activity
import (
"context"
"time"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
usersvc "bindbox-game/internal/service/user"
)
const (
TypeDaily = "daily"
TypeWeekly = "weekly"
TypeMonthly = "monthly"
StatusActive = "active"
StatusFinished = "finished"
)
type Service interface {
CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error)
UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error
CopyActivity(ctx context.Context, id int64, req SaveActivityRequest) (int64, error)
ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error)
GetActivity(ctx context.Context, id int64, userID int64) (*ActivityDetail, error)
DeleteActivity(ctx context.Context, id int64) error
SavePrizes(ctx context.Context, activityID int64, prizes []PrizeInput) error
ListParticipants(ctx context.Context, activityID int64, page int, pageSize int) (*ParticipantResponse, error)
Join(ctx context.Context, activityID int64, userID int64) error
ListWinners(ctx context.Context, activityID int64, page int, pageSize int) (*WinnerListResponse, error)
ListJoinableActivitiesForUser(ctx context.Context, userID int64) ([]JoinableActivityItem, error)
Draw(ctx context.Context, activityID int64) error
DrawDueActivities(ctx context.Context) error
GetCost(ctx context.Context, activityID int64) (*CostSummary, error)
GetCostSummary(ctx context.Context, startTime *time.Time, endTime *time.Time) (*CostSummary, error)
}
type service struct {
logger logger.CustomLogger
repo mysql.Repo
userSvc usersvc.Service
}
func New(log logger.CustomLogger, repo mysql.Repo) Service {
return &service{logger: log, repo: repo, userSvc: usersvc.New(log, repo)}
}

View File

@ -0,0 +1,57 @@
package welfare_activity
import (
"context"
"time"
)
func (s *service) ListWinners(ctx context.Context, activityID int64, page int, pageSize int) (*WinnerListResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
db := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners w").Where("w.activity_id = ?", activityID)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var list []WinnerItem
err := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners w").
Select("w.id, w.user_id, COALESCE(u.nickname, '') AS nickname, COALESCE(u.avatar, '') AS avatar, w.prize_id, w.reward_type, w.reward_ref_id, w.prize_name_snapshot AS prize_name, w.prize_image_snapshot AS prize_image, w.prize_value_snapshot_cents AS price_cents, w.cost_cents, w.grant_record_type, w.grant_record_id, w.created_at").
Joins("LEFT JOIN users u ON u.id = w.user_id").
Where("w.activity_id = ?", activityID).
Order("w.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&list).Error
return &WinnerListResponse{Page: page, PageSize: pageSize, Total: total, List: list}, err
}
func (s *service) GetCost(ctx context.Context, activityID int64) (*CostSummary, error) {
var out CostSummary
err := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").
Select("COALESCE(SUM(cost_cents), 0) AS cost_cents, COUNT(*) AS count").
Where("activity_id = ?", activityID).
Scan(&out).Error
return &out, err
}
func (s *service) GetCostSummary(ctx context.Context, startTime *time.Time, endTime *time.Time) (*CostSummary, error) {
q := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").Select("COALESCE(SUM(cost_cents), 0) AS cost_cents, COUNT(*) AS count")
if startTime != nil {
q = q.Where("created_at >= ?", *startTime)
}
if endTime != nil {
q = q.Where("created_at < ?", *endTime)
}
var out CostSummary
if err := q.Scan(&out).Error; err != nil {
return nil, err
}
return &out, nil
}

View File

@ -0,0 +1,75 @@
CREATE TABLE IF NOT EXISTS `welfare_activities` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`title` VARCHAR(128) NOT NULL COMMENT '活动标题',
`type` VARCHAR(16) NOT NULL COMMENT '活动类型: daily/weekly/monthly',
`threshold_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '参与门槛金额(分)',
`start_time` DATETIME(3) NOT NULL COMMENT '开始时间',
`end_time` DATETIME(3) NOT NULL COMMENT '结束时间',
`draw_time` DATETIME(3) NOT NULL COMMENT '开奖时间',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/finished',
`description` TEXT NULL COMMENT '活动说明',
`cover_image` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面图',
`draw_batch` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '开奖批次',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted_at` DATETIME(3) NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_welfare_activities_type_status` (`type`, `status`),
KEY `idx_welfare_activities_draw_time` (`draw_time`),
KEY `idx_welfare_activities_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动';
CREATE TABLE IF NOT EXISTS `welfare_activity_prizes` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '福利活动ID',
`reward_type` VARCHAR(32) NOT NULL COMMENT '奖品类型: product/item_card/coupon',
`reward_ref_id` BIGINT NOT NULL COMMENT '奖品资源ID',
`reward_name_snapshot` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称快照',
`reward_image_snapshot` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图片快照',
`reward_value_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '奖品展示价值快照(分)',
`cost_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '奖品成本快照(分)',
`quantity` INT NOT NULL DEFAULT 0 COMMENT '初始奖品数量',
`remaining_quantity` INT NOT NULL DEFAULT 0 COMMENT '剩余数量',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_welfare_prizes_activity` (`activity_id`),
KEY `idx_welfare_prizes_reward` (`reward_type`, `reward_ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动奖品配置';
CREATE TABLE IF NOT EXISTS `welfare_activity_participants` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '福利活动ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`period_key` VARCHAR(16) NOT NULL COMMENT '参与周期标识',
`paid_amount_snapshot` BIGINT NOT NULL DEFAULT 0 COMMENT '参与时周期消费快照(分)',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_welfare_participant` (`activity_id`, `user_id`, `period_key`),
KEY `idx_welfare_participants_activity` (`activity_id`, `created_at`),
KEY `idx_welfare_participants_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动参与记录';
CREATE TABLE IF NOT EXISTS `welfare_activity_winners` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '福利活动ID',
`prize_id` BIGINT NOT NULL COMMENT '奖品配置ID',
`reward_type` VARCHAR(32) NOT NULL COMMENT '奖品类型: product/item_card/coupon',
`reward_ref_id` BIGINT NOT NULL COMMENT '奖品资源ID',
`prize_name_snapshot` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '中奖奖品名称快照',
`prize_image_snapshot` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '中奖奖品图片快照',
`prize_value_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '中奖奖品展示价值快照(分)',
`user_id` BIGINT NOT NULL COMMENT '中奖用户ID',
`grant_record_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发放记录类型: inventory/user_item_card/user_coupon',
`grant_record_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发放记录ID',
`cost_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '成本(分)',
`draw_batch` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '开奖批次',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_welfare_winner_user` (`activity_id`, `user_id`),
KEY `idx_welfare_winners_activity` (`activity_id`, `created_at`),
KEY `idx_welfare_winners_user` (`user_id`),
KEY `idx_welfare_winners_reward` (`reward_type`, `reward_ref_id`),
KEY `idx_welfare_winners_grant` (`grant_record_type`, `grant_record_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动中奖记录';