feat(activity): 重构福利活动并支持统一奖池
对齐福利活动新库表结构,支持商品、道具卡和优惠券统一建奖、开奖与中奖记录。 同时新增福利活动测试命令行工具,便于模拟消费、参与活动并验证完整开奖链路。
This commit is contained in:
parent
45ea70760b
commit
3db52af4b6
284
cmd/welfare_activity_test/main.go
Normal file
284
cmd/welfare_activity_test/main.go
Normal 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
1
go.mod
@ -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
2
go.sum
@ -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=
|
||||
|
||||
119
internal/api/activity/welfare_activities_app.go
Normal file
119
internal/api/activity/welfare_activities_app.go
Normal 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)
|
||||
}
|
||||
}
|
||||
311
internal/api/admin/welfare_activities_admin.go
Normal file
311
internal/api/admin/welfare_activities_admin.go
Normal 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
|
||||
}
|
||||
108
internal/service/user/game_pass_testtool.go
Normal file
108
internal/service/user/game_pass_testtool.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
// 管理端强制绑定/修改/解绑邀请人
|
||||
|
||||
344
internal/service/welfare_activity/activity.go
Normal file
344
internal/service/welfare_activity/activity.go
Normal 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(¤t).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
|
||||
}
|
||||
}
|
||||
}
|
||||
329
internal/service/welfare_activity/draw.go
Normal file
329
internal/service/welfare_activity/draw.go
Normal 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())
|
||||
}
|
||||
}()
|
||||
}
|
||||
93
internal/service/welfare_activity/participant.go
Normal file
93
internal/service/welfare_activity/participant.go
Normal 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")
|
||||
}
|
||||
}
|
||||
69
internal/service/welfare_activity/test_tool.go
Normal file
69
internal/service/welfare_activity/test_tool.go
Normal 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
|
||||
}
|
||||
193
internal/service/welfare_activity/types.go
Normal file
193
internal/service/welfare_activity/types.go
Normal 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"`
|
||||
}
|
||||
47
internal/service/welfare_activity/welfare_activity.go
Normal file
47
internal/service/welfare_activity/welfare_activity.go
Normal 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)}
|
||||
}
|
||||
57
internal/service/welfare_activity/winner.go
Normal file
57
internal/service/welfare_activity/winner.go
Normal 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
|
||||
}
|
||||
75
migrations/20260426_welfare_full_schema.sql
Normal file
75
migrations/20260426_welfare_full_schema.sql
Normal 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='福利活动中奖记录';
|
||||
Loading…
x
Reference in New Issue
Block a user