diff --git a/internal/api/activity/app.go b/internal/api/activity/app.go index 278ce92..b52ee47 100755 --- a/internal/api/activity/app.go +++ b/internal/api/activity/app.go @@ -7,6 +7,7 @@ import ( activitysvc "bindbox-game/internal/service/activity" prizegrantsvc "bindbox-game/internal/service/prize_grant_activity" tasksvc "bindbox-game/internal/service/task_center" + thresholdsvc "bindbox-game/internal/service/threshold_activity" titlesvc "bindbox-game/internal/service/title" usersvc "bindbox-game/internal/service/user" welfaresvc "bindbox-game/internal/service/welfare_activity" @@ -26,6 +27,7 @@ type handler struct { redis *redis.Client activityOrder activitysvc.ActivityOrderService // 活动订单服务 welfare welfaresvc.Service + threshold thresholdsvc.Service prizeGrant prizegrantsvc.Service } @@ -43,6 +45,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task task redis: rdb, activityOrder: activitysvc.NewActivityOrderService(logger, db), welfare: welfaresvc.New(logger, db), + threshold: thresholdsvc.New(logger, db), prizeGrant: prizegrantsvc.New(logger, db), } } diff --git a/internal/api/activity/threshold_activities_app.go b/internal/api/activity/threshold_activities_app.go new file mode 100644 index 0000000..64a65b2 --- /dev/null +++ b/internal/api/activity/threshold_activities_app.go @@ -0,0 +1,119 @@ +package app + +import ( + "net/http" + "strconv" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + thresholdsvc "bindbox-game/internal/service/threshold_activity" +) + +type listThresholdActivitiesRequest struct { + Type string `form:"type"` + Status string `form:"status"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type listThresholdParticipantsRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type listThresholdWinnersRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +func (h *handler) ListThresholdActivities() core.HandlerFunc { + return func(ctx core.Context) { + req := new(listThresholdActivitiesRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + status := req.Status + if status == "" { + status = thresholdsvc.StatusActive + } + res, err := h.threshold.ListActivities(ctx.RequestContext(), thresholdsvc.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) GetThresholdActivity() 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.threshold.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) JoinThresholdActivity() 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.threshold.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) ListThresholdParticipants() core.HandlerFunc { + return func(ctx core.Context) { + id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64) + req := new(listThresholdParticipantsRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + res, err := h.threshold.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) ListThresholdWinners() core.HandlerFunc { + return func(ctx core.Context) { + id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64) + req := new(listThresholdWinnersRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + res, err := h.threshold.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/activity/threshold_activities_app_test.go b/internal/api/activity/threshold_activities_app_test.go new file mode 100644 index 0000000..adf9177 --- /dev/null +++ b/internal/api/activity/threshold_activities_app_test.go @@ -0,0 +1,242 @@ +package app + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/proposal" + "bindbox-game/internal/repository/mysql" +) + +func setupThresholdAppTestRouter(t *testing.T) (mysql.Repo, http.Handler) { + t.Helper() + repo, err := mysql.NewSQLiteRepoForTest() + if err != nil { + t.Fatal(err) + } + ddls := []string{ + `CREATE TABLE threshold_activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + type TEXT NOT NULL, + qualification_mode TEXT NOT NULL, + spend_threshold_amount INTEGER NOT NULL DEFAULT 0, + invite_threshold_count INTEGER NOT NULL DEFAULT 0, + invite_effective_amount INTEGER NOT NULL DEFAULT 0, + min_participants INTEGER NOT NULL DEFAULT 1, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + draw_time DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + description TEXT, + cover_image TEXT NOT NULL DEFAULT '', + draw_batch TEXT NOT NULL DEFAULT '', + abort_reason TEXT NOT NULL DEFAULT '', + aborted_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + )`, + `CREATE TABLE threshold_activity_prizes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_ref_id INTEGER NOT NULL, + reward_name_snapshot TEXT NOT NULL DEFAULT '', + reward_image_snapshot TEXT NOT NULL DEFAULT '', + reward_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, + cost_snapshot_cents INTEGER NOT NULL DEFAULT 0, + quantity INTEGER NOT NULL DEFAULT 0, + remaining_quantity INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE threshold_activity_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + period_key TEXT NOT NULL, + qualification_source TEXT NOT NULL DEFAULT '', + paid_amount_snapshot INTEGER NOT NULL DEFAULT 0, + effective_invite_count_snapshot INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(activity_id, user_id, period_key) + )`, + `CREATE TABLE threshold_activity_winners ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + prize_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_ref_id INTEGER NOT NULL, + prize_name_snapshot TEXT NOT NULL DEFAULT '', + prize_image_snapshot TEXT NOT NULL DEFAULT '', + prize_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NOT NULL, + grant_record_type TEXT NOT NULL DEFAULT '', + grant_record_id INTEGER NOT NULL DEFAULT 0, + cost_cents INTEGER NOT NULL DEFAULT 0, + draw_batch TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nickname TEXT NOT NULL, + avatar TEXT, + invite_code TEXT NOT NULL, + inviter_id INTEGER, + status INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + )`, + `CREATE TABLE user_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inviter_id INTEGER NOT NULL, + invitee_id INTEGER NOT NULL, + invite_code TEXT NOT NULL DEFAULT '', + reward_points INTEGER NOT NULL DEFAULT 0, + rewarded_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + is_effective BOOLEAN NOT NULL DEFAULT 0, + accumulated_amount INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER NOT NULL, + order_no TEXT NOT NULL, + source_type INTEGER NOT NULL DEFAULT 1, + total_amount INTEGER NOT NULL DEFAULT 0, + discount_amount INTEGER NOT NULL DEFAULT 0, + points_amount INTEGER NOT NULL DEFAULT 0, + actual_amount INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + pay_preorder_id INTEGER NOT NULL DEFAULT 0, + paid_at DATETIME, + cancelled_at DATETIME, + user_address_id INTEGER NOT NULL DEFAULT 0, + is_consumed INTEGER NOT NULL DEFAULT 0, + points_ledger_id INTEGER NOT NULL DEFAULT 0, + coupon_id INTEGER NOT NULL DEFAULT 0, + item_card_id INTEGER NOT NULL DEFAULT 0, + remark TEXT, + ext_order_id TEXT NOT NULL DEFAULT '' + )`, + } + for _, ddl := range ddls { + if err := repo.GetDbW().Exec(ddl).Error; err != nil { + t.Fatal(err) + } + } + + lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) + if err != nil { + t.Fatal(err) + } + mux, err := core.New(lg) + if err != nil { + t.Fatal(err) + } + h := New(lg, repo, nil, nil) + public := mux.Group("/api/app") + public.GET("/threshold-activities", h.ListThresholdActivities()) + public.GET("/threshold-activities/:id", h.GetThresholdActivity()) + dummyAuth := func(ctx core.Context) (proposal.SessionUserInfo, core.BusinessError) { + return proposal.SessionUserInfo{Id: 88}, nil + } + auth := mux.Group("/api/app", core.WrapAuthHandler(dummyAuth)) + auth.GET("/threshold-activities/:id/my", h.GetThresholdActivity()) + auth.POST("/threshold-activities/:id/join", h.JoinThresholdActivity()) + return repo, mux +} + +func TestThresholdAppListAndGetMy(t *testing.T) { + repo, mux := setupThresholdAppTestRouter(t) + now := time.Now() + if err := repo.GetDbW().Exec(`INSERT INTO threshold_activities (id, title, type, qualification_mode, spend_threshold_amount, invite_threshold_count, invite_effective_amount, min_participants, start_time, end_time, draw_time, status, description, cover_image, draw_batch, abort_reason, created_at, updated_at) VALUES (1, 'App活动', 'daily', 'spend_only', 1000, 0, 0, 1, ?, ?, ?, 'active', 'desc', '', '', '', ?, ?)`, now.Add(-time.Hour), now.Add(24*time.Hour), now.Add(25*time.Hour), now, now).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbW().Exec(`INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (88, 'APP_OK', 1, 1200, 1200, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)).Error; err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/app/threshold-activities?page=1&page_size=20", bytes.NewReader([]byte{})) + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("list code=%d body=%s", rr.Code, rr.Body.String()) + } + var listed struct { + Total int64 `json:"total"` + List []map[string]any `json:"list"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil { + t.Fatal(err) + } + if listed.Total != 1 || len(listed.List) != 1 { + t.Fatalf("expected 1 app activity, got total=%d len=%d", listed.Total, len(listed.List)) + } + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/api/app/threshold-activities/1/my", bytes.NewReader([]byte{})) + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("get my code=%d body=%s", rr.Code, rr.Body.String()) + } + var detail map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &detail); err != nil { + t.Fatal(err) + } + if detail["can_join"] != true { + t.Fatalf("expected can_join=true, got %v", detail["can_join"]) + } + progress := detail["qualification_progress"].(map[string]any) + if int(progress["current_paid"].(float64)) != 1200 { + t.Fatalf("expected current_paid=1200, got %v", progress["current_paid"]) + } +} + +func TestThresholdAppJoinSuccessAndRejectUnqualified(t *testing.T) { + repo, mux := setupThresholdAppTestRouter(t) + now := time.Now() + if err := repo.GetDbW().Exec(`INSERT INTO threshold_activities (id, title, type, qualification_mode, spend_threshold_amount, invite_threshold_count, invite_effective_amount, min_participants, start_time, end_time, draw_time, status, description, cover_image, draw_batch, abort_reason, created_at, updated_at) VALUES (2, 'Join活动', 'daily', 'spend_only', 1000, 0, 0, 1, ?, ?, ?, 'active', 'desc', '', '', '', ?, ?)`, now.Add(-time.Hour), now.Add(24*time.Hour), now.Add(25*time.Hour), now, now).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbW().Exec(`INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (88, 'JOIN_OK', 1, 1500, 1500, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)).Error; err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/app/threshold-activities/2/join", bytes.NewReader([]byte{})) + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("join success code=%d body=%s", rr.Code, rr.Body.String()) + } + var count int64 + if err := repo.GetDbW().Table("threshold_activity_participants").Where("activity_id = ? AND user_id = ?", 2, 88).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected 1 participant after join, got %d", count) + } + + if err := repo.GetDbW().Exec(`INSERT INTO threshold_activities (id, title, type, qualification_mode, spend_threshold_amount, invite_threshold_count, invite_effective_amount, min_participants, start_time, end_time, draw_time, status, description, cover_image, draw_batch, abort_reason, created_at, updated_at) VALUES (3, 'Join失败活动', 'daily', 'spend_only', 5000, 0, 0, 1, ?, ?, ?, 'active', 'desc', '', '', '', ?, ?)`, now.Add(-time.Hour), now.Add(24*time.Hour), now.Add(25*time.Hour), now, now).Error; err != nil { + t.Fatal(err) + } + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, "/api/app/threshold-activities/3/join", bytes.NewReader([]byte{})) + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unqualified join, got %d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go index 3641f73..4d85a5b 100755 --- a/internal/api/admin/admin.go +++ b/internal/api/admin/admin.go @@ -17,6 +17,7 @@ import ( snapshotsvc "bindbox-game/internal/service/snapshot" synthesissvc "bindbox-game/internal/service/synthesis" syscfgsvc "bindbox-game/internal/service/sysconfig" + thresholdsvc "bindbox-game/internal/service/threshold_activity" titlesvc "bindbox-game/internal/service/title" usersvc "bindbox-game/internal/service/user" welfaresvc "bindbox-game/internal/service/welfare_activity" @@ -44,6 +45,7 @@ type handler struct { synthesis synthesissvc.Service financeSvc financesvc.Service // P&L service (read-only) welfare welfaresvc.Service + threshold thresholdsvc.Service prizeGrant prizegrantsvc.Service } @@ -74,6 +76,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler synthesis: synthesissvc.New(db), financeSvc: financesvc.New(logger, db), welfare: welfaresvc.New(logger, db), + threshold: thresholdsvc.New(logger, db), prizeGrant: prizegrantsvc.New(logger, db), } } diff --git a/internal/api/admin/threshold_activities_admin.go b/internal/api/admin/threshold_activities_admin.go new file mode 100644 index 0000000..682cd46 --- /dev/null +++ b/internal/api/admin/threshold_activities_admin.go @@ -0,0 +1,314 @@ +package admin + +import ( + "net/http" + "strconv" + "time" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + thresholdsvc "bindbox-game/internal/service/threshold_activity" +) + +type saveThresholdActivityRequest struct { + Title string `json:"title" binding:"required"` + Type string `json:"type" binding:"required"` + QualificationMode string `json:"qualification_mode" binding:"required"` + SpendThresholdAmount int64 `json:"spend_threshold_amount"` + InviteThresholdCount int64 `json:"invite_threshold_count"` + InviteEffectiveAmount int64 `json:"invite_effective_amount"` + MinParticipants int64 `json:"min_participants"` + 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 []thresholdsvc.PrizeInput `json:"prizes"` +} + +type listAdminThresholdActivitiesRequest struct { + Title string `form:"title"` + Type string `form:"type"` + Status string `form:"status"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type listAdminThresholdWinnersRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type thresholdCostSummaryRequest struct { + StartTime string `form:"start_time"` + EndTime string `form:"end_time"` +} + +type copyThresholdActivityRequest struct { + Title string `json:"title"` + Type string `json:"type" binding:"required"` + QualificationMode string `json:"qualification_mode" binding:"required"` + SpendThresholdAmount int64 `json:"spend_threshold_amount"` + InviteThresholdCount int64 `json:"invite_threshold_count"` + InviteEffectiveAmount int64 `json:"invite_effective_amount"` + MinParticipants int64 `json:"min_participants"` + 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) CreateThresholdActivity() core.HandlerFunc { + return func(ctx core.Context) { + req := new(saveThresholdActivityRequest) + 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.threshold.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) UpdateThresholdActivity() 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(saveThresholdActivityRequest) + 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.threshold.UpdateActivity(ctx.RequestContext(), id, input); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "操作成功"}) + } +} + +func (h *handler) ListThresholdActivities() core.HandlerFunc { + return func(ctx core.Context) { + req := new(listAdminThresholdActivitiesRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + res, err := h.threshold.ListActivities(ctx.RequestContext(), thresholdsvc.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) GetThresholdActivity() 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.threshold.GetActivityAdmin(ctx.RequestContext(), id) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) DeleteThresholdActivity() 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.threshold.DeleteActivity(ctx.RequestContext(), id); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "操作成功"}) + } +} + +func (h *handler) CopyThresholdActivity() 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(copyThresholdActivityRequest) + 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.threshold.CopyActivity(ctx.RequestContext(), id, thresholdsvc.SaveActivityRequest{ + Title: req.Title, + Type: req.Type, + QualificationMode: req.QualificationMode, + SpendThresholdAmount: req.SpendThresholdAmount, + InviteThresholdCount: req.InviteThresholdCount, + InviteEffectiveAmount: req.InviteEffectiveAmount, + MinParticipants: req.MinParticipants, + 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) ListThresholdParticipants() core.HandlerFunc { + return func(ctx core.Context) { + id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64) + res, err := h.threshold.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) ListThresholdWinners() core.HandlerFunc { + return func(ctx core.Context) { + id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64) + req := new(listAdminThresholdWinnersRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + res, err := h.threshold.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) DrawThresholdActivity() core.HandlerFunc { + return func(ctx core.Context) { + id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err := h.threshold.Draw(ctx.RequestContext(), id); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "操作成功"}) + } +} + +func (h *handler) GetThresholdCost() core.HandlerFunc { + return func(ctx core.Context) { + id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64) + res, err := h.threshold.GetCost(ctx.RequestContext(), id) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) GetThresholdCostSummary() core.HandlerFunc { + return func(ctx core.Context) { + req := new(thresholdCostSummaryRequest) + 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.threshold.GetCostSummary(ctx.RequestContext(), start, end) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (r *saveThresholdActivityRequest) toInput() (thresholdsvc.SaveActivityRequest, error) { + start, err := parseRequiredTime(r.StartTime) + if err != nil { + return thresholdsvc.SaveActivityRequest{}, err + } + end, err := parseRequiredTime(r.EndTime) + if err != nil { + return thresholdsvc.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 thresholdsvc.SaveActivityRequest{}, err + } + prizes := make([]thresholdsvc.PrizeInput, 0, len(r.Prizes)) + for _, prize := range r.Prizes { + if prize.RewardType == "" && prize.ProductID > 0 { + prize.RewardType = thresholdsvc.RewardTypeProduct + prize.RewardRefID = prize.ProductID + } + prizes = append(prizes, prize) + } + return thresholdsvc.SaveActivityRequest{ + Title: r.Title, + Type: r.Type, + QualificationMode: r.QualificationMode, + SpendThresholdAmount: r.SpendThresholdAmount, + InviteThresholdCount: r.InviteThresholdCount, + InviteEffectiveAmount: r.InviteEffectiveAmount, + MinParticipants: r.MinParticipants, + StartTime: start, + EndTime: end, + DrawTime: draw, + Status: r.Status, + Description: r.Description, + CoverImage: r.CoverImage, + Prizes: prizes, + }, nil +} diff --git a/internal/api/admin/threshold_activities_admin_test.go b/internal/api/admin/threshold_activities_admin_test.go new file mode 100644 index 0000000..6668e31 --- /dev/null +++ b/internal/api/admin/threshold_activities_admin_test.go @@ -0,0 +1,208 @@ +package admin + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/proposal" + "bindbox-game/internal/repository/mysql" +) + +func setupThresholdAdminTestRouter(t *testing.T) (mysql.Repo, http.Handler) { + t.Helper() + repo, err := mysql.NewSQLiteRepoForTest() + if err != nil { + t.Fatal(err) + } + ddls := []string{ + `CREATE TABLE threshold_activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + type TEXT NOT NULL, + qualification_mode TEXT NOT NULL, + spend_threshold_amount INTEGER NOT NULL DEFAULT 0, + invite_threshold_count INTEGER NOT NULL DEFAULT 0, + invite_effective_amount INTEGER NOT NULL DEFAULT 0, + min_participants INTEGER NOT NULL DEFAULT 1, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + draw_time DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + description TEXT, + cover_image TEXT NOT NULL DEFAULT '', + draw_batch TEXT NOT NULL DEFAULT '', + abort_reason TEXT NOT NULL DEFAULT '', + aborted_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + )`, + `CREATE TABLE threshold_activity_prizes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_ref_id INTEGER NOT NULL, + reward_name_snapshot TEXT NOT NULL DEFAULT '', + reward_image_snapshot TEXT NOT NULL DEFAULT '', + reward_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, + cost_snapshot_cents INTEGER NOT NULL DEFAULT 0, + quantity INTEGER NOT NULL DEFAULT 0, + remaining_quantity INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE threshold_activity_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + period_key TEXT NOT NULL, + qualification_source TEXT NOT NULL DEFAULT '', + paid_amount_snapshot INTEGER NOT NULL DEFAULT 0, + effective_invite_count_snapshot INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE threshold_activity_winners ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + prize_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_ref_id INTEGER NOT NULL, + prize_name_snapshot TEXT NOT NULL DEFAULT '', + prize_image_snapshot TEXT NOT NULL DEFAULT '', + prize_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NOT NULL, + grant_record_type TEXT NOT NULL DEFAULT '', + grant_record_id INTEGER NOT NULL DEFAULT 0, + cost_cents INTEGER NOT NULL DEFAULT 0, + draw_batch TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nickname TEXT NOT NULL, + avatar TEXT, + invite_code TEXT NOT NULL, + inviter_id INTEGER, + status INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + )`, + } + for _, ddl := range ddls { + if err := repo.GetDbW().Exec(ddl).Error; err != nil { + t.Fatal(err) + } + } + + lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) + if err != nil { + t.Fatal(err) + } + mux, err := core.New(lg) + if err != nil { + t.Fatal(err) + } + dummyAuth := func(ctx core.Context) (proposal.SessionUserInfo, core.BusinessError) { + return proposal.SessionUserInfo{Id: 1, IsSuper: 1}, nil + } + g := mux.Group("/api/admin", core.WrapAuthHandler(dummyAuth)) + h := New(lg, repo, nil) + g.GET("/threshold-activities", h.ListThresholdActivities()) + g.POST("/threshold-activities", h.CreateThresholdActivity()) + g.GET("/threshold-activities/:id", h.GetThresholdActivity()) + g.PUT("/threshold-activities/:id", h.UpdateThresholdActivity()) + return repo, mux +} + +func TestThresholdAdminCreateListAndGet(t *testing.T) { + _, mux := setupThresholdAdminTestRouter(t) + start := time.Now().Add(-time.Hour).Format("2006-01-02 15:04:05") + end := time.Now().Add(24 * time.Hour).Format("2006-01-02 15:04:05") + draw := time.Now().Add(25 * time.Hour).Format("2006-01-02 15:04:05") + body := map[string]any{ + "title": "后台测试活动", + "type": "daily", + "qualification_mode": "invite_only", + "spend_threshold_amount": 0, + "invite_threshold_count": 2, + "invite_effective_amount": 1000, + "min_participants": 3, + "start_time": start, + "end_time": end, + "draw_time": draw, + "status": "active", + "description": "admin create", + "prizes": []any{}, + } + payload, _ := json.Marshal(body) + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/admin/threshold-activities", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("create code=%d body=%s", rr.Code, rr.Body.String()) + } + var created map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + id := int(created["id"].(float64)) + if id <= 0 { + t.Fatalf("expected positive activity id, got %v", created["id"]) + } + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/api/admin/threshold-activities?page=1&page_size=20&status=active", bytes.NewReader([]byte{})) + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("list code=%d body=%s", rr.Code, rr.Body.String()) + } + var listed struct { + Total int64 `json:"total"` + List []map[string]any `json:"list"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil { + t.Fatal(err) + } + if listed.Total != 1 || len(listed.List) != 1 { + t.Fatalf("expected 1 listed activity, got total=%d len=%d", listed.Total, len(listed.List)) + } + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/api/admin/threshold-activities/%d", id), bytes.NewReader([]byte{})) + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("get code=%d body=%s", rr.Code, rr.Body.String()) + } + var detail map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &detail); err != nil { + t.Fatal(err) + } + if detail["title"] != "后台测试活动" { + t.Fatalf("unexpected title: %v", detail["title"]) + } + if detail["qualification_mode"] != "invite_only" { + t.Fatalf("unexpected qualification mode: %v", detail["qualification_mode"]) + } +} + +func TestThresholdAdminCreateRejectsInvalidPayload(t *testing.T) { + _, mux := setupThresholdAdminTestRouter(t) + body := []byte(`{"title":"bad","type":"daily","start_time":"2026-01-01 00:00:00","end_time":"2026-01-02 00:00:00","draw_time":"2026-01-03 00:00:00"}`) + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/admin/threshold-activities", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid payload, got %d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 4cb4f1e..4825067 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -50,16 +50,13 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er panic(err) } - // 添加 OpenTelemetry 链路追踪中间件 cfg := configs.Get() if cfg.Otel.Enabled { mux.Engine().Use(otel.Middleware(configs.ProjectName)) } - // Redis is initialized in main.go rdb := redis.GetClient() - // Instantiate Services userSvc := usersvc.New(logger, db) titleSvc := titlesvc.New(logger, db) taskSvc := tasksvc.New(logger, db, rdb, userSvc, titleSvc) @@ -68,35 +65,23 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er ticketSvc := gamesvc.NewTicketService(logger, db) douyinSvc := douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc) - // Context for Worker ctx, cancel := context.WithCancel(context.Background()) - // Start task center worker go taskSvc.StartWorker(ctx) - // 实例化拦截器 adminHandler := admin.New(logger, db, rdb) activityHandler := activityapi.New(logger, db, rdb, taskSvc) taskCenterHandler := taskcenterapi.New(logger, db, taskSvc) - - // app端的API userHandler := userapi.New(logger, db, taskSvc) - // TODO: Check if userHandler and userAppHandler are redundant or distinct. - // Based on typical project structure, `internal/api/user` is likely `userapp`. - // `internal/api/admin/users_admin.go` might be `userapi` (admin). - // Let's correct the `appapi` typo first. commonHandler := commonapi.New(logger, db) payHandler := payapi.New(logger, db, taskSvc, activitySvc) gameHandler := gameapi.New(logger, db, rdb, userSvc) intc := interceptor.New(logger, db) - // 内部服务接口路由组 (供 Nakama 调用) - // 使用 X-Internal-Key 头进行验证,防止外部访问 internalRouter := mux.Group("/api/internal", func(ctx core.Context) { internalKey := ctx.GetHeader("X-Internal-Key") - // 从配置文件读取内部 API 密钥 expectedKey := configs.Get().Internal.ApiKey if expectedKey == "" { - expectedKey = "bindbox-internal-secret-2024" // 默认值(仅用于向后兼容) + expectedKey = "bindbox-internal-secret-2024" } if internalKey != expectedKey { ctx.AbortWithError(core.Error(403, 10403, "Forbidden: Invalid internal key")) @@ -112,23 +97,16 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er internalRouter.GET("/game/leaderboard", gameHandler.GetLeaderboardInternal()) } - // 管理端非认证接口路由组 adminNonAuthApiRouter := mux.Group("/api/admin") { adminNonAuthApiRouter.POST("/login", adminHandler.Login()) } - // 管理端认证接口路由组 adminAuthApiRouter := mux.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole()) - - // 系统管理接口(为前端模板路径兼容,挂载到 /api) systemApiRouter := mux.Group("/api", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole()) { - // 管理员账号维护接口移除(未被前端使用) - adminAuthApiRouter.GET("/activity_categories", adminHandler.ListActivityCategories()) - // 任务中心管理端 adminAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForAdmin()) adminAuthApiRouter.POST("/task-center/tasks", taskCenterHandler.CreateTaskForAdmin()) adminAuthApiRouter.PUT("/task-center/tasks/:id", taskCenterHandler.ModifyTaskForAdmin()) @@ -140,9 +118,8 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/task-center/events/order-paid", taskCenterHandler.SimulateOrderPaid()) adminAuthApiRouter.POST("/task-center/events/invite-success", taskCenterHandler.SimulateInviteSuccess()) adminAuthApiRouter.GET("/task-center/tasks/:id/reward-stats", taskCenterHandler.GetTaskRewardStats()) - adminAuthApiRouter.GET("/task-center/reward-cost-stats", taskCenterHandler.GetRewardCostStats()) + adminAuthApiRouter.GET("/task-center/reward-cost-stats", taskCenterHandler.GetRewardCostStats()) - // 工作台 adminAuthApiRouter.GET("/dashboard/cards", adminHandler.DashboardCards()) adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend()) adminAuthApiRouter.GET("/dashboard/draw_trend", adminHandler.DashboardDrawTrend()) @@ -158,8 +135,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.GET("/dashboard/player_spending_leaderboard", adminHandler.DashboardPlayerSpendingLeaderboard()) adminAuthApiRouter.GET("/dashboard/activity-profit-loss", adminHandler.DashboardActivityProfitLoss()) adminAuthApiRouter.GET("/dashboard/activity-profit-loss/:activity_id/logs", adminHandler.DashboardActivityLogs()) - - // 运营分析 adminAuthApiRouter.GET("/operations/user_economics", adminHandler.DashboardUserEconomics()) adminAuthApiRouter.GET("/operations/prize_distribution", adminHandler.DashboardPrizeDistribution()) adminAuthApiRouter.GET("/dashboard/product_performance", adminHandler.OperationsProductPerformance()) @@ -173,6 +148,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.GET("/dashboard/order_trend", adminHandler.DashboardOrderTrend()) adminAuthApiRouter.GET("/dashboard/activity_stats", adminHandler.DashboardActivityStats()) adminAuthApiRouter.GET("/dashboard/item_card_sales", adminHandler.DashboardItemCardSales()) + adminAuthApiRouter.GET("/welfare-activities", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareActivities()) adminAuthApiRouter.POST("/welfare-activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateWelfareActivity()) adminAuthApiRouter.GET("/welfare-activities/cost-summary", intc.RequireAdminAction("activity:view"), adminHandler.GetWelfareCostSummary()) @@ -184,6 +160,19 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.GET("/welfare-activities/:id/winners", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareWinners()) adminAuthApiRouter.POST("/welfare-activities/:id/draw", intc.RequireAdminAction("activity:modify"), adminHandler.DrawWelfareActivity()) adminAuthApiRouter.GET("/welfare-activities/:id/cost", intc.RequireAdminAction("activity:view"), adminHandler.GetWelfareCost()) + + adminAuthApiRouter.GET("/threshold-activities", intc.RequireAdminAction("activity:view"), adminHandler.ListThresholdActivities()) + adminAuthApiRouter.POST("/threshold-activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateThresholdActivity()) + adminAuthApiRouter.GET("/threshold-activities/cost-summary", intc.RequireAdminAction("activity:view"), adminHandler.GetThresholdCostSummary()) + adminAuthApiRouter.GET("/threshold-activities/:id", intc.RequireAdminAction("activity:view"), adminHandler.GetThresholdActivity()) + adminAuthApiRouter.PUT("/threshold-activities/:id", intc.RequireAdminAction("activity:modify"), adminHandler.UpdateThresholdActivity()) + adminAuthApiRouter.DELETE("/threshold-activities/:id", intc.RequireAdminAction("activity:delete"), adminHandler.DeleteThresholdActivity()) + adminAuthApiRouter.POST("/threshold-activities/:id/copy", intc.RequireAdminAction("activity:create"), adminHandler.CopyThresholdActivity()) + adminAuthApiRouter.GET("/threshold-activities/:id/participants", intc.RequireAdminAction("activity:view"), adminHandler.ListThresholdParticipants()) + adminAuthApiRouter.GET("/threshold-activities/:id/winners", intc.RequireAdminAction("activity:view"), adminHandler.ListThresholdWinners()) + adminAuthApiRouter.POST("/threshold-activities/:id/draw", intc.RequireAdminAction("activity:modify"), adminHandler.DrawThresholdActivity()) + adminAuthApiRouter.GET("/threshold-activities/:id/cost", intc.RequireAdminAction("activity:view"), adminHandler.GetThresholdCost()) + adminAuthApiRouter.POST("/activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateActivity()) adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities()) adminAuthApiRouter.PUT("/activities/:activity_id", intc.RequireAdminAction("activity:modify"), adminHandler.ModifyActivity()) @@ -203,9 +192,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.PUT("/activities/:activity_id/issues/:issue_id/rewards/batch", intc.RequireAdminAction("activity:modify"), adminHandler.BatchModifyIssueRewards()) adminAuthApiRouter.DELETE("/activities/:activity_id/issues/:issue_id/rewards/:reward_id", intc.RequireAdminAction("activity:delete"), adminHandler.DeleteIssueReward()) - // 已移除:批量造数/批量删除用户接口(未被前端使用) - - // 商品管理:分类与商品 adminAuthApiRouter.POST("/product_categories", adminHandler.CreateProductCategory()) adminAuthApiRouter.PUT("/product_categories/:category_id", adminHandler.ModifyProductCategory()) adminAuthApiRouter.DELETE("/product_categories/:category_id", adminHandler.DeleteProductCategory()) @@ -217,13 +203,11 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.GET("/products", intc.RequireAdminAction("product:view"), adminHandler.ListProducts()) adminAuthApiRouter.POST("/auth/refresh", adminHandler.RefreshToken()) - // 轮播图管理 adminAuthApiRouter.POST("/banners", intc.RequireAdminAction("banner:create"), adminHandler.CreateBanner()) adminAuthApiRouter.PUT("/banners/:banner_id", intc.RequireAdminAction("banner:modify"), adminHandler.ModifyBanner()) adminAuthApiRouter.DELETE("/banners/:banner_id", intc.RequireAdminAction("banner:delete"), adminHandler.DeleteBanner()) adminAuthApiRouter.GET("/banners", intc.RequireAdminAction("banner:view"), adminHandler.ListBanners()) - // 渠道管理 adminAuthApiRouter.POST("/channels", intc.RequireAdminAction("channel:create"), adminHandler.CreateChannel()) adminAuthApiRouter.PUT("/channels/:channel_id", intc.RequireAdminAction("channel:modify"), adminHandler.ModifyChannel()) adminAuthApiRouter.DELETE("/channels/:channel_id", intc.RequireAdminAction("channel:delete"), adminHandler.DeleteChannel()) @@ -232,23 +216,19 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/channels/:channel_id/users/bind", intc.RequireAdminAction("channel:modify"), adminHandler.BindChannelUsers()) adminAuthApiRouter.GET("/channels/:channel_id/stats", intc.RequireAdminAction("channel:view"), adminHandler.ChannelStats()) - // 抖店订单管理 adminAuthApiRouter.GET("/douyin/config", adminHandler.GetDouyinConfig()) adminAuthApiRouter.PUT("/douyin/config", adminHandler.SaveDouyinConfig()) adminAuthApiRouter.GET("/douyin/orders", adminHandler.ListDouyinOrders()) adminAuthApiRouter.POST("/douyin/sync", adminHandler.SyncDouyinOrders()) - // 新增: 手动同步接口 (优化版) adminAuthApiRouter.POST("/douyin/sync-all", adminHandler.ManualSyncAll()) adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund()) adminAuthApiRouter.POST("/douyin/grant-prizes", adminHandler.ManualGrantPrizes()) adminAuthApiRouter.POST("/douyin/orders/:shop_order_id/grant-reward", adminHandler.GrantOrderReward()) - // 抖店商品奖励规则 adminAuthApiRouter.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards()) adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward()) adminAuthApiRouter.PUT("/douyin/product-rewards/:id", adminHandler.UpdateDouyinProductReward()) adminAuthApiRouter.DELETE("/douyin/product-rewards/:id", adminHandler.DeleteDouyinProductReward()) - // 直播间活动管理 adminAuthApiRouter.POST("/livestream/activities", adminHandler.CreateLivestreamActivity()) adminAuthApiRouter.GET("/livestream/activities", adminHandler.ListLivestreamActivities()) adminAuthApiRouter.GET("/livestream/activities/:id", adminHandler.GetLivestreamActivity()) @@ -264,34 +244,30 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/livestream/activities/:id/commitment/generate", adminHandler.GenerateLivestreamCommitment()) adminAuthApiRouter.GET("/livestream/activities/:id/commitment/summary", adminHandler.GetLivestreamCommitmentSummary()) - // 抖音用户黑名单管理 adminAuthApiRouter.GET("/blacklist", adminHandler.ListBlacklist()) adminAuthApiRouter.POST("/blacklist", adminHandler.AddBlacklist()) adminAuthApiRouter.DELETE("/blacklist/:id", adminHandler.RemoveBlacklist()) adminAuthApiRouter.GET("/blacklist/check", adminHandler.CheckBlacklist()) adminAuthApiRouter.POST("/blacklist/batch", adminHandler.BatchAddBlacklist()) - // 系统配置KV adminAuthApiRouter.GET("/system/configs", adminHandler.ListSystemConfigs()) adminAuthApiRouter.POST("/system/configs", adminHandler.UpsertSystemConfig()) adminAuthApiRouter.PUT("/system/configs/:id", adminHandler.ModifySystemConfig()) adminAuthApiRouter.DELETE("/system/configs/:id", adminHandler.DeleteSystemConfig()) - - // 奖品发放活动 - adminAuthApiRouter.POST("/prize-grant-activities", adminHandler.CreatePrizeGrantActivity()) - adminAuthApiRouter.GET("/prize-grant-activities", adminHandler.ListPrizeGrantActivities()) - adminAuthApiRouter.GET("/prize-grant-activities/cost-summary", adminHandler.GetPrizeGrantCostSummary()) - adminAuthApiRouter.GET("/prize-grant-activities/:id", adminHandler.GetPrizeGrantActivity()) - adminAuthApiRouter.PUT("/prize-grant-activities/:id", adminHandler.UpdatePrizeGrantActivity()) - adminAuthApiRouter.DELETE("/prize-grant-activities/:id", adminHandler.DeletePrizeGrantActivity()) - adminAuthApiRouter.GET("/prize-grant-activities/:id/user-records", adminHandler.ListPrizeGrantUserRecords()) - adminAuthApiRouter.DELETE("/prize-grant-activities/:id/user-records/:record_id", adminHandler.DeletePrizeGrantUserRecord()) - adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-processed", adminHandler.MarkPrizeGrantUsersProcessed()) - adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-all-processed", adminHandler.MarkAllPrizeGrantUsersProcessed()) -// 用户管理 + adminAuthApiRouter.POST("/prize-grant-activities", adminHandler.CreatePrizeGrantActivity()) + adminAuthApiRouter.GET("/prize-grant-activities", adminHandler.ListPrizeGrantActivities()) + adminAuthApiRouter.GET("/prize-grant-activities/cost-summary", adminHandler.GetPrizeGrantCostSummary()) + adminAuthApiRouter.GET("/prize-grant-activities/:id", adminHandler.GetPrizeGrantActivity()) + adminAuthApiRouter.PUT("/prize-grant-activities/:id", adminHandler.UpdatePrizeGrantActivity()) + adminAuthApiRouter.DELETE("/prize-grant-activities/:id", adminHandler.DeletePrizeGrantActivity()) + adminAuthApiRouter.GET("/prize-grant-activities/:id/user-records", adminHandler.ListPrizeGrantUserRecords()) + adminAuthApiRouter.DELETE("/prize-grant-activities/:id/user-records/:record_id", adminHandler.DeletePrizeGrantUserRecord()) + adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-processed", adminHandler.MarkPrizeGrantUsersProcessed()) + adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-all-processed", adminHandler.MarkAllPrizeGrantUsersProcessed()) + adminAuthApiRouter.GET("/users", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsers()) - adminAuthApiRouter.GET("/users/optimized", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsersOptimized()) // 优化版本(性能提升83%) + adminAuthApiRouter.GET("/users/optimized", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsersOptimized()) adminAuthApiRouter.GET("/users/:user_id/invites", intc.RequireAdminAction("user:view"), adminHandler.ListUserInvites()) adminAuthApiRouter.GET("/users/:user_id/orders", intc.RequireAdminAction("user:view"), adminHandler.ListUserOrders()) adminAuthApiRouter.GET("/users/:user_id/coupons", intc.RequireAdminAction("user:view"), adminHandler.ListUserCoupons()) @@ -315,7 +291,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.GET("/users/search", intc.RequireAdminAction("user:view"), adminHandler.AdminSearchUsers()) adminAuthApiRouter.DELETE("/users/:user_id", intc.RequireAdminAction("user:delete"), adminHandler.DeleteUser()) adminAuthApiRouter.GET("/users/:user_id/audit", intc.RequireAdminAction("user:view"), adminHandler.ListUserAuditLogs()) - adminAuthApiRouter.POST("/users/:user_id/token", intc.RequireAdminAction("user:token:issue"), adminHandler.IssueUserToken()) adminAuthApiRouter.POST("/users/batch/points/add", intc.RequireAdminAction("user:points:batch:add"), adminHandler.BatchAddUserPoints()) adminAuthApiRouter.POST("/users/batch/coupons/add", intc.RequireAdminAction("user:coupons:batch:add"), adminHandler.BatchAddUserCoupons()) @@ -324,8 +299,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/users/:user_id/inventory/:inventory_id/void", intc.RequireAdminAction("user:inventory:void"), adminHandler.VoidUserInventory()) adminAuthApiRouter.GET("/users/:user_id/item_cards", adminHandler.ListUserItemCards()) adminAuthApiRouter.POST("/users/:user_id/item_cards/:user_item_card_id/void", adminHandler.VoidUserItemCard()) - // 已移除:为指定用户签发APP令牌接口(未被前端使用) - // 系统称号与分配 + adminAuthApiRouter.GET("/system_titles", intc.RequireAdminAction("title:view"), adminHandler.ListSystemTitles()) adminAuthApiRouter.POST("/users/:user_id/titles", intc.RequireAdminAction("title:assign"), adminHandler.AssignUserTitle()) adminAuthApiRouter.POST("/system_titles", intc.RequireAdminAction("title:create"), adminHandler.CreateSystemTitle()) @@ -336,73 +310,52 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.PUT("/system_titles/:title_id/effects/:effect_id", intc.RequireAdminAction("title:effect:modify"), adminHandler.ModifySystemTitleEffect()) adminAuthApiRouter.DELETE("/system_titles/:title_id/effects/:effect_id", intc.RequireAdminAction("title:effect:delete"), adminHandler.DeleteSystemTitleEffect()) - // 小程序二维码生成 adminAuthApiRouter.POST("/miniapp/qrcode", adminHandler.GenerateMiniAppQRCode()) - - // 小程序发货信息管理(虚拟发货) adminAuthApiRouter.POST("/miniapp/shipping/set_jump_path", adminHandler.SetMiniAppMsgJumpPath()) adminAuthApiRouter.POST("/miniapp/shipping/upload_virtual", adminHandler.UploadVirtualShippingForTransaction()) - // 道具卡管理 adminAuthApiRouter.POST("/system_item_cards", intc.RequireAdminAction("itemcard:create"), adminHandler.CreateSystemItemCard()) adminAuthApiRouter.PUT("/system_item_cards/:item_card_id", intc.RequireAdminAction("itemcard:modify"), adminHandler.ModifySystemItemCard()) adminAuthApiRouter.DELETE("/system_item_cards/:item_card_id", intc.RequireAdminAction("itemcard:delete"), adminHandler.DeleteSystemItemCard()) adminAuthApiRouter.GET("/system_item_cards", intc.RequireAdminAction("itemcard:view"), adminHandler.ListSystemItemCards()) - // 优惠券管理 adminAuthApiRouter.POST("/system_coupons", intc.RequireAdminAction("coupon:create"), adminHandler.CreateSystemCoupon()) adminAuthApiRouter.PUT("/system_coupons/:coupon_id", intc.RequireAdminAction("coupon:modify"), adminHandler.ModifySystemCoupon()) adminAuthApiRouter.DELETE("/system_coupons/:coupon_id", intc.RequireAdminAction("coupon:delete"), adminHandler.DeleteSystemCoupon()) adminAuthApiRouter.GET("/system_coupons", intc.RequireAdminAction("coupon:view"), adminHandler.ListSystemCoupons()) adminAuthApiRouter.POST("/users/:user_id/item_cards", adminHandler.AssignUserItemCard()) - // 次数卡管理 adminAuthApiRouter.POST("/game-passes/grant", adminHandler.GrantGamePass()) adminAuthApiRouter.GET("/game-passes/list", adminHandler.ListGamePasses()) adminAuthApiRouter.GET("/users/:user_id/game-passes", adminHandler.GetUserGamePasses()) adminAuthApiRouter.GET("/activities/:activity_id/game-passes/check", adminHandler.CheckActivityGamePasses()) - - // 次数卡套餐管理 adminAuthApiRouter.GET("/game-pass-packages", adminHandler.ListGamePassPackages()) adminAuthApiRouter.POST("/game-pass-packages", adminHandler.CreateGamePassPackage()) adminAuthApiRouter.PUT("/game-pass-packages/:package_id", adminHandler.ModifyGamePassPackage()) adminAuthApiRouter.DELETE("/game-pass-packages/:package_id", adminHandler.DeleteGamePassPackage()) - - // 对对碰卡牌类型管理 adminAuthApiRouter.GET("/matching_card_types", adminHandler.ListMatchingCardTypes()) adminAuthApiRouter.POST("/matching_card_types", adminHandler.CreateMatchingCardType()) adminAuthApiRouter.PUT("/matching_card_types/:id", adminHandler.ModifyMatchingCardType()) adminAuthApiRouter.DELETE("/matching_card_types/:id", adminHandler.DeleteMatchingCardType()) adminAuthApiRouter.GET("/matching/audit/:order_no", adminHandler.GetMatchingAudit()) - - // 游戏资格管理 adminAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.ListUserTickets()) adminAuthApiRouter.POST("/users/:user_id/game_tickets", gameHandler.GrantUserTicket()) - - // 扫雷排行榜 & 对战记录 adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard()) adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords()) - - // 发货统计 adminAuthApiRouter.GET("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:view"), adminHandler.ListShippingStats()) adminAuthApiRouter.GET("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:view"), adminHandler.GetShippingStat()) adminAuthApiRouter.POST("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:write"), adminHandler.CreateShippingStat()) adminAuthApiRouter.PUT("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:write"), adminHandler.ModifyShippingStat()) adminAuthApiRouter.DELETE("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:write"), adminHandler.DeleteShippingStat()) - - // 发货订单管理 adminAuthApiRouter.GET("/shipping/orders", intc.RequireAdminAction("shipping:view"), adminHandler.ListShippingOrders()) adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail()) adminAuthApiRouter.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch()) adminAuthApiRouter.POST("/shipping/orders/cancel", intc.RequireAdminAction("shipping:modify"), adminHandler.AdminCancelShipping()) - - // 碎片合成配方管理 adminAuthApiRouter.GET("/synthesis/recipes", adminHandler.ListSynthesisRecipes()) adminAuthApiRouter.GET("/synthesis/recipes/:id", adminHandler.GetSynthesisRecipe()) adminAuthApiRouter.POST("/synthesis/recipes", adminHandler.CreateSynthesisRecipe()) adminAuthApiRouter.PUT("/synthesis/recipes/:id", adminHandler.ModifySynthesisRecipe()) adminAuthApiRouter.DELETE("/synthesis/recipes/:id", adminHandler.DeleteSynthesisRecipe()) adminAuthApiRouter.GET("/synthesis/logs", adminHandler.ListSynthesisLogs()) - adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund()) adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds()) adminAuthApiRouter.GET("/pay/refunds/:refund_no", intc.RequireAdminAction("refund:view"), adminHandler.GetRefundDetail()) @@ -411,7 +364,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/lottery/issues/:issue_id/simulate", adminHandler.SimulateIssue()) adminAuthApiRouter.GET("/ichiban/activities/:activity_id/issues/:issue_id/slots", adminHandler.ListIchibanSlots()) adminAuthApiRouter.GET("/ichiban/issues/:issue_id/slot/:slot_index", adminHandler.GetIchibanSlotDetail()) - adminAuthApiRouter.POST("/activities/:activity_id/commitment/generate", adminHandler.GenerateActivityCommitmentGeneral()) adminAuthApiRouter.GET("/activities/:activity_id/commitment/summary", adminHandler.GetActivityCommitmentSummaryGeneral()) adminAuthApiRouter.GET("/activities/:activity_id/credential", adminHandler.GetActivityCredential()) @@ -424,18 +376,14 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.PUT("/pay/orders/:order_no/consume", intc.RequireAdminAction("order:consume"), adminHandler.ConsumeOrder()) adminAuthApiRouter.POST("/pay/orders/:order_no/miniapp_shipping", intc.RequireAdminAction("order:miniapp_shipping"), adminHandler.UploadMiniAppVirtualShippingForOrder()) adminAuthApiRouter.GET("/pay/orders/export", intc.RequireAdminAction("order:export"), adminHandler.ExportPayOrders()) - // 订单审计快照 adminAuthApiRouter.GET("/orders/:order_id/snapshots", intc.RequireAdminAction("order:view"), adminHandler.GetOrderSnapshots()) adminAuthApiRouter.POST("/orders/:order_id/rollback", intc.RequireAdminAction("order:rollback"), adminHandler.RollbackOrder()) - // 通用上传 systemApiRouter.POST("/common/upload/wangeditor", commonHandler.UploadWangEditorImage()) systemApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu()) systemApiRouter.POST("/menu/ensure_activity_profit_loss", adminHandler.EnsureActivityProfitLossMenu()) } - // 系统管理:用户/角色/菜单 { - systemApiRouter.GET("/user/list", adminHandler.ListUsers()) systemApiRouter.POST("/user", adminHandler.CreateUser()) systemApiRouter.GET("/role/list", adminHandler.ListRoles()) @@ -459,7 +407,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er systemApiRouter.DELETE("/system/recycle", adminHandler.ForceDeleteRecycle()) } - // APP 端公开接口路由组 appPublicApiRouter := mux.Group("/api/app") { appPublicApiRouter.GET("/activities", activityHandler.ListActivities()) @@ -472,33 +419,26 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appPublicApiRouter.GET("/welfare-activities/:id", activityHandler.GetWelfareActivity()) appPublicApiRouter.GET("/welfare-activities/:id/participants", activityHandler.ListWelfareParticipants()) appPublicApiRouter.GET("/welfare-activities/:id/winners", activityHandler.ListWelfareWinners()) - - // APP 端轮播图 + appPublicApiRouter.GET("/threshold-activities", activityHandler.ListThresholdActivities()) + appPublicApiRouter.GET("/threshold-activities/:id", activityHandler.GetThresholdActivity()) + appPublicApiRouter.GET("/threshold-activities/:id/participants", activityHandler.ListThresholdParticipants()) + appPublicApiRouter.GET("/threshold-activities/:id/winners", activityHandler.ListThresholdWinners()) appPublicApiRouter.GET("/banners", appapi.NewBanner(logger, db).ListBannersForApp()) appPublicApiRouter.GET("/notices", appapi.NewNotice(logger, db).ListNoticesForApp()) appPublicApiRouter.GET("/categories", appapi.NewCategory(logger, db).ListCategoriesForApp()) - - // 登录保持公开 appPublicApiRouter.POST("/users/weixin/login", userHandler.WeixinLogin()) appPublicApiRouter.POST("/users/douyin/login", userHandler.DouyinLogin()) appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare()) appPublicApiRouter.GET("/matching/card_types", activityHandler.ListMatchingCardTypes()) - - // 短信登录 appPublicApiRouter.POST("/sms/send-code", userHandler.SendSmsCode()) appPublicApiRouter.POST("/sms/login", userHandler.SmsLogin()) - - // 公共工具 appPublicApiRouter.POST("/common/openid", commonHandler.GetOpenID()) appPublicApiRouter.GET("/config/public", commonHandler.GetPublicConfig()) - - // 商城浏览(无需登录) appPublicApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp()) appPublicApiRouter.GET("/product_categories", appapi.NewProductCategory(logger, db).ListProductCategoriesForApp()) appPublicApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp()) } - // 公开接口路由组 (无需登录) publicApiRouter := mux.Group("/api/public") { publicHandler := publicapi.New(logger, db, douyinSvc) @@ -509,12 +449,11 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er publicApiRouter.GET("/livestream/:access_code/pending-orders", publicHandler.GetLivestreamPendingOrders()) } - // APP 端认证接口路由组 appAuthApiRouter := mux.Group("/api/app", core.WrapAuthHandler(intc.AppTokenAuthVerify)) { appAuthApiRouter.PUT("/users/:user_id", userHandler.ModifyUser()) appAuthApiRouter.GET("/users/profile", userHandler.GetUserProfile()) - appAuthApiRouter.GET("/users/info", userHandler.GetUserProfile()) // 别名,保持前端兼容性 + appAuthApiRouter.GET("/users/info", userHandler.GetUserProfile()) appAuthApiRouter.GET("/users/:user_id/orders", userHandler.ListUserOrders()) appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons()) appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats()) @@ -538,18 +477,15 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.PUT("/users/:user_id/addresses/:address_id/default", userHandler.SetDefaultUserAddress()) appAuthApiRouter.PUT("/users/:user_id/addresses/:address_id", userHandler.UpdateUserAddress()) appAuthApiRouter.DELETE("/users/:user_id/addresses/:address_id", userHandler.DeleteUserAddress()) - appAuthApiRouter.GET("/welfare-activities/:id/my", activityHandler.GetWelfareActivity()) appAuthApiRouter.POST("/welfare-activities/:id/join", activityHandler.JoinWelfareActivity()) - - - appAuthApiRouter.GET("/prize-grant-activities/pending", activityHandler.GetPendingPrizeGrantActivity()) - appAuthApiRouter.POST("/prize-grant-activities/:id/claim", activityHandler.ClaimPrizeGrantActivity()) -// 任务中心 APP 端 + appAuthApiRouter.GET("/threshold-activities/:id/my", activityHandler.GetThresholdActivity()) + appAuthApiRouter.POST("/threshold-activities/:id/join", activityHandler.JoinThresholdActivity()) + appAuthApiRouter.GET("/prize-grant-activities/pending", activityHandler.GetPendingPrizeGrantActivity()) + appAuthApiRouter.POST("/prize-grant-activities/:id/claim", activityHandler.ClaimPrizeGrantActivity()) appAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForApp()) appAuthApiRouter.GET("/task-center/tasks/:id/progress/:user_id", taskCenterHandler.GetTaskProgressForApp()) appAuthApiRouter.POST("/task-center/tasks/:id/claim/:user_id", taskCenterHandler.ClaimTaskTierForApp()) - appAuthApiRouter.GET("/activities/:activity_id/issues/:issue_id/choices", activityHandler.ListIssueChoices()) appAuthApiRouter.POST("/orders/test/create", userHandler.CreateTestOrder()) appAuthApiRouter.GET("/orders/:order_id", userHandler.GetOrderDetail()) @@ -557,25 +493,16 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp()) appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder()) - // 需要黑名单检查的抽奖接口组 lotteryGroup := appAuthApiRouter.Group("", intc.CheckBlacklist()) { - lotteryGroup.POST("/pay/wechat/jsapi/preorder", userHandler.WechatJSAPIPreorder()) // 支付前也检查 + lotteryGroup.POST("/pay/wechat/jsapi/preorder", userHandler.WechatJSAPIPreorder()) lotteryGroup.POST("/lottery/join", activityHandler.JoinLottery()) - - // 对对碰游戏 lotteryGroup.POST("/matching/preorder", activityHandler.PreOrderMatchingGame()) - - // 扫雷游戏 lotteryGroup.POST("/games/enter", gameHandler.EnterGame()) lotteryGroup.GET("/games/leaderboard", gameHandler.GetLeaderboard()) - - // 积分兑换操作也应该检查黑名单 lotteryGroup.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon()) lotteryGroup.POST("/users/:user_id/points/redeem-product", userHandler.RedeemPointsToProduct()) lotteryGroup.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard()) - - // 资产操作(发货/回收) lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/check", userHandler.ShippingFeeCheck()) lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/preorder", userHandler.ShippingFeePreorder()) lotteryGroup.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch()) @@ -583,30 +510,27 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er lotteryGroup.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints()) } - // 碎片合成 appAuthApiRouter.GET("/users/:user_id/synthesis/recipes", userHandler.ListSynthesisRecipesForUser()) appAuthApiRouter.POST("/users/:user_id/synthesis/do", userHandler.DoSynthesis()) appAuthApiRouter.POST("/users/:user_id/synthesis/do-batch", userHandler.DoBatchSynthesis()) appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser()) - - // 对对碰其他接口(不需要严查黑名单,或者已在preorder查过) appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame()) appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState()) appAuthApiRouter.GET("/matching/cards", activityHandler.GetMatchingGameCards()) - - // 次数卡 appAuthApiRouter.GET("/game-passes/available", userHandler.GetGamePasses()) appAuthApiRouter.GET("/game-passes/packages", userHandler.GetGamePassPackages()) - appAuthApiRouter.POST("/game-passes/purchase", userHandler.PurchaseGamePassPackage()) // 购买次数卡是否要查? - - // 扫雷游戏其他接口 + appAuthApiRouter.POST("/game-passes/purchase", userHandler.PurchaseGamePassPackage()) appAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.GetMyTickets()) - appAuthApiRouter.POST("/users/:user_id/inventory/address-share/create", userHandler.CreateAddressShare()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare()) - } - // 微信支付平台回调(无需鉴权) - mux.Group("/api/pay").POST("/wechat/notify", payHandler.WechatNotify()) - return mux, cancel, nil + + payNotifyRouter := mux.Group("/api/pay") + { + payNotifyRouter.POST("/wechat/notify", payHandler.WechatNotify()) + } + + return mux, func() { + cancel() + }, nil } diff --git a/internal/service/threshold_activity/activity.go b/internal/service/threshold_activity/activity.go new file mode 100644 index 0000000..6f81b02 --- /dev/null +++ b/internal/service/threshold_activity/activity.go @@ -0,0 +1,412 @@ +package threshold_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, + QualificationMode: normalizeQualificationMode(req.QualificationMode), + SpendThresholdAmount: req.SpendThresholdAmount, + InviteThresholdCount: req.InviteThresholdCount, + InviteEffectiveAmount: req.InviteEffectiveAmount, + MinParticipants: normalizeMinParticipants(req.MinParticipants), + StartTime: req.StartTime, + EndTime: req.EndTime, + DrawTime: req.DrawTime, + Status: normalizeStatus(req.Status), + Description: req.Description, + CoverImage: req.CoverImage, + } + if 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) + }); err != nil { + return nil, err + } + return item, nil +} + +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, + "qualification_mode": normalizeQualificationMode(req.QualificationMode), + "spend_threshold_amount": req.SpendThresholdAmount, + "invite_threshold_count": req.InviteThresholdCount, + "invite_effective_amount": req.InviteEffectiveAmount, + "min_participants": normalizeMinParticipants(req.MinParticipants), + "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 || current.Status == StatusAborted { + 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, + QualificationMode: req.QualificationMode, + SpendThresholdAmount: req.SpendThresholdAmount, + InviteThresholdCount: req.InviteThresholdCount, + InviteEffectiveAmount: req.InviteEffectiveAmount, + MinParticipants: req.MinParticipants, + StartTime: req.StartTime, + EndTime: req.EndTime, + DrawTime: req.DrawTime, + Status: normalizeStatus(req.Status), + CoverImage: src.CoverImage, + Description: src.Description, + } + 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 + } + now := time.Now() + 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 req.Status == StatusActive { + db = db.Where("start_time <= ? AND end_time >= ? AND draw_time >= ?", now, now, now) + } + } + 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("threshold_activity_participants").Where("activity_id = ?", row.ID).Count(&item.ParticipantCount) + s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_winners").Where("activity_id = ?", row.ID).Count(&item.WinnerCount) + s.repo.GetDbR().WithContext(ctx).Table("threshold_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 + } + if userID <= 0 && item.Status == StatusActive && time.Now().Before(item.StartTime) { + return nil, errors.New("活动未开始") + } + return s.buildActivityDetail(ctx, item, userID) +} + +func (s *service) GetActivityAdmin(ctx context.Context, id 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 + } + return s.buildActivityDetail(ctx, item, 0) +} + +func (s *service) buildActivityDetail(ctx context.Context, item Activity, userID int64) (*ActivityDetail, error) { + detail := &ActivityDetail{Activity: item} + s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", item.ID).Order("sort ASC, id ASC").Find(&detail.Prizes) + s.fillPrizeMeta(ctx, detail.Prizes) + participants, _ := s.ListParticipants(ctx, item.ID, 1, 20) + if participants != nil { + detail.ParticipantCount = participants.Total + detail.Participants = participants.List + } + winners, _ := s.ListWinners(ctx, item.ID, 1, 20) + if winners != nil { + detail.Winners = winners.List + } + if userID > 0 { + progress, period, err := s.evaluateQualification(ctx, item, userID, time.Now()) + if err != nil { + return nil, err + } + detail.QualificationProgress = progress + detail.CanJoin = isJoinWindowOpen(item, time.Now()) && qualificationSatisfied(item, progress) + var count int64 + s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", item.ID, userID, period).Count(&count) + detail.Joined = count > 0 + if detail.Joined { + detail.CanJoin = false + } + } + 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.SpendThresholdAmount < 0 || req.InviteThresholdCount < 0 || req.InviteEffectiveAmount < 0 { + return errors.New("门槛不能小于0") + } + if req.QualificationMode == QualificationModeSpendOnly && req.SpendThresholdAmount <= 0 { + return errors.New("消费门槛活动必须设置消费门槛") + } + if req.QualificationMode == QualificationModeInviteOnly && (req.InviteThresholdCount <= 0 || req.InviteEffectiveAmount <= 0) { + return errors.New("邀请门槛活动必须设置有效邀请人数和有效消费门槛") + } + if req.QualificationMode == QualificationModeEither && req.SpendThresholdAmount <= 0 && (req.InviteThresholdCount <= 0 || req.InviteEffectiveAmount <= 0) { + return errors.New("任一达标活动至少需要配置一条有效资格线") + } + 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 { + switch strings.TrimSpace(status) { + case StatusFinished: + return StatusFinished + case StatusAborted: + return StatusAborted + default: + return StatusActive + } +} + +func normalizeQualificationMode(mode string) string { + switch strings.TrimSpace(mode) { + case QualificationModeSpendOnly: + return QualificationModeSpendOnly + case QualificationModeInviteOnly: + return QualificationModeInviteOnly + default: + return QualificationModeEither + } +} + +func normalizeMinParticipants(v int64) int64 { + if v <= 0 { + return 1 + } + return v +} + +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/threshold_activity/draw.go b/internal/service/threshold_activity/draw.go new file mode 100644 index 0000000..37959ce --- /dev/null +++ b/internal/service/threshold_activity/draw.go @@ -0,0 +1,346 @@ +package threshold_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("threshold 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("TA%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("活动不允许开奖或已开奖") + } + + var finalStatus = StatusFinished + var abortReason string + err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var activity Activity + if err := tx.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", activityID).First(&activity).Error; err != nil { + return err + } + var participants []Participant + if err := tx.Where("activity_id = ?", activityID).Find(&participants).Error; err != nil { + return err + } + if int64(len(participants)) < activity.MinParticipants { + finalStatus = StatusAborted + abortReason = "min_participants_not_met" + 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 + } + updates := map[string]interface{}{ + "status": finalStatus, + "draw_batch": batch, + } + if finalStatus == StatusAborted { + now := time.Now() + updates["abort_reason"] = abortReason + updates["aborted_at"] = now + } + if err := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ?", activityID).Updates(updates).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("TA%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/threshold_activity/draw_test.go b/internal/service/threshold_activity/draw_test.go new file mode 100644 index 0000000..b8956f8 --- /dev/null +++ b/internal/service/threshold_activity/draw_test.go @@ -0,0 +1,151 @@ +package threshold_activity + +import ( + "context" + "testing" + "time" +) + +func TestDraw_AbortWhenParticipantsBelowMin(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + activity := Activity{ + ID: 11, + Title: "开奖流产", + Type: TypeDaily, + QualificationMode: QualificationModeSpendOnly, + MinParticipants: 2, + StartTime: now.Add(-2 * time.Hour), + EndTime: now.Add(2 * time.Hour), + DrawTime: now.Add(-time.Minute), + Status: StatusActive, + } + mustInsertActivity(t, db, activity) + mustExec(t, db, `INSERT INTO threshold_activity_participants (activity_id, user_id, period_key, qualification_source, paid_amount_snapshot, effective_invite_count_snapshot, created_at) VALUES (11, 101, '2026-06-09', 'spend', 1000, 0, ?)`, now) + + if err := svc.Draw(ctx, activity.ID); err != nil { + t.Fatalf("Draw failed: %v", err) + } + var updated Activity + if err := db.Where("id = ?", activity.ID).First(&updated).Error; err != nil { + t.Fatalf("query activity failed: %v", err) + } + if updated.Status != StatusAborted { + t.Fatalf("expected status aborted, got %s", updated.Status) + } + if updated.AbortReason != "min_participants_not_met" { + t.Fatalf("expected abort reason min_participants_not_met, got %s", updated.AbortReason) + } + var winnerCount int64 + if err := db.Table("threshold_activity_winners").Where("activity_id = ?", activity.ID).Count(&winnerCount).Error; err != nil { + t.Fatalf("count winners failed: %v", err) + } + if winnerCount != 0 { + t.Fatalf("expected no winners when aborted, got %d", winnerCount) + } +} + +func TestDraw_FinishWhenParticipantsReachMinCreatesWinner(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + activity := Activity{ + ID: 12, + Title: "正常开奖", + Type: TypeDaily, + QualificationMode: QualificationModeSpendOnly, + MinParticipants: 1, + StartTime: now.Add(-2 * time.Hour), + EndTime: now.Add(2 * time.Hour), + DrawTime: now.Add(-time.Minute), + Status: StatusActive, + } + mustInsertActivity(t, db, activity) + mustExec(t, db, `INSERT INTO threshold_activity_participants (activity_id, user_id, period_key, qualification_source, paid_amount_snapshot, effective_invite_count_snapshot, created_at) VALUES (12, 201, '2026-06-09', 'spend', 2000, 0, ?)`, now) + mustExec(t, db, `INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (501, '测试商品', 1999, 888, 5, '[]')`) + mustExec(t, db, `INSERT INTO threshold_activity_prizes (id, activity_id, reward_type, reward_ref_id, reward_name_snapshot, reward_image_snapshot, reward_value_snapshot_cents, cost_snapshot_cents, quantity, remaining_quantity, sort, created_at, updated_at) VALUES (601, 12, 'product', 501, '测试商品', '', 1999, 888, 1, 1, 1, ?, ?)`, now, now) + + if err := svc.Draw(ctx, activity.ID); err != nil { + t.Fatalf("Draw failed: %v", err) + } + var updated Activity + if err := db.Where("id = ?", activity.ID).First(&updated).Error; err != nil { + t.Fatalf("query activity failed: %v", err) + } + if updated.Status != StatusFinished { + t.Fatalf("expected status finished, got %s", updated.Status) + } + if updated.DrawBatch == "" { + t.Fatalf("expected draw batch to be set") + } + var winnerCount int64 + if err := db.Table("threshold_activity_winners").Where("activity_id = ?", activity.ID).Count(&winnerCount).Error; err != nil { + t.Fatalf("count winners failed: %v", err) + } + if winnerCount != 1 { + t.Fatalf("expected 1 winner, got %d", winnerCount) + } + var prize Prize + if err := db.Where("id = ?", 601).First(&prize).Error; err != nil { + t.Fatalf("query prize failed: %v", err) + } + if prize.RemainingQuantity != 0 { + t.Fatalf("expected remaining quantity 0, got %d", prize.RemainingQuantity) + } + var stock int64 + if err := db.Table("products").Select("stock").Where("id = ?", 501).Scan(&stock).Error; err != nil { + t.Fatalf("query stock failed: %v", err) + } + if stock != 4 { + t.Fatalf("expected product stock 4, got %d", stock) + } +} + +func TestDrawDueActivities_ProcessesOnlyDueActivities(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + due := Activity{ + ID: 21, + Title: "到期活动", + Type: TypeDaily, + QualificationMode: QualificationModeSpendOnly, + MinParticipants: 2, + StartTime: now.Add(-2 * time.Hour), + EndTime: now.Add(2 * time.Hour), + DrawTime: now.Add(-time.Minute), + Status: StatusActive, + } + future := Activity{ + ID: 22, + Title: "未到期活动", + Type: TypeDaily, + QualificationMode: QualificationModeSpendOnly, + MinParticipants: 1, + StartTime: now.Add(-2 * time.Hour), + EndTime: now.Add(2 * time.Hour), + DrawTime: now.Add(time.Hour), + Status: StatusActive, + } + mustInsertActivity(t, db, due) + mustInsertActivity(t, db, future) + mustExec(t, db, `INSERT INTO threshold_activity_participants (activity_id, user_id, period_key, qualification_source, paid_amount_snapshot, effective_invite_count_snapshot, created_at) VALUES (21, 301, '2026-06-09', 'spend', 1000, 0, ?)`, now) + + if err := svc.DrawDueActivities(ctx); err != nil { + t.Fatalf("DrawDueActivities failed: %v", err) + } + var dueUpdated, futureUpdated Activity + if err := db.Where("id = ?", 21).First(&dueUpdated).Error; err != nil { + t.Fatalf("query due activity failed: %v", err) + } + if err := db.Where("id = ?", 22).First(&futureUpdated).Error; err != nil { + t.Fatalf("query future activity failed: %v", err) + } + if dueUpdated.Status != StatusAborted { + t.Fatalf("expected due activity aborted, got %s", dueUpdated.Status) + } + if futureUpdated.Status != StatusActive { + t.Fatalf("expected future activity remain active, got %s", futureUpdated.Status) + } +} diff --git a/internal/service/threshold_activity/participant.go b/internal/service/threshold_activity/participant.go new file mode 100644 index 0000000..991d7eb --- /dev/null +++ b/internal/service/threshold_activity/participant.go @@ -0,0 +1,224 @@ +package threshold_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 !isJoinWindowOpen(activity, now) { + if now.Before(activity.StartTime) { + return errors.New("活动未开始") + } + return errors.New("当前不在活动参与时间内") + } + progress, period, err := s.evaluateQualification(ctx, activity, userID, now) + if err != nil { + return err + } + if !qualificationSatisfied(activity, progress) { + return errors.New(joinQualificationError(activity, progress)) + } + participant := &Participant{ + ActivityID: activityID, + UserID: userID, + PeriodKey: period, + QualificationSource: progress.QualificationSource, + PaidAmountSnapshot: progress.CurrentPaid, + EffectiveInviteCountSnapshot: progress.EffectiveInviteCount, + } + 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("threshold_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("threshold_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) 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 + } + items := make([]JoinableActivityItem, 0, len(activities)) + for _, activity := range activities { + progress, period, err := s.evaluateQualification(ctx, activity, userID, now) + if err != nil { + return nil, err + } + var count int64 + s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", activity.ID, userID, period).Count(&count) + joined := count > 0 + canJoin := !joined && qualificationSatisfied(activity, progress) + items = append(items, JoinableActivityItem{ + ActivityID: activity.ID, + Title: activity.Title, + Type: activity.Type, + QualificationMode: activity.QualificationMode, + SpendThresholdAmount: activity.SpendThresholdAmount, + InviteThresholdCount: activity.InviteThresholdCount, + InviteEffectiveAmount: activity.InviteEffectiveAmount, + MinParticipants: activity.MinParticipants, + QualificationProgress: progress, + CanJoin: canJoin, + Joined: joined, + StartTime: activity.StartTime, + EndTime: activity.EndTime, + DrawTime: activity.DrawTime, + }) + } + return items, nil +} + +func (s *service) evaluateQualification(ctx context.Context, activity Activity, userID int64, now time.Time) (QualificationProgress, string, error) { + start, end, period := periodRange(activity.Type, now) + paid, err := s.sumPaidAmount(ctx, userID, start, end) + if err != nil { + return QualificationProgress{}, "", err + } + inviteCount, err := s.countEffectiveInvites(ctx, userID, activity.StartTime, start, end, activity.InviteEffectiveAmount) + if err != nil { + return QualificationProgress{}, "", err + } + progress := QualificationProgress{ + CurrentPaid: paid, + EffectiveInviteCount: inviteCount, + SpendQualified: activity.SpendThresholdAmount > 0 && paid >= activity.SpendThresholdAmount, + InviteQualified: activity.InviteThresholdCount > 0 && inviteCount >= activity.InviteThresholdCount, + } + switch { + case progress.SpendQualified && progress.InviteQualified: + progress.QualificationSource = QualificationSourceBoth + case progress.SpendQualified: + progress.QualificationSource = QualificationSourceSpend + case progress.InviteQualified: + progress.QualificationSource = QualificationSourceInvite + } + return progress, period, nil +} + +func qualificationSatisfied(activity Activity, progress QualificationProgress) bool { + switch normalizeQualificationMode(activity.QualificationMode) { + case QualificationModeSpendOnly: + return progress.SpendQualified + case QualificationModeInviteOnly: + return progress.InviteQualified + default: + return progress.SpendQualified || progress.InviteQualified + } +} + +func joinQualificationError(activity Activity, progress QualificationProgress) string { + switch normalizeQualificationMode(activity.QualificationMode) { + case QualificationModeSpendOnly: + return fmt.Sprintf("未达到消费门槛,还差%d分", activity.SpendThresholdAmount-progress.CurrentPaid) + case QualificationModeInviteOnly: + return fmt.Sprintf("未达到有效邀请门槛,还差%d人", activity.InviteThresholdCount-progress.EffectiveInviteCount) + default: + if activity.SpendThresholdAmount > 0 && progress.CurrentPaid < activity.SpendThresholdAmount { + return fmt.Sprintf("未达到参与门槛:消费还差%d分,或邀请还差%d人", activity.SpendThresholdAmount-progress.CurrentPaid, maxInt64(0, activity.InviteThresholdCount-progress.EffectiveInviteCount)) + } + return fmt.Sprintf("未达到参与门槛:邀请还差%d人", maxInt64(0, activity.InviteThresholdCount-progress.EffectiveInviteCount)) + } +} + +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 (s *service) countEffectiveInvites(ctx context.Context, inviterID int64, activityStart time.Time, start time.Time, end time.Time, thresholdAmount int64) (int64, error) { + if thresholdAmount <= 0 { + return 0, nil + } + var count int64 + query := ` + SELECT COUNT(1) + FROM ( + SELECT ui.invitee_id + FROM user_invites ui + JOIN orders o ON o.user_id = ui.invitee_id + WHERE ui.inviter_id = ? + AND ui.created_at >= ? + AND o.status = 2 + AND o.actual_amount > 0 + AND COALESCE(NULLIF(o.paid_at, '1970-01-01 00:00:00'), o.created_at) >= ? + AND COALESCE(NULLIF(o.paid_at, '1970-01-01 00:00:00'), o.created_at) < ? + GROUP BY ui.invitee_id + HAVING COALESCE(SUM(o.actual_amount), 0) >= ? + ) t + ` + if err := s.repo.GetDbR().WithContext(ctx).Raw(query, inviterID, activityStart, start, end, thresholdAmount).Scan(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +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") + } +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/service/threshold_activity/participant_test.go b/internal/service/threshold_activity/participant_test.go new file mode 100644 index 0000000..ee4e229 --- /dev/null +++ b/internal/service/threshold_activity/participant_test.go @@ -0,0 +1,153 @@ +package threshold_activity + +import ( + "context" + "testing" + "time" +) + +func TestCountEffectiveInvites_OnlyCountsInvitesCreatedAfterActivityStart(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + activityStart := now.Add(-2 * time.Hour) + periodStart, periodEnd, _ := periodRange(TypeDaily, now) + + mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (1, 2, 'INV1', ?, ?)`, activityStart.Add(-time.Hour), activityStart.Add(-time.Hour)) + mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (1, 3, 'INV1', ?, ?)`, activityStart.Add(10*time.Minute), activityStart.Add(10*time.Minute)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (2, 'OLD_INV', 1, 1200, 1200, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (3, 'NEW_INV', 1, 1500, 1500, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + + count, err := svc.countEffectiveInvites(ctx, 1, activityStart, periodStart, periodEnd, 1000) + if err != nil { + t.Fatalf("countEffectiveInvites failed: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 effective invite after activity start, got %d", count) + } +} + +func TestJoin_SpendOnlyQualifiedWritesParticipant(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + activity := Activity{ + ID: 1, + Title: "消费门槛", + Type: TypeDaily, + QualificationMode: QualificationModeSpendOnly, + SpendThresholdAmount: 1000, + MinParticipants: 1, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(24 * time.Hour), + DrawTime: now.Add(25 * time.Hour), + Status: StatusActive, + } + mustInsertActivity(t, db, activity) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (10, 'SPEND_OK', 1, 1500, 1500, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + + if err := svc.Join(ctx, activity.ID, 10); err != nil { + t.Fatalf("Join failed: %v", err) + } + var participant Participant + if err := db.Where("activity_id = ? AND user_id = ?", activity.ID, 10).First(&participant).Error; err != nil { + t.Fatalf("query participant failed: %v", err) + } + if participant.QualificationSource != QualificationSourceSpend { + t.Fatalf("expected qualification source spend, got %s", participant.QualificationSource) + } + if participant.PaidAmountSnapshot != 1500 { + t.Fatalf("expected paid snapshot 1500, got %d", participant.PaidAmountSnapshot) + } +} + +func TestJoin_InviteOnlyQualifiedAndDuplicateRejected(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + activity := Activity{ + ID: 2, + Title: "邀请门槛", + Type: TypeDaily, + QualificationMode: QualificationModeInviteOnly, + InviteThresholdCount: 1, + InviteEffectiveAmount: 1000, + MinParticipants: 1, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(24 * time.Hour), + DrawTime: now.Add(25 * time.Hour), + Status: StatusActive, + } + mustInsertActivity(t, db, activity) + mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (20, 21, 'INV20', ?, ?)`, now, now) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (21, 'INVITE_OK', 1, 1200, 1200, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + + if err := svc.Join(ctx, activity.ID, 20); err != nil { + t.Fatalf("Join failed: %v", err) + } + var participant Participant + if err := db.Where("activity_id = ? AND user_id = ?", activity.ID, 20).First(&participant).Error; err != nil { + t.Fatalf("query participant failed: %v", err) + } + if participant.QualificationSource != QualificationSourceInvite { + t.Fatalf("expected qualification source invite, got %s", participant.QualificationSource) + } + if participant.EffectiveInviteCountSnapshot != 1 { + t.Fatalf("expected invite snapshot 1, got %d", participant.EffectiveInviteCountSnapshot) + } + if err := svc.Join(ctx, activity.ID, 20); err == nil { + t.Fatalf("expected duplicate join to fail") + } + var total int64 + if err := db.Model(&Participant{}).Where("activity_id = ? AND user_id = ?", activity.ID, 20).Count(&total).Error; err != nil { + t.Fatalf("count participant failed: %v", err) + } + if total != 1 { + t.Fatalf("expected only 1 participant row after duplicate join, got %d", total) + } +} + +func TestListJoinableActivitiesForUser_ReflectsJoinedState(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Now() + activity := Activity{ + ID: 3, + Title: "任一达标", + Type: TypeDaily, + QualificationMode: QualificationModeEither, + SpendThresholdAmount: 1000, + MinParticipants: 1, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(24 * time.Hour), + DrawTime: now.Add(25 * time.Hour), + Status: StatusActive, + } + mustInsertActivity(t, db, activity) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (30, 'EITHER_OK', 1, 1000, 1000, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + + items, err := svc.ListJoinableActivitiesForUser(ctx, 30) + if err != nil { + t.Fatalf("ListJoinableActivitiesForUser failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 activity, got %d", len(items)) + } + if !items[0].CanJoin || items[0].Joined { + t.Fatalf("expected activity to be joinable before join, got canJoin=%v joined=%v", items[0].CanJoin, items[0].Joined) + } + + if err := svc.Join(ctx, activity.ID, 30); err != nil { + t.Fatalf("Join failed: %v", err) + } + items, err = svc.ListJoinableActivitiesForUser(ctx, 30) + if err != nil { + t.Fatalf("ListJoinableActivitiesForUser after join failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 activity after join, got %d", len(items)) + } + if items[0].CanJoin || !items[0].Joined { + t.Fatalf("expected activity to be joined and not joinable, got canJoin=%v joined=%v", items[0].CanJoin, items[0].Joined) + } +} diff --git a/internal/service/threshold_activity/test_helper_test.go b/internal/service/threshold_activity/test_helper_test.go new file mode 100644 index 0000000..0833e4a --- /dev/null +++ b/internal/service/threshold_activity/test_helper_test.go @@ -0,0 +1,202 @@ +package threshold_activity + +import ( + "testing" + "time" + + "bindbox-game/internal/repository/mysql" + + "gorm.io/gorm" +) + +func newThresholdTestService(t *testing.T) (*service, mysql.Repo, *gorm.DB) { + t.Helper() + repo, err := mysql.NewSQLiteRepoForTest() + if err != nil { + t.Fatalf("create sqlite repo failed: %v", err) + } + db := repo.GetDbW() + initThresholdTestTables(t, db) + return &service{repo: repo}, repo, db +} + +func initThresholdTestTables(t *testing.T, db *gorm.DB) { + t.Helper() + stmts := []string{ + `CREATE TABLE threshold_activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + type TEXT NOT NULL, + qualification_mode TEXT NOT NULL, + spend_threshold_amount INTEGER NOT NULL DEFAULT 0, + invite_threshold_count INTEGER NOT NULL DEFAULT 0, + invite_effective_amount INTEGER NOT NULL DEFAULT 0, + min_participants INTEGER NOT NULL DEFAULT 1, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + draw_time DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + description TEXT, + cover_image TEXT, + draw_batch TEXT NOT NULL DEFAULT '', + abort_reason TEXT NOT NULL DEFAULT '', + aborted_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + );`, + `CREATE TABLE threshold_activity_prizes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_ref_id INTEGER NOT NULL, + reward_name_snapshot TEXT NOT NULL DEFAULT '', + reward_image_snapshot TEXT NOT NULL DEFAULT '', + reward_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, + cost_snapshot_cents INTEGER NOT NULL DEFAULT 0, + quantity INTEGER NOT NULL DEFAULT 0, + remaining_quantity INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE TABLE threshold_activity_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + period_key TEXT NOT NULL, + qualification_source TEXT NOT NULL DEFAULT '', + paid_amount_snapshot INTEGER NOT NULL DEFAULT 0, + effective_invite_count_snapshot INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(activity_id, user_id, period_key) + );`, + `CREATE TABLE threshold_activity_winners ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + prize_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_ref_id INTEGER NOT NULL, + prize_name_snapshot TEXT NOT NULL DEFAULT '', + prize_image_snapshot TEXT NOT NULL DEFAULT '', + prize_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NOT NULL, + grant_record_type TEXT NOT NULL DEFAULT '', + grant_record_id INTEGER NOT NULL DEFAULT 0, + cost_cents INTEGER NOT NULL DEFAULT 0, + draw_batch TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(activity_id, user_id) + );`, + `CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nickname TEXT, + avatar TEXT, + invite_code TEXT, + inviter_id INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + );`, + `CREATE TABLE user_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inviter_id INTEGER NOT NULL, + invitee_id INTEGER NOT NULL, + invite_code TEXT NOT NULL DEFAULT '', + reward_points INTEGER NOT NULL DEFAULT 0, + rewarded_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + is_effective BOOLEAN NOT NULL DEFAULT 0, + accumulated_amount INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER NOT NULL, + order_no TEXT NOT NULL, + source_type INTEGER NOT NULL DEFAULT 1, + total_amount INTEGER NOT NULL DEFAULT 0, + discount_amount INTEGER NOT NULL DEFAULT 0, + points_amount INTEGER NOT NULL DEFAULT 0, + actual_amount INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + pay_preorder_id INTEGER NOT NULL DEFAULT 0, + paid_at DATETIME, + cancelled_at DATETIME, + user_address_id INTEGER NOT NULL DEFAULT 0, + is_consumed INTEGER NOT NULL DEFAULT 0, + points_ledger_id INTEGER NOT NULL DEFAULT 0, + coupon_id INTEGER NOT NULL DEFAULT 0, + item_card_id INTEGER NOT NULL DEFAULT 0, + remark TEXT, + ext_order_id TEXT NOT NULL DEFAULT '' + );`, + `CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + price INTEGER NOT NULL, + cost_price INTEGER NOT NULL DEFAULT 0, + stock INTEGER NOT NULL, + images_json TEXT, + updated_at DATETIME, + deleted_at DATETIME + );`, + `CREATE TABLE order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + order_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + title TEXT, + quantity INTEGER NOT NULL DEFAULT 1, + price INTEGER NOT NULL DEFAULT 0, + total_amount INTEGER NOT NULL DEFAULT 0, + product_images TEXT, + status INTEGER NOT NULL DEFAULT 1 + );`, + `CREATE TABLE user_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + value_cents INTEGER NOT NULL DEFAULT 0, + value_source INTEGER NOT NULL DEFAULT 0, + value_snapshot_at DATETIME, + order_id INTEGER NOT NULL DEFAULT 0, + activity_id INTEGER NOT NULL DEFAULT 0, + reward_id INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + shipping_no TEXT NOT NULL DEFAULT '', + remark TEXT + );`, + } + for _, stmt := range stmts { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("create table failed: %v", err) + } + } +} + +func mustExec(t *testing.T, db *gorm.DB, sql string, args ...any) { + t.Helper() + if err := db.Exec(sql, args...).Error; err != nil { + t.Fatalf("exec failed: %v, sql=%s", err, sql) + } +} + +func mustInsertActivity(t *testing.T, db *gorm.DB, activity Activity) { + t.Helper() + if activity.CreatedAt.IsZero() { + activity.CreatedAt = time.Now() + } + if activity.UpdatedAt.IsZero() { + activity.UpdatedAt = activity.CreatedAt + } + if err := db.Create(&activity).Error; err != nil { + t.Fatalf("insert activity failed: %v", err) + } +} diff --git a/internal/service/threshold_activity/threshold_activity.go b/internal/service/threshold_activity/threshold_activity.go new file mode 100644 index 0000000..736ec6b --- /dev/null +++ b/internal/service/threshold_activity/threshold_activity.go @@ -0,0 +1,52 @@ +package threshold_activity + +import ( + "context" + "time" + + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + usersvc "bindbox-game/internal/service/user" +) + +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) + GetActivityAdmin(ctx context.Context, id 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)} +} + +func isJoinWindowOpen(activity Activity, now time.Time) bool { + if activity.Status != StatusActive { + return false + } + if now.Before(activity.StartTime) { + return false + } + if now.After(activity.EndTime) || now.After(activity.DrawTime) { + return false + } + return true +} diff --git a/internal/service/threshold_activity/types.go b/internal/service/threshold_activity/types.go new file mode 100644 index 0000000..fd5a628 --- /dev/null +++ b/internal/service/threshold_activity/types.go @@ -0,0 +1,233 @@ +package threshold_activity + +import "time" + +const ( + RewardTypeProduct = "product" + RewardTypeItemCard = "item_card" + RewardTypeCoupon = "coupon" + + GrantRecordTypeInventory = "inventory" + GrantRecordTypeItemCard = "user_item_card" + GrantRecordTypeCoupon = "user_coupon" + + TypeDaily = "daily" + TypeWeekly = "weekly" + TypeMonthly = "monthly" + + StatusActive = "active" + StatusFinished = "finished" + StatusAborted = "aborted" + + QualificationModeSpendOnly = "spend_only" + QualificationModeInviteOnly = "invite_only" + QualificationModeEither = "either" + + QualificationSourceSpend = "spend" + QualificationSourceInvite = "invite" + QualificationSourceBoth = "both" +) + +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"` + QualificationMode string `gorm:"column:qualification_mode" json:"qualification_mode"` + SpendThresholdAmount int64 `gorm:"column:spend_threshold_amount" json:"spend_threshold_amount"` + InviteThresholdCount int64 `gorm:"column:invite_threshold_count" json:"invite_threshold_count"` + InviteEffectiveAmount int64 `gorm:"column:invite_effective_amount" json:"invite_effective_amount"` + MinParticipants int64 `gorm:"column:min_participants" json:"min_participants"` + 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"` + AbortReason string `gorm:"column:abort_reason" json:"abort_reason"` + AbortedAt *time.Time `gorm:"column:aborted_at" json:"aborted_at,omitempty"` + 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 "threshold_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 "threshold_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"` + QualificationSource string `gorm:"column:qualification_source" json:"qualification_source"` + PaidAmountSnapshot int64 `gorm:"column:paid_amount_snapshot" json:"paid_amount_snapshot"` + EffectiveInviteCountSnapshot int64 `gorm:"column:effective_invite_count_snapshot" json:"effective_invite_count_snapshot"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` +} + +func (*Participant) TableName() string { return "threshold_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 "threshold_activity_winners" } + +type SaveActivityRequest struct { + Title string `json:"title"` + Type string `json:"type"` + QualificationMode string `json:"qualification_mode"` + SpendThresholdAmount int64 `json:"spend_threshold_amount"` + InviteThresholdCount int64 `json:"invite_threshold_count"` + InviteEffectiveAmount int64 `json:"invite_effective_amount"` + MinParticipants int64 `json:"min_participants"` + 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 QualificationProgress struct { + CurrentPaid int64 `json:"current_paid"` + EffectiveInviteCount int64 `json:"effective_invite_count"` + SpendQualified bool `json:"spend_qualified"` + InviteQualified bool `json:"invite_qualified"` + QualificationSource string `json:"qualification_source"` +} + +type ActivityDetail struct { + Activity + Prizes []Prize `json:"prizes"` + QualificationProgress QualificationProgress `json:"qualification_progress"` + 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"` + QualificationMode string `json:"qualification_mode"` + SpendThresholdAmount int64 `json:"spend_threshold_amount"` + InviteThresholdCount int64 `json:"invite_threshold_count"` + InviteEffectiveAmount int64 `json:"invite_effective_amount"` + MinParticipants int64 `json:"min_participants"` + QualificationProgress QualificationProgress `json:"qualification_progress"` + 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/threshold_activity/winner.go b/internal/service/threshold_activity/winner.go new file mode 100644 index 0000000..7545886 --- /dev/null +++ b/internal/service/threshold_activity/winner.go @@ -0,0 +1,57 @@ +package threshold_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 + } + b := s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_winners w").Where("w.activity_id = ?", activityID) + var total int64 + if err := b.Count(&total).Error; err != nil { + return nil, err + } + var list []WinnerItem + err := s.repo.GetDbR().WithContext(ctx).Table("threshold_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("threshold_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("threshold_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/main.go b/main.go index 8a5de3a..5310076 100755 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( douyinsvc "bindbox-game/internal/service/douyin" gamesvc "bindbox-game/internal/service/game" syscfgsvc "bindbox-game/internal/service/sysconfig" + thresholdsvc "bindbox-game/internal/service/threshold_activity" titlesvc "bindbox-game/internal/service/title" usersvc "bindbox-game/internal/service/user" welfaresvc "bindbox-game/internal/service/welfare_activity" @@ -101,6 +102,7 @@ func main() { activitysvc.StartScheduledSettlement(customLogger, dbRepo, redis.GetClient()) welfaresvc.StartScheduledDraw(customLogger, dbRepo) + thresholdsvc.StartScheduledDraw(customLogger, dbRepo) usersvc.StartExpirationCheck(customLogger, dbRepo) usersvc.StartAutoCancelWorker(customLogger, dbRepo) diff --git a/migrations/20260609_threshold_activities.sql b/migrations/20260609_threshold_activities.sql new file mode 100644 index 0000000..bffde81 --- /dev/null +++ b/migrations/20260609_threshold_activities.sql @@ -0,0 +1,83 @@ +CREATE TABLE IF NOT EXISTS `threshold_activities` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `title` VARCHAR(128) NOT NULL COMMENT '活动标题', + `type` VARCHAR(16) NOT NULL COMMENT '活动类型: daily/weekly/monthly', + `qualification_mode` VARCHAR(16) NOT NULL DEFAULT 'either' COMMENT '资格模式: spend_only/invite_only/either', + `spend_threshold_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '消费门槛金额(分)', + `invite_threshold_count` BIGINT NOT NULL DEFAULT 0 COMMENT '有效邀请人数门槛', + `invite_effective_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '有效邀请用户消费门槛(分)', + `min_participants` BIGINT NOT NULL DEFAULT 1 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/aborted', + `description` TEXT NULL COMMENT '活动说明', + `cover_image` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面图', + `draw_batch` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '开奖批次', + `abort_reason` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '流产原因', + `aborted_at` DATETIME(3) NULL 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_threshold_activities_type_status` (`type`, `status`), + KEY `idx_threshold_activities_draw_time` (`draw_time`), + KEY `idx_threshold_activities_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门槛活动'; + +CREATE TABLE IF NOT EXISTS `threshold_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_threshold_prizes_activity` (`activity_id`), + KEY `idx_threshold_prizes_reward` (`reward_type`, `reward_ref_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门槛活动奖品配置'; + +CREATE TABLE IF NOT EXISTS `threshold_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 '参与周期标识', + `qualification_source` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '资格来源: spend/invite/both', + `paid_amount_snapshot` BIGINT NOT NULL DEFAULT 0 COMMENT '参与时周期消费快照(分)', + `effective_invite_count_snapshot` BIGINT NOT NULL DEFAULT 0 COMMENT '参与时有效邀请人数快照', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_threshold_participant` (`activity_id`, `user_id`, `period_key`), + KEY `idx_threshold_participants_activity` (`activity_id`, `created_at`), + KEY `idx_threshold_participants_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门槛活动参与记录'; + +CREATE TABLE IF NOT EXISTS `threshold_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_threshold_winner_user` (`activity_id`, `user_id`), + KEY `idx_threshold_winners_activity` (`activity_id`, `created_at`), + KEY `idx_threshold_winners_user` (`user_id`), + KEY `idx_threshold_winners_reward` (`reward_type`, `reward_ref_id`), + KEY `idx_threshold_winners_grant` (`grant_record_type`, `grant_record_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门槛活动中奖记录';