diff --git a/cmd/welfare_activity_test/main.go b/cmd/welfare_activity_test/main.go new file mode 100644 index 0000000..f48afca --- /dev/null +++ b/cmd/welfare_activity_test/main.go @@ -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] 当前没有可选的进行中福利活��") + 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 +} diff --git a/go.mod b/go.mod index b5bf38a..6575828 100755 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 1a9e825..13d8856 100755 --- a/go.sum +++ b/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= diff --git a/internal/api/activity/welfare_activities_app.go b/internal/api/activity/welfare_activities_app.go new file mode 100644 index 0000000..e057c3d --- /dev/null +++ b/internal/api/activity/welfare_activities_app.go @@ -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) + } +} diff --git a/internal/api/admin/welfare_activities_admin.go b/internal/api/admin/welfare_activities_admin.go new file mode 100644 index 0000000..028b893 --- /dev/null +++ b/internal/api/admin/welfare_activities_admin.go @@ -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 +} diff --git a/internal/service/user/game_pass_testtool.go b/internal/service/user/game_pass_testtool.go new file mode 100644 index 0000000..019e7a0 --- /dev/null +++ b/internal/service/user/game_pass_testtool.go @@ -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 +} diff --git a/internal/service/user/user.go b/internal/service/user/user.go index a8c0660..32322be 100755 --- a/internal/service/user/user.go +++ b/internal/service/user/user.go @@ -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) // 管理端强制绑定/修改/解绑邀请人 diff --git a/internal/service/welfare_activity/activity.go b/internal/service/welfare_activity/activity.go new file mode 100644 index 0000000..c7f19be --- /dev/null +++ b/internal/service/welfare_activity/activity.go @@ -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 + } + } +} diff --git a/internal/service/welfare_activity/draw.go b/internal/service/welfare_activity/draw.go new file mode 100644 index 0000000..096053f --- /dev/null +++ b/internal/service/welfare_activity/draw.go @@ -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()) + } + }() +} diff --git a/internal/service/welfare_activity/participant.go b/internal/service/welfare_activity/participant.go new file mode 100644 index 0000000..9a52ed6 --- /dev/null +++ b/internal/service/welfare_activity/participant.go @@ -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") + } +} diff --git a/internal/service/welfare_activity/test_tool.go b/internal/service/welfare_activity/test_tool.go new file mode 100644 index 0000000..f5154ca --- /dev/null +++ b/internal/service/welfare_activity/test_tool.go @@ -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 +} diff --git a/internal/service/welfare_activity/types.go b/internal/service/welfare_activity/types.go new file mode 100644 index 0000000..121078b --- /dev/null +++ b/internal/service/welfare_activity/types.go @@ -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"` +} diff --git a/internal/service/welfare_activity/welfare_activity.go b/internal/service/welfare_activity/welfare_activity.go new file mode 100644 index 0000000..c64d493 --- /dev/null +++ b/internal/service/welfare_activity/welfare_activity.go @@ -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)} +} diff --git a/internal/service/welfare_activity/winner.go b/internal/service/welfare_activity/winner.go new file mode 100644 index 0000000..6021dc9 --- /dev/null +++ b/internal/service/welfare_activity/winner.go @@ -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 +} diff --git a/migrations/20260426_welfare_full_schema.sql b/migrations/20260426_welfare_full_schema.sql new file mode 100644 index 0000000..5528440 --- /dev/null +++ b/migrations/20260426_welfare_full_schema.sql @@ -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='福利活动中奖记录';