feat: 新增独立门槛活动模块与测试

This commit is contained in:
Zuncle 2026-06-09 21:42:49 +08:00
parent 8aa8ff7467
commit f8c4e17ccc
18 changed files with 2853 additions and 125 deletions

View File

@ -7,6 +7,7 @@ import (
activitysvc "bindbox-game/internal/service/activity" activitysvc "bindbox-game/internal/service/activity"
prizegrantsvc "bindbox-game/internal/service/prize_grant_activity" prizegrantsvc "bindbox-game/internal/service/prize_grant_activity"
tasksvc "bindbox-game/internal/service/task_center" tasksvc "bindbox-game/internal/service/task_center"
thresholdsvc "bindbox-game/internal/service/threshold_activity"
titlesvc "bindbox-game/internal/service/title" titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity" welfaresvc "bindbox-game/internal/service/welfare_activity"
@ -26,6 +27,7 @@ type handler struct {
redis *redis.Client redis *redis.Client
activityOrder activitysvc.ActivityOrderService // 活动订单服务 activityOrder activitysvc.ActivityOrderService // 活动订单服务
welfare welfaresvc.Service welfare welfaresvc.Service
threshold thresholdsvc.Service
prizeGrant prizegrantsvc.Service prizeGrant prizegrantsvc.Service
} }
@ -43,6 +45,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task task
redis: rdb, redis: rdb,
activityOrder: activitysvc.NewActivityOrderService(logger, db), activityOrder: activitysvc.NewActivityOrderService(logger, db),
welfare: welfaresvc.New(logger, db), welfare: welfaresvc.New(logger, db),
threshold: thresholdsvc.New(logger, db),
prizeGrant: prizegrantsvc.New(logger, db), prizeGrant: prizegrantsvc.New(logger, db),
} }
} }

View File

@ -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)
}
}

View File

@ -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())
}
}

View File

@ -17,6 +17,7 @@ import (
snapshotsvc "bindbox-game/internal/service/snapshot" snapshotsvc "bindbox-game/internal/service/snapshot"
synthesissvc "bindbox-game/internal/service/synthesis" synthesissvc "bindbox-game/internal/service/synthesis"
syscfgsvc "bindbox-game/internal/service/sysconfig" syscfgsvc "bindbox-game/internal/service/sysconfig"
thresholdsvc "bindbox-game/internal/service/threshold_activity"
titlesvc "bindbox-game/internal/service/title" titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity" welfaresvc "bindbox-game/internal/service/welfare_activity"
@ -44,6 +45,7 @@ type handler struct {
synthesis synthesissvc.Service synthesis synthesissvc.Service
financeSvc financesvc.Service // P&L service (read-only) financeSvc financesvc.Service // P&L service (read-only)
welfare welfaresvc.Service welfare welfaresvc.Service
threshold thresholdsvc.Service
prizeGrant prizegrantsvc.Service prizeGrant prizegrantsvc.Service
} }
@ -74,6 +76,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
synthesis: synthesissvc.New(db), synthesis: synthesissvc.New(db),
financeSvc: financesvc.New(logger, db), financeSvc: financesvc.New(logger, db),
welfare: welfaresvc.New(logger, db), welfare: welfaresvc.New(logger, db),
threshold: thresholdsvc.New(logger, db),
prizeGrant: prizegrantsvc.New(logger, db), prizeGrant: prizegrantsvc.New(logger, db),
} }
} }

View File

@ -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
}

View File

@ -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())
}
}

View File

@ -50,16 +50,13 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
panic(err) panic(err)
} }
// 添加 OpenTelemetry 链路追踪中间件
cfg := configs.Get() cfg := configs.Get()
if cfg.Otel.Enabled { if cfg.Otel.Enabled {
mux.Engine().Use(otel.Middleware(configs.ProjectName)) mux.Engine().Use(otel.Middleware(configs.ProjectName))
} }
// Redis is initialized in main.go
rdb := redis.GetClient() rdb := redis.GetClient()
// Instantiate Services
userSvc := usersvc.New(logger, db) userSvc := usersvc.New(logger, db)
titleSvc := titlesvc.New(logger, db) titleSvc := titlesvc.New(logger, db)
taskSvc := tasksvc.New(logger, db, rdb, userSvc, titleSvc) 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) ticketSvc := gamesvc.NewTicketService(logger, db)
douyinSvc := douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc) douyinSvc := douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc)
// Context for Worker
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// Start task center worker
go taskSvc.StartWorker(ctx) go taskSvc.StartWorker(ctx)
// 实例化拦截器
adminHandler := admin.New(logger, db, rdb) adminHandler := admin.New(logger, db, rdb)
activityHandler := activityapi.New(logger, db, rdb, taskSvc) activityHandler := activityapi.New(logger, db, rdb, taskSvc)
taskCenterHandler := taskcenterapi.New(logger, db, taskSvc) taskCenterHandler := taskcenterapi.New(logger, db, taskSvc)
// app端的API
userHandler := userapi.New(logger, db, taskSvc) 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) commonHandler := commonapi.New(logger, db)
payHandler := payapi.New(logger, db, taskSvc, activitySvc) payHandler := payapi.New(logger, db, taskSvc, activitySvc)
gameHandler := gameapi.New(logger, db, rdb, userSvc) gameHandler := gameapi.New(logger, db, rdb, userSvc)
intc := interceptor.New(logger, db) intc := interceptor.New(logger, db)
// 内部服务接口路由组 (供 Nakama 调用)
// 使用 X-Internal-Key 头进行验证,防止外部访问
internalRouter := mux.Group("/api/internal", func(ctx core.Context) { internalRouter := mux.Group("/api/internal", func(ctx core.Context) {
internalKey := ctx.GetHeader("X-Internal-Key") internalKey := ctx.GetHeader("X-Internal-Key")
// 从配置文件读取内部 API 密钥
expectedKey := configs.Get().Internal.ApiKey expectedKey := configs.Get().Internal.ApiKey
if expectedKey == "" { if expectedKey == "" {
expectedKey = "bindbox-internal-secret-2024" // 默认值(仅用于向后兼容) expectedKey = "bindbox-internal-secret-2024"
} }
if internalKey != expectedKey { if internalKey != expectedKey {
ctx.AbortWithError(core.Error(403, 10403, "Forbidden: Invalid internal key")) 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()) internalRouter.GET("/game/leaderboard", gameHandler.GetLeaderboardInternal())
} }
// 管理端非认证接口路由组
adminNonAuthApiRouter := mux.Group("/api/admin") adminNonAuthApiRouter := mux.Group("/api/admin")
{ {
adminNonAuthApiRouter.POST("/login", adminHandler.Login()) adminNonAuthApiRouter.POST("/login", adminHandler.Login())
} }
// 管理端认证接口路由组
adminAuthApiRouter := mux.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole()) adminAuthApiRouter := mux.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole())
// 系统管理接口(为前端模板路径兼容,挂载到 /api
systemApiRouter := mux.Group("/api", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole()) systemApiRouter := mux.Group("/api", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole())
{ {
// 管理员账号维护接口移除(未被前端使用)
adminAuthApiRouter.GET("/activity_categories", adminHandler.ListActivityCategories()) adminAuthApiRouter.GET("/activity_categories", adminHandler.ListActivityCategories())
// 任务中心管理端
adminAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForAdmin()) adminAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForAdmin())
adminAuthApiRouter.POST("/task-center/tasks", taskCenterHandler.CreateTaskForAdmin()) adminAuthApiRouter.POST("/task-center/tasks", taskCenterHandler.CreateTaskForAdmin())
adminAuthApiRouter.PUT("/task-center/tasks/:id", taskCenterHandler.ModifyTaskForAdmin()) 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/order-paid", taskCenterHandler.SimulateOrderPaid())
adminAuthApiRouter.POST("/task-center/events/invite-success", taskCenterHandler.SimulateInviteSuccess()) adminAuthApiRouter.POST("/task-center/events/invite-success", taskCenterHandler.SimulateInviteSuccess())
adminAuthApiRouter.GET("/task-center/tasks/:id/reward-stats", taskCenterHandler.GetTaskRewardStats()) 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/cards", adminHandler.DashboardCards())
adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend()) adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend())
adminAuthApiRouter.GET("/dashboard/draw_trend", adminHandler.DashboardDrawTrend()) 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/player_spending_leaderboard", adminHandler.DashboardPlayerSpendingLeaderboard())
adminAuthApiRouter.GET("/dashboard/activity-profit-loss", adminHandler.DashboardActivityProfitLoss()) adminAuthApiRouter.GET("/dashboard/activity-profit-loss", adminHandler.DashboardActivityProfitLoss())
adminAuthApiRouter.GET("/dashboard/activity-profit-loss/:activity_id/logs", adminHandler.DashboardActivityLogs()) adminAuthApiRouter.GET("/dashboard/activity-profit-loss/:activity_id/logs", adminHandler.DashboardActivityLogs())
// 运营分析
adminAuthApiRouter.GET("/operations/user_economics", adminHandler.DashboardUserEconomics()) adminAuthApiRouter.GET("/operations/user_economics", adminHandler.DashboardUserEconomics())
adminAuthApiRouter.GET("/operations/prize_distribution", adminHandler.DashboardPrizeDistribution()) adminAuthApiRouter.GET("/operations/prize_distribution", adminHandler.DashboardPrizeDistribution())
adminAuthApiRouter.GET("/dashboard/product_performance", adminHandler.OperationsProductPerformance()) 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/order_trend", adminHandler.DashboardOrderTrend())
adminAuthApiRouter.GET("/dashboard/activity_stats", adminHandler.DashboardActivityStats()) adminAuthApiRouter.GET("/dashboard/activity_stats", adminHandler.DashboardActivityStats())
adminAuthApiRouter.GET("/dashboard/item_card_sales", adminHandler.DashboardItemCardSales()) adminAuthApiRouter.GET("/dashboard/item_card_sales", adminHandler.DashboardItemCardSales())
adminAuthApiRouter.GET("/welfare-activities", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareActivities()) adminAuthApiRouter.GET("/welfare-activities", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareActivities())
adminAuthApiRouter.POST("/welfare-activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateWelfareActivity()) adminAuthApiRouter.POST("/welfare-activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateWelfareActivity())
adminAuthApiRouter.GET("/welfare-activities/cost-summary", intc.RequireAdminAction("activity:view"), adminHandler.GetWelfareCostSummary()) 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.GET("/welfare-activities/:id/winners", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareWinners())
adminAuthApiRouter.POST("/welfare-activities/:id/draw", intc.RequireAdminAction("activity:modify"), adminHandler.DrawWelfareActivity()) 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("/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.POST("/activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateActivity())
adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities()) adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities())
adminAuthApiRouter.PUT("/activities/:activity_id", intc.RequireAdminAction("activity:modify"), adminHandler.ModifyActivity()) 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.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.DELETE("/activities/:activity_id/issues/:issue_id/rewards/:reward_id", intc.RequireAdminAction("activity:delete"), adminHandler.DeleteIssueReward())
// 已移除:批量造数/批量删除用户接口(未被前端使用)
// 商品管理:分类与商品
adminAuthApiRouter.POST("/product_categories", adminHandler.CreateProductCategory()) adminAuthApiRouter.POST("/product_categories", adminHandler.CreateProductCategory())
adminAuthApiRouter.PUT("/product_categories/:category_id", adminHandler.ModifyProductCategory()) adminAuthApiRouter.PUT("/product_categories/:category_id", adminHandler.ModifyProductCategory())
adminAuthApiRouter.DELETE("/product_categories/:category_id", adminHandler.DeleteProductCategory()) 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.GET("/products", intc.RequireAdminAction("product:view"), adminHandler.ListProducts())
adminAuthApiRouter.POST("/auth/refresh", adminHandler.RefreshToken()) adminAuthApiRouter.POST("/auth/refresh", adminHandler.RefreshToken())
// 轮播图管理
adminAuthApiRouter.POST("/banners", intc.RequireAdminAction("banner:create"), adminHandler.CreateBanner()) adminAuthApiRouter.POST("/banners", intc.RequireAdminAction("banner:create"), adminHandler.CreateBanner())
adminAuthApiRouter.PUT("/banners/:banner_id", intc.RequireAdminAction("banner:modify"), adminHandler.ModifyBanner()) adminAuthApiRouter.PUT("/banners/:banner_id", intc.RequireAdminAction("banner:modify"), adminHandler.ModifyBanner())
adminAuthApiRouter.DELETE("/banners/:banner_id", intc.RequireAdminAction("banner:delete"), adminHandler.DeleteBanner()) adminAuthApiRouter.DELETE("/banners/:banner_id", intc.RequireAdminAction("banner:delete"), adminHandler.DeleteBanner())
adminAuthApiRouter.GET("/banners", intc.RequireAdminAction("banner:view"), adminHandler.ListBanners()) adminAuthApiRouter.GET("/banners", intc.RequireAdminAction("banner:view"), adminHandler.ListBanners())
// 渠道管理
adminAuthApiRouter.POST("/channels", intc.RequireAdminAction("channel:create"), adminHandler.CreateChannel()) adminAuthApiRouter.POST("/channels", intc.RequireAdminAction("channel:create"), adminHandler.CreateChannel())
adminAuthApiRouter.PUT("/channels/:channel_id", intc.RequireAdminAction("channel:modify"), adminHandler.ModifyChannel()) adminAuthApiRouter.PUT("/channels/:channel_id", intc.RequireAdminAction("channel:modify"), adminHandler.ModifyChannel())
adminAuthApiRouter.DELETE("/channels/:channel_id", intc.RequireAdminAction("channel:delete"), adminHandler.DeleteChannel()) 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.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("/channels/:channel_id/stats", intc.RequireAdminAction("channel:view"), adminHandler.ChannelStats())
// 抖店订单管理
adminAuthApiRouter.GET("/douyin/config", adminHandler.GetDouyinConfig()) adminAuthApiRouter.GET("/douyin/config", adminHandler.GetDouyinConfig())
adminAuthApiRouter.PUT("/douyin/config", adminHandler.SaveDouyinConfig()) adminAuthApiRouter.PUT("/douyin/config", adminHandler.SaveDouyinConfig())
adminAuthApiRouter.GET("/douyin/orders", adminHandler.ListDouyinOrders()) adminAuthApiRouter.GET("/douyin/orders", adminHandler.ListDouyinOrders())
adminAuthApiRouter.POST("/douyin/sync", adminHandler.SyncDouyinOrders()) adminAuthApiRouter.POST("/douyin/sync", adminHandler.SyncDouyinOrders())
// 新增: 手动同步接口 (优化版)
adminAuthApiRouter.POST("/douyin/sync-all", adminHandler.ManualSyncAll()) adminAuthApiRouter.POST("/douyin/sync-all", adminHandler.ManualSyncAll())
adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund()) adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund())
adminAuthApiRouter.POST("/douyin/grant-prizes", adminHandler.ManualGrantPrizes()) adminAuthApiRouter.POST("/douyin/grant-prizes", adminHandler.ManualGrantPrizes())
adminAuthApiRouter.POST("/douyin/orders/:shop_order_id/grant-reward", adminHandler.GrantOrderReward()) adminAuthApiRouter.POST("/douyin/orders/:shop_order_id/grant-reward", adminHandler.GrantOrderReward())
// 抖店商品奖励规则
adminAuthApiRouter.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards()) adminAuthApiRouter.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards())
adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward()) adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward())
adminAuthApiRouter.PUT("/douyin/product-rewards/:id", adminHandler.UpdateDouyinProductReward()) adminAuthApiRouter.PUT("/douyin/product-rewards/:id", adminHandler.UpdateDouyinProductReward())
adminAuthApiRouter.DELETE("/douyin/product-rewards/:id", adminHandler.DeleteDouyinProductReward()) adminAuthApiRouter.DELETE("/douyin/product-rewards/:id", adminHandler.DeleteDouyinProductReward())
// 直播间活动管理
adminAuthApiRouter.POST("/livestream/activities", adminHandler.CreateLivestreamActivity()) adminAuthApiRouter.POST("/livestream/activities", adminHandler.CreateLivestreamActivity())
adminAuthApiRouter.GET("/livestream/activities", adminHandler.ListLivestreamActivities()) adminAuthApiRouter.GET("/livestream/activities", adminHandler.ListLivestreamActivities())
adminAuthApiRouter.GET("/livestream/activities/:id", adminHandler.GetLivestreamActivity()) 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.POST("/livestream/activities/:id/commitment/generate", adminHandler.GenerateLivestreamCommitment())
adminAuthApiRouter.GET("/livestream/activities/:id/commitment/summary", adminHandler.GetLivestreamCommitmentSummary()) adminAuthApiRouter.GET("/livestream/activities/:id/commitment/summary", adminHandler.GetLivestreamCommitmentSummary())
// 抖音用户黑名单管理
adminAuthApiRouter.GET("/blacklist", adminHandler.ListBlacklist()) adminAuthApiRouter.GET("/blacklist", adminHandler.ListBlacklist())
adminAuthApiRouter.POST("/blacklist", adminHandler.AddBlacklist()) adminAuthApiRouter.POST("/blacklist", adminHandler.AddBlacklist())
adminAuthApiRouter.DELETE("/blacklist/:id", adminHandler.RemoveBlacklist()) adminAuthApiRouter.DELETE("/blacklist/:id", adminHandler.RemoveBlacklist())
adminAuthApiRouter.GET("/blacklist/check", adminHandler.CheckBlacklist()) adminAuthApiRouter.GET("/blacklist/check", adminHandler.CheckBlacklist())
adminAuthApiRouter.POST("/blacklist/batch", adminHandler.BatchAddBlacklist()) adminAuthApiRouter.POST("/blacklist/batch", adminHandler.BatchAddBlacklist())
// 系统配置KV
adminAuthApiRouter.GET("/system/configs", adminHandler.ListSystemConfigs()) adminAuthApiRouter.GET("/system/configs", adminHandler.ListSystemConfigs())
adminAuthApiRouter.POST("/system/configs", adminHandler.UpsertSystemConfig()) adminAuthApiRouter.POST("/system/configs", adminHandler.UpsertSystemConfig())
adminAuthApiRouter.PUT("/system/configs/:id", adminHandler.ModifySystemConfig()) adminAuthApiRouter.PUT("/system/configs/:id", adminHandler.ModifySystemConfig())
adminAuthApiRouter.DELETE("/system/configs/:id", adminHandler.DeleteSystemConfig()) adminAuthApiRouter.DELETE("/system/configs/:id", adminHandler.DeleteSystemConfig())
adminAuthApiRouter.POST("/prize-grant-activities", adminHandler.CreatePrizeGrantActivity())
// 奖品发放活动 adminAuthApiRouter.GET("/prize-grant-activities", adminHandler.ListPrizeGrantActivities())
adminAuthApiRouter.POST("/prize-grant-activities", adminHandler.CreatePrizeGrantActivity()) adminAuthApiRouter.GET("/prize-grant-activities/cost-summary", adminHandler.GetPrizeGrantCostSummary())
adminAuthApiRouter.GET("/prize-grant-activities", adminHandler.ListPrizeGrantActivities()) adminAuthApiRouter.GET("/prize-grant-activities/:id", adminHandler.GetPrizeGrantActivity())
adminAuthApiRouter.GET("/prize-grant-activities/cost-summary", adminHandler.GetPrizeGrantCostSummary()) adminAuthApiRouter.PUT("/prize-grant-activities/:id", adminHandler.UpdatePrizeGrantActivity())
adminAuthApiRouter.GET("/prize-grant-activities/:id", adminHandler.GetPrizeGrantActivity()) adminAuthApiRouter.DELETE("/prize-grant-activities/:id", adminHandler.DeletePrizeGrantActivity())
adminAuthApiRouter.PUT("/prize-grant-activities/:id", adminHandler.UpdatePrizeGrantActivity()) adminAuthApiRouter.GET("/prize-grant-activities/:id/user-records", adminHandler.ListPrizeGrantUserRecords())
adminAuthApiRouter.DELETE("/prize-grant-activities/:id", adminHandler.DeletePrizeGrantActivity()) adminAuthApiRouter.DELETE("/prize-grant-activities/:id/user-records/:record_id", adminHandler.DeletePrizeGrantUserRecord())
adminAuthApiRouter.GET("/prize-grant-activities/:id/user-records", adminHandler.ListPrizeGrantUserRecords()) adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-processed", adminHandler.MarkPrizeGrantUsersProcessed())
adminAuthApiRouter.DELETE("/prize-grant-activities/:id/user-records/:record_id", adminHandler.DeletePrizeGrantUserRecord()) adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-all-processed", adminHandler.MarkAllPrizeGrantUsersProcessed())
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", 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/invites", intc.RequireAdminAction("user:view"), adminHandler.ListUserInvites())
adminAuthApiRouter.GET("/users/:user_id/orders", intc.RequireAdminAction("user:view"), adminHandler.ListUserOrders()) adminAuthApiRouter.GET("/users/:user_id/orders", intc.RequireAdminAction("user:view"), adminHandler.ListUserOrders())
adminAuthApiRouter.GET("/users/:user_id/coupons", intc.RequireAdminAction("user:view"), adminHandler.ListUserCoupons()) 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.GET("/users/search", intc.RequireAdminAction("user:view"), adminHandler.AdminSearchUsers())
adminAuthApiRouter.DELETE("/users/:user_id", intc.RequireAdminAction("user:delete"), adminHandler.DeleteUser()) adminAuthApiRouter.DELETE("/users/:user_id", intc.RequireAdminAction("user:delete"), adminHandler.DeleteUser())
adminAuthApiRouter.GET("/users/:user_id/audit", intc.RequireAdminAction("user:view"), adminHandler.ListUserAuditLogs()) 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/: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/points/add", intc.RequireAdminAction("user:points:batch:add"), adminHandler.BatchAddUserPoints())
adminAuthApiRouter.POST("/users/batch/coupons/add", intc.RequireAdminAction("user:coupons:batch:add"), adminHandler.BatchAddUserCoupons()) 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.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.GET("/users/:user_id/item_cards", adminHandler.ListUserItemCards())
adminAuthApiRouter.POST("/users/:user_id/item_cards/:user_item_card_id/void", adminHandler.VoidUserItemCard()) 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.GET("/system_titles", intc.RequireAdminAction("title:view"), adminHandler.ListSystemTitles())
adminAuthApiRouter.POST("/users/:user_id/titles", intc.RequireAdminAction("title:assign"), adminHandler.AssignUserTitle()) adminAuthApiRouter.POST("/users/:user_id/titles", intc.RequireAdminAction("title:assign"), adminHandler.AssignUserTitle())
adminAuthApiRouter.POST("/system_titles", intc.RequireAdminAction("title:create"), adminHandler.CreateSystemTitle()) 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.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.DELETE("/system_titles/:title_id/effects/:effect_id", intc.RequireAdminAction("title:effect:delete"), adminHandler.DeleteSystemTitleEffect())
// 小程序二维码生成
adminAuthApiRouter.POST("/miniapp/qrcode", adminHandler.GenerateMiniAppQRCode()) adminAuthApiRouter.POST("/miniapp/qrcode", adminHandler.GenerateMiniAppQRCode())
// 小程序发货信息管理(虚拟发货)
adminAuthApiRouter.POST("/miniapp/shipping/set_jump_path", adminHandler.SetMiniAppMsgJumpPath()) adminAuthApiRouter.POST("/miniapp/shipping/set_jump_path", adminHandler.SetMiniAppMsgJumpPath())
adminAuthApiRouter.POST("/miniapp/shipping/upload_virtual", adminHandler.UploadVirtualShippingForTransaction()) adminAuthApiRouter.POST("/miniapp/shipping/upload_virtual", adminHandler.UploadVirtualShippingForTransaction())
// 道具卡管理
adminAuthApiRouter.POST("/system_item_cards", intc.RequireAdminAction("itemcard:create"), adminHandler.CreateSystemItemCard()) 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.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.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.GET("/system_item_cards", intc.RequireAdminAction("itemcard:view"), adminHandler.ListSystemItemCards())
// 优惠券管理
adminAuthApiRouter.POST("/system_coupons", intc.RequireAdminAction("coupon:create"), adminHandler.CreateSystemCoupon()) adminAuthApiRouter.POST("/system_coupons", intc.RequireAdminAction("coupon:create"), adminHandler.CreateSystemCoupon())
adminAuthApiRouter.PUT("/system_coupons/:coupon_id", intc.RequireAdminAction("coupon:modify"), adminHandler.ModifySystemCoupon()) 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.DELETE("/system_coupons/:coupon_id", intc.RequireAdminAction("coupon:delete"), adminHandler.DeleteSystemCoupon())
adminAuthApiRouter.GET("/system_coupons", intc.RequireAdminAction("coupon:view"), adminHandler.ListSystemCoupons()) adminAuthApiRouter.GET("/system_coupons", intc.RequireAdminAction("coupon:view"), adminHandler.ListSystemCoupons())
adminAuthApiRouter.POST("/users/:user_id/item_cards", adminHandler.AssignUserItemCard()) adminAuthApiRouter.POST("/users/:user_id/item_cards", adminHandler.AssignUserItemCard())
// 次数卡管理
adminAuthApiRouter.POST("/game-passes/grant", adminHandler.GrantGamePass()) adminAuthApiRouter.POST("/game-passes/grant", adminHandler.GrantGamePass())
adminAuthApiRouter.GET("/game-passes/list", adminHandler.ListGamePasses()) adminAuthApiRouter.GET("/game-passes/list", adminHandler.ListGamePasses())
adminAuthApiRouter.GET("/users/:user_id/game-passes", adminHandler.GetUserGamePasses()) adminAuthApiRouter.GET("/users/:user_id/game-passes", adminHandler.GetUserGamePasses())
adminAuthApiRouter.GET("/activities/:activity_id/game-passes/check", adminHandler.CheckActivityGamePasses()) adminAuthApiRouter.GET("/activities/:activity_id/game-passes/check", adminHandler.CheckActivityGamePasses())
// 次数卡套餐管理
adminAuthApiRouter.GET("/game-pass-packages", adminHandler.ListGamePassPackages()) adminAuthApiRouter.GET("/game-pass-packages", adminHandler.ListGamePassPackages())
adminAuthApiRouter.POST("/game-pass-packages", adminHandler.CreateGamePassPackage()) adminAuthApiRouter.POST("/game-pass-packages", adminHandler.CreateGamePassPackage())
adminAuthApiRouter.PUT("/game-pass-packages/:package_id", adminHandler.ModifyGamePassPackage()) adminAuthApiRouter.PUT("/game-pass-packages/:package_id", adminHandler.ModifyGamePassPackage())
adminAuthApiRouter.DELETE("/game-pass-packages/:package_id", adminHandler.DeleteGamePassPackage()) adminAuthApiRouter.DELETE("/game-pass-packages/:package_id", adminHandler.DeleteGamePassPackage())
// 对对碰卡牌类型管理
adminAuthApiRouter.GET("/matching_card_types", adminHandler.ListMatchingCardTypes()) adminAuthApiRouter.GET("/matching_card_types", adminHandler.ListMatchingCardTypes())
adminAuthApiRouter.POST("/matching_card_types", adminHandler.CreateMatchingCardType()) adminAuthApiRouter.POST("/matching_card_types", adminHandler.CreateMatchingCardType())
adminAuthApiRouter.PUT("/matching_card_types/:id", adminHandler.ModifyMatchingCardType()) adminAuthApiRouter.PUT("/matching_card_types/:id", adminHandler.ModifyMatchingCardType())
adminAuthApiRouter.DELETE("/matching_card_types/:id", adminHandler.DeleteMatchingCardType()) adminAuthApiRouter.DELETE("/matching_card_types/:id", adminHandler.DeleteMatchingCardType())
adminAuthApiRouter.GET("/matching/audit/:order_no", adminHandler.GetMatchingAudit()) adminAuthApiRouter.GET("/matching/audit/:order_no", adminHandler.GetMatchingAudit())
// 游戏资格管理
adminAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.ListUserTickets()) adminAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.ListUserTickets())
adminAuthApiRouter.POST("/users/:user_id/game_tickets", gameHandler.GrantUserTicket()) adminAuthApiRouter.POST("/users/:user_id/game_tickets", gameHandler.GrantUserTicket())
// 扫雷排行榜 & 对战记录
adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard()) adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard())
adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords()) adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords())
// 发货统计
adminAuthApiRouter.GET("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:view"), adminHandler.ListShippingStats()) 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.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.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.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.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", intc.RequireAdminAction("shipping:view"), adminHandler.ListShippingOrders())
adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail()) adminAuthApiRouter.GET("/shipping/orders/:id", intc.RequireAdminAction("shipping:view"), adminHandler.GetShippingOrderDetail())
adminAuthApiRouter.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch()) adminAuthApiRouter.PUT("/shipping/orders/batch", intc.RequireAdminAction("shipping:modify"), adminHandler.UpdateShippingBatch())
adminAuthApiRouter.POST("/shipping/orders/cancel", intc.RequireAdminAction("shipping:modify"), adminHandler.AdminCancelShipping()) adminAuthApiRouter.POST("/shipping/orders/cancel", intc.RequireAdminAction("shipping:modify"), adminHandler.AdminCancelShipping())
// 碎片合成配方管理
adminAuthApiRouter.GET("/synthesis/recipes", adminHandler.ListSynthesisRecipes()) adminAuthApiRouter.GET("/synthesis/recipes", adminHandler.ListSynthesisRecipes())
adminAuthApiRouter.GET("/synthesis/recipes/:id", adminHandler.GetSynthesisRecipe()) adminAuthApiRouter.GET("/synthesis/recipes/:id", adminHandler.GetSynthesisRecipe())
adminAuthApiRouter.POST("/synthesis/recipes", adminHandler.CreateSynthesisRecipe()) adminAuthApiRouter.POST("/synthesis/recipes", adminHandler.CreateSynthesisRecipe())
adminAuthApiRouter.PUT("/synthesis/recipes/:id", adminHandler.ModifySynthesisRecipe()) adminAuthApiRouter.PUT("/synthesis/recipes/:id", adminHandler.ModifySynthesisRecipe())
adminAuthApiRouter.DELETE("/synthesis/recipes/:id", adminHandler.DeleteSynthesisRecipe()) adminAuthApiRouter.DELETE("/synthesis/recipes/:id", adminHandler.DeleteSynthesisRecipe())
adminAuthApiRouter.GET("/synthesis/logs", adminHandler.ListSynthesisLogs()) adminAuthApiRouter.GET("/synthesis/logs", adminHandler.ListSynthesisLogs())
adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund()) adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund())
adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds()) adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds())
adminAuthApiRouter.GET("/pay/refunds/:refund_no", intc.RequireAdminAction("refund:view"), adminHandler.GetRefundDetail()) 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.POST("/lottery/issues/:issue_id/simulate", adminHandler.SimulateIssue())
adminAuthApiRouter.GET("/ichiban/activities/:activity_id/issues/:issue_id/slots", adminHandler.ListIchibanSlots()) adminAuthApiRouter.GET("/ichiban/activities/:activity_id/issues/:issue_id/slots", adminHandler.ListIchibanSlots())
adminAuthApiRouter.GET("/ichiban/issues/:issue_id/slot/:slot_index", adminHandler.GetIchibanSlotDetail()) adminAuthApiRouter.GET("/ichiban/issues/:issue_id/slot/:slot_index", adminHandler.GetIchibanSlotDetail())
adminAuthApiRouter.POST("/activities/:activity_id/commitment/generate", adminHandler.GenerateActivityCommitmentGeneral()) adminAuthApiRouter.POST("/activities/:activity_id/commitment/generate", adminHandler.GenerateActivityCommitmentGeneral())
adminAuthApiRouter.GET("/activities/:activity_id/commitment/summary", adminHandler.GetActivityCommitmentSummaryGeneral()) adminAuthApiRouter.GET("/activities/:activity_id/commitment/summary", adminHandler.GetActivityCommitmentSummaryGeneral())
adminAuthApiRouter.GET("/activities/:activity_id/credential", adminHandler.GetActivityCredential()) 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.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.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("/pay/orders/export", intc.RequireAdminAction("order:export"), adminHandler.ExportPayOrders())
// 订单审计快照
adminAuthApiRouter.GET("/orders/:order_id/snapshots", intc.RequireAdminAction("order:view"), adminHandler.GetOrderSnapshots()) adminAuthApiRouter.GET("/orders/:order_id/snapshots", intc.RequireAdminAction("order:view"), adminHandler.GetOrderSnapshots())
adminAuthApiRouter.POST("/orders/:order_id/rollback", intc.RequireAdminAction("order:rollback"), adminHandler.RollbackOrder()) adminAuthApiRouter.POST("/orders/:order_id/rollback", intc.RequireAdminAction("order:rollback"), adminHandler.RollbackOrder())
// 通用上传
systemApiRouter.POST("/common/upload/wangeditor", commonHandler.UploadWangEditorImage()) systemApiRouter.POST("/common/upload/wangeditor", commonHandler.UploadWangEditorImage())
systemApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu()) systemApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu())
systemApiRouter.POST("/menu/ensure_activity_profit_loss", adminHandler.EnsureActivityProfitLossMenu()) systemApiRouter.POST("/menu/ensure_activity_profit_loss", adminHandler.EnsureActivityProfitLossMenu())
} }
// 系统管理:用户/角色/菜单
{ {
systemApiRouter.GET("/user/list", adminHandler.ListUsers()) systemApiRouter.GET("/user/list", adminHandler.ListUsers())
systemApiRouter.POST("/user", adminHandler.CreateUser()) systemApiRouter.POST("/user", adminHandler.CreateUser())
systemApiRouter.GET("/role/list", adminHandler.ListRoles()) 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()) systemApiRouter.DELETE("/system/recycle", adminHandler.ForceDeleteRecycle())
} }
// APP 端公开接口路由组
appPublicApiRouter := mux.Group("/api/app") appPublicApiRouter := mux.Group("/api/app")
{ {
appPublicApiRouter.GET("/activities", activityHandler.ListActivities()) 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", activityHandler.GetWelfareActivity())
appPublicApiRouter.GET("/welfare-activities/:id/participants", activityHandler.ListWelfareParticipants()) appPublicApiRouter.GET("/welfare-activities/:id/participants", activityHandler.ListWelfareParticipants())
appPublicApiRouter.GET("/welfare-activities/:id/winners", activityHandler.ListWelfareWinners()) appPublicApiRouter.GET("/welfare-activities/:id/winners", activityHandler.ListWelfareWinners())
appPublicApiRouter.GET("/threshold-activities", activityHandler.ListThresholdActivities())
// APP 端轮播图 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("/banners", appapi.NewBanner(logger, db).ListBannersForApp())
appPublicApiRouter.GET("/notices", appapi.NewNotice(logger, db).ListNoticesForApp()) appPublicApiRouter.GET("/notices", appapi.NewNotice(logger, db).ListNoticesForApp())
appPublicApiRouter.GET("/categories", appapi.NewCategory(logger, db).ListCategoriesForApp()) appPublicApiRouter.GET("/categories", appapi.NewCategory(logger, db).ListCategoriesForApp())
// 登录保持公开
appPublicApiRouter.POST("/users/weixin/login", userHandler.WeixinLogin()) appPublicApiRouter.POST("/users/weixin/login", userHandler.WeixinLogin())
appPublicApiRouter.POST("/users/douyin/login", userHandler.DouyinLogin()) appPublicApiRouter.POST("/users/douyin/login", userHandler.DouyinLogin())
appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare()) appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare())
appPublicApiRouter.GET("/matching/card_types", activityHandler.ListMatchingCardTypes()) appPublicApiRouter.GET("/matching/card_types", activityHandler.ListMatchingCardTypes())
// 短信登录
appPublicApiRouter.POST("/sms/send-code", userHandler.SendSmsCode()) appPublicApiRouter.POST("/sms/send-code", userHandler.SendSmsCode())
appPublicApiRouter.POST("/sms/login", userHandler.SmsLogin()) appPublicApiRouter.POST("/sms/login", userHandler.SmsLogin())
// 公共工具
appPublicApiRouter.POST("/common/openid", commonHandler.GetOpenID()) appPublicApiRouter.POST("/common/openid", commonHandler.GetOpenID())
appPublicApiRouter.GET("/config/public", commonHandler.GetPublicConfig()) appPublicApiRouter.GET("/config/public", commonHandler.GetPublicConfig())
// 商城浏览(无需登录)
appPublicApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp()) appPublicApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp())
appPublicApiRouter.GET("/product_categories", appapi.NewProductCategory(logger, db).ListProductCategoriesForApp()) appPublicApiRouter.GET("/product_categories", appapi.NewProductCategory(logger, db).ListProductCategoriesForApp())
appPublicApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp()) appPublicApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp())
} }
// 公开接口路由组 (无需登录)
publicApiRouter := mux.Group("/api/public") publicApiRouter := mux.Group("/api/public")
{ {
publicHandler := publicapi.New(logger, db, douyinSvc) 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()) publicApiRouter.GET("/livestream/:access_code/pending-orders", publicHandler.GetLivestreamPendingOrders())
} }
// APP 端认证接口路由组
appAuthApiRouter := mux.Group("/api/app", core.WrapAuthHandler(intc.AppTokenAuthVerify)) appAuthApiRouter := mux.Group("/api/app", core.WrapAuthHandler(intc.AppTokenAuthVerify))
{ {
appAuthApiRouter.PUT("/users/:user_id", userHandler.ModifyUser()) appAuthApiRouter.PUT("/users/:user_id", userHandler.ModifyUser())
appAuthApiRouter.GET("/users/profile", userHandler.GetUserProfile()) 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/orders", userHandler.ListUserOrders())
appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons()) appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons())
appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats()) 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/default", userHandler.SetDefaultUserAddress())
appAuthApiRouter.PUT("/users/:user_id/addresses/:address_id", userHandler.UpdateUserAddress()) appAuthApiRouter.PUT("/users/:user_id/addresses/:address_id", userHandler.UpdateUserAddress())
appAuthApiRouter.DELETE("/users/:user_id/addresses/:address_id", userHandler.DeleteUserAddress()) appAuthApiRouter.DELETE("/users/:user_id/addresses/:address_id", userHandler.DeleteUserAddress())
appAuthApiRouter.GET("/welfare-activities/:id/my", activityHandler.GetWelfareActivity()) appAuthApiRouter.GET("/welfare-activities/:id/my", activityHandler.GetWelfareActivity())
appAuthApiRouter.POST("/welfare-activities/:id/join", activityHandler.JoinWelfareActivity()) appAuthApiRouter.POST("/welfare-activities/:id/join", activityHandler.JoinWelfareActivity())
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.GET("/prize-grant-activities/pending", activityHandler.GetPendingPrizeGrantActivity())
appAuthApiRouter.POST("/prize-grant-activities/:id/claim", activityHandler.ClaimPrizeGrantActivity()) appAuthApiRouter.POST("/prize-grant-activities/:id/claim", activityHandler.ClaimPrizeGrantActivity())
// 任务中心 APP 端
appAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForApp()) appAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForApp())
appAuthApiRouter.GET("/task-center/tasks/:id/progress/:user_id", taskCenterHandler.GetTaskProgressForApp()) appAuthApiRouter.GET("/task-center/tasks/:id/progress/:user_id", taskCenterHandler.GetTaskProgressForApp())
appAuthApiRouter.POST("/task-center/tasks/:id/claim/:user_id", taskCenterHandler.ClaimTaskTierForApp()) appAuthApiRouter.POST("/task-center/tasks/:id/claim/:user_id", taskCenterHandler.ClaimTaskTierForApp())
appAuthApiRouter.GET("/activities/:activity_id/issues/:issue_id/choices", activityHandler.ListIssueChoices()) appAuthApiRouter.GET("/activities/:activity_id/issues/:issue_id/choices", activityHandler.ListIssueChoices())
appAuthApiRouter.POST("/orders/test/create", userHandler.CreateTestOrder()) appAuthApiRouter.POST("/orders/test/create", userHandler.CreateTestOrder())
appAuthApiRouter.GET("/orders/:order_id", userHandler.GetOrderDetail()) 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("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp())
appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder()) appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder())
// 需要黑名单检查的抽奖接口组
lotteryGroup := appAuthApiRouter.Group("", intc.CheckBlacklist()) 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("/lottery/join", activityHandler.JoinLottery())
// 对对碰游戏
lotteryGroup.POST("/matching/preorder", activityHandler.PreOrderMatchingGame()) lotteryGroup.POST("/matching/preorder", activityHandler.PreOrderMatchingGame())
// 扫雷游戏
lotteryGroup.POST("/games/enter", gameHandler.EnterGame()) lotteryGroup.POST("/games/enter", gameHandler.EnterGame())
lotteryGroup.GET("/games/leaderboard", gameHandler.GetLeaderboard()) lotteryGroup.GET("/games/leaderboard", gameHandler.GetLeaderboard())
// 积分兑换操作也应该检查黑名单
lotteryGroup.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon()) 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-product", userHandler.RedeemPointsToProduct())
lotteryGroup.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard()) 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/check", userHandler.ShippingFeeCheck())
lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/preorder", userHandler.ShippingFeePreorder()) lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/preorder", userHandler.ShippingFeePreorder())
lotteryGroup.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch()) 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()) lotteryGroup.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints())
} }
// 碎片合成
appAuthApiRouter.GET("/users/:user_id/synthesis/recipes", userHandler.ListSynthesisRecipesForUser()) 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", userHandler.DoSynthesis())
appAuthApiRouter.POST("/users/:user_id/synthesis/do-batch", userHandler.DoBatchSynthesis()) appAuthApiRouter.POST("/users/:user_id/synthesis/do-batch", userHandler.DoBatchSynthesis())
appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser()) appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser())
// 对对碰其他接口不需要严查黑名单或者已在preorder查过
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame()) appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState()) appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
appAuthApiRouter.GET("/matching/cards", activityHandler.GetMatchingGameCards()) appAuthApiRouter.GET("/matching/cards", activityHandler.GetMatchingGameCards())
// 次数卡
appAuthApiRouter.GET("/game-passes/available", userHandler.GetGamePasses()) appAuthApiRouter.GET("/game-passes/available", userHandler.GetGamePasses())
appAuthApiRouter.GET("/game-passes/packages", userHandler.GetGamePassPackages()) 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.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/create", userHandler.CreateAddressShare())
appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare())
} }
// 微信支付平台回调(无需鉴权)
mux.Group("/api/pay").POST("/wechat/notify", payHandler.WechatNotify()) payNotifyRouter := mux.Group("/api/pay")
return mux, cancel, nil {
payNotifyRouter.POST("/wechat/notify", payHandler.WechatNotify())
}
return mux, func() {
cancel()
}, nil
} }

View File

@ -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(&current).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
}
}
}

View File

@ -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())
}
}()
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -18,6 +18,7 @@ import (
douyinsvc "bindbox-game/internal/service/douyin" douyinsvc "bindbox-game/internal/service/douyin"
gamesvc "bindbox-game/internal/service/game" gamesvc "bindbox-game/internal/service/game"
syscfgsvc "bindbox-game/internal/service/sysconfig" syscfgsvc "bindbox-game/internal/service/sysconfig"
thresholdsvc "bindbox-game/internal/service/threshold_activity"
titlesvc "bindbox-game/internal/service/title" titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity" welfaresvc "bindbox-game/internal/service/welfare_activity"
@ -101,6 +102,7 @@ func main() {
activitysvc.StartScheduledSettlement(customLogger, dbRepo, redis.GetClient()) activitysvc.StartScheduledSettlement(customLogger, dbRepo, redis.GetClient())
welfaresvc.StartScheduledDraw(customLogger, dbRepo) welfaresvc.StartScheduledDraw(customLogger, dbRepo)
thresholdsvc.StartScheduledDraw(customLogger, dbRepo)
usersvc.StartExpirationCheck(customLogger, dbRepo) usersvc.StartExpirationCheck(customLogger, dbRepo)
usersvc.StartAutoCancelWorker(customLogger, dbRepo) usersvc.StartAutoCancelWorker(customLogger, dbRepo)

View File

@ -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='门槛活动中奖记录';