feat: 新增独立门槛活动模块与测试
This commit is contained in:
parent
8aa8ff7467
commit
f8c4e17ccc
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
internal/api/activity/threshold_activities_app.go
Normal file
119
internal/api/activity/threshold_activities_app.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
internal/api/activity/threshold_activities_app_test.go
Normal file
242
internal/api/activity/threshold_activities_app_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
314
internal/api/admin/threshold_activities_admin.go
Normal file
314
internal/api/admin/threshold_activities_admin.go
Normal 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
|
||||||
|
}
|
||||||
208
internal/api/admin/threshold_activities_admin_test.go
Normal file
208
internal/api/admin/threshold_activities_admin_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
412
internal/service/threshold_activity/activity.go
Normal file
412
internal/service/threshold_activity/activity.go
Normal 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(¤t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current.Status == StatusFinished || current.Status == StatusAborted {
|
||||||
|
return errors.New("已结算活动不可编辑")
|
||||||
|
}
|
||||||
|
if err := tx.Model(&Activity{}).Where("id = ? AND deleted_at IS NULL", id).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.replacePrizes(ctx, tx, id, req.Prizes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CopyActivity(ctx context.Context, id int64, req SaveActivityRequest) (int64, error) {
|
||||||
|
var src Activity
|
||||||
|
db := s.repo.GetDbR().WithContext(ctx)
|
||||||
|
if err := db.Where("id = ? AND deleted_at IS NULL", id).First(&src).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var prizes []Prize
|
||||||
|
if err := db.Where("activity_id = ?", id).Order("sort ASC, id ASC").Find(&prizes).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
copyReq := SaveActivityRequest{
|
||||||
|
Title: req.Title,
|
||||||
|
Type: req.Type,
|
||||||
|
QualificationMode: req.QualificationMode,
|
||||||
|
SpendThresholdAmount: req.SpendThresholdAmount,
|
||||||
|
InviteThresholdCount: req.InviteThresholdCount,
|
||||||
|
InviteEffectiveAmount: req.InviteEffectiveAmount,
|
||||||
|
MinParticipants: req.MinParticipants,
|
||||||
|
StartTime: req.StartTime,
|
||||||
|
EndTime: req.EndTime,
|
||||||
|
DrawTime: req.DrawTime,
|
||||||
|
Status: normalizeStatus(req.Status),
|
||||||
|
CoverImage: src.CoverImage,
|
||||||
|
Description: src.Description,
|
||||||
|
}
|
||||||
|
for _, p := range prizes {
|
||||||
|
copyReq.Prizes = append(copyReq.Prizes, PrizeInput{
|
||||||
|
RewardType: p.RewardType,
|
||||||
|
RewardRefID: p.RewardRefID,
|
||||||
|
Quantity: p.Quantity,
|
||||||
|
Sort: p.Sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
item, err := s.CreateActivity(ctx, copyReq)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return item.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error) {
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize <= 0 {
|
||||||
|
req.PageSize = 20
|
||||||
|
}
|
||||||
|
if req.PageSize > 100 {
|
||||||
|
req.PageSize = 100
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
db := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}).Where("deleted_at IS NULL")
|
||||||
|
if req.Type != "" {
|
||||||
|
db = db.Where("type = ?", req.Type)
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
db = db.Where("status = ?", req.Status)
|
||||||
|
if req.Status == StatusActive {
|
||||||
|
db = db.Where("start_time <= ? AND end_time >= ? AND draw_time >= ?", now, now, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Title) != "" {
|
||||||
|
db = db.Where("title LIKE ?", "%"+strings.TrimSpace(req.Title)+"%")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []Activity
|
||||||
|
if err := db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]ActivityListItem, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
item := ActivityListItem{Activity: row}
|
||||||
|
s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_participants").Where("activity_id = ?", row.ID).Count(&item.ParticipantCount)
|
||||||
|
s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_winners").Where("activity_id = ?", row.ID).Count(&item.WinnerCount)
|
||||||
|
s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_winners").Select("COALESCE(SUM(cost_cents),0)").Where("activity_id = ?", row.ID).Scan(&item.CostCents)
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
return &ListActivitiesResponse{Page: req.Page, PageSize: req.PageSize, Total: total, List: list}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetActivity(ctx context.Context, id int64, userID int64) (*ActivityDetail, error) {
|
||||||
|
var item Activity
|
||||||
|
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&item).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if userID <= 0 && item.Status == StatusActive && time.Now().Before(item.StartTime) {
|
||||||
|
return nil, errors.New("活动未开始")
|
||||||
|
}
|
||||||
|
return s.buildActivityDetail(ctx, item, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) GetActivityAdmin(ctx context.Context, id int64) (*ActivityDetail, error) {
|
||||||
|
var item Activity
|
||||||
|
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&item).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildActivityDetail(ctx, item, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) buildActivityDetail(ctx context.Context, item Activity, userID int64) (*ActivityDetail, error) {
|
||||||
|
detail := &ActivityDetail{Activity: item}
|
||||||
|
s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", item.ID).Order("sort ASC, id ASC").Find(&detail.Prizes)
|
||||||
|
s.fillPrizeMeta(ctx, detail.Prizes)
|
||||||
|
participants, _ := s.ListParticipants(ctx, item.ID, 1, 20)
|
||||||
|
if participants != nil {
|
||||||
|
detail.ParticipantCount = participants.Total
|
||||||
|
detail.Participants = participants.List
|
||||||
|
}
|
||||||
|
winners, _ := s.ListWinners(ctx, item.ID, 1, 20)
|
||||||
|
if winners != nil {
|
||||||
|
detail.Winners = winners.List
|
||||||
|
}
|
||||||
|
if userID > 0 {
|
||||||
|
progress, period, err := s.evaluateQualification(ctx, item, userID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail.QualificationProgress = progress
|
||||||
|
detail.CanJoin = isJoinWindowOpen(item, time.Now()) && qualificationSatisfied(item, progress)
|
||||||
|
var count int64
|
||||||
|
s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", item.ID, userID, period).Count(&count)
|
||||||
|
detail.Joined = count > 0
|
||||||
|
if detail.Joined {
|
||||||
|
detail.CanJoin = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) DeleteActivity(ctx context.Context, id int64) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return errors.New("活动ID无效")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) SavePrizes(ctx context.Context, activityID int64, prizes []PrizeInput) error {
|
||||||
|
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
return s.replacePrizes(ctx, tx, activityID, prizes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) replacePrizes(ctx context.Context, tx *gorm.DB, activityID int64, inputs []PrizeInput) error {
|
||||||
|
if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Delete(&Prize{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, input := range inputs {
|
||||||
|
prizeInput, err := normalizePrizeInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prizeInput.Quantity <= 0 {
|
||||||
|
return errors.New("奖品数量不能为空")
|
||||||
|
}
|
||||||
|
prize, err := s.buildPrizeSnapshot(ctx, tx, activityID, prizeInput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(prize).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateActivity(req SaveActivityRequest) error {
|
||||||
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
|
return errors.New("活动标题不能为空")
|
||||||
|
}
|
||||||
|
if req.Type != TypeDaily && req.Type != TypeWeekly && req.Type != TypeMonthly {
|
||||||
|
return errors.New("活动类型无效")
|
||||||
|
}
|
||||||
|
if req.SpendThresholdAmount < 0 || req.InviteThresholdCount < 0 || req.InviteEffectiveAmount < 0 {
|
||||||
|
return errors.New("门槛不能小于0")
|
||||||
|
}
|
||||||
|
if req.QualificationMode == QualificationModeSpendOnly && req.SpendThresholdAmount <= 0 {
|
||||||
|
return errors.New("消费门槛活动必须设置消费门槛")
|
||||||
|
}
|
||||||
|
if req.QualificationMode == QualificationModeInviteOnly && (req.InviteThresholdCount <= 0 || req.InviteEffectiveAmount <= 0) {
|
||||||
|
return errors.New("邀请门槛活动必须设置有效邀请人数和有效消费门槛")
|
||||||
|
}
|
||||||
|
if req.QualificationMode == QualificationModeEither && req.SpendThresholdAmount <= 0 && (req.InviteThresholdCount <= 0 || req.InviteEffectiveAmount <= 0) {
|
||||||
|
return errors.New("任一达标活动至少需要配置一条有效资格线")
|
||||||
|
}
|
||||||
|
if req.StartTime.IsZero() || req.EndTime.IsZero() || req.DrawTime.IsZero() {
|
||||||
|
return errors.New("活动时间和开奖时间不能为空")
|
||||||
|
}
|
||||||
|
if !req.EndTime.After(req.StartTime) {
|
||||||
|
return errors.New("结束时间必须晚于开始时间")
|
||||||
|
}
|
||||||
|
if req.DrawTime.Before(req.StartTime) {
|
||||||
|
return errors.New("开奖时间不能早于开始时间")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStatus(status string) string {
|
||||||
|
switch strings.TrimSpace(status) {
|
||||||
|
case StatusFinished:
|
||||||
|
return StatusFinished
|
||||||
|
case StatusAborted:
|
||||||
|
return StatusAborted
|
||||||
|
default:
|
||||||
|
return StatusActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQualificationMode(mode string) string {
|
||||||
|
switch strings.TrimSpace(mode) {
|
||||||
|
case QualificationModeSpendOnly:
|
||||||
|
return QualificationModeSpendOnly
|
||||||
|
case QualificationModeInviteOnly:
|
||||||
|
return QualificationModeInviteOnly
|
||||||
|
default:
|
||||||
|
return QualificationModeEither
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMinParticipants(v int64) int64 {
|
||||||
|
if v <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstProductImage(imagesJSON string) string {
|
||||||
|
imagesJSON = strings.TrimSpace(imagesJSON)
|
||||||
|
if imagesJSON == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var images []string
|
||||||
|
if err := json.Unmarshal([]byte(imagesJSON), &images); err == nil && len(images) > 0 {
|
||||||
|
return strings.TrimSpace(images[0])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeActivityTitle(title string) string {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
return time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePrizeInput(input PrizeInput) (PrizeInput, error) {
|
||||||
|
if input.RewardType == "" && input.ProductID > 0 {
|
||||||
|
input.RewardType = RewardTypeProduct
|
||||||
|
input.RewardRefID = input.ProductID
|
||||||
|
}
|
||||||
|
if input.RewardType == "" || input.RewardRefID <= 0 {
|
||||||
|
return PrizeInput{}, errors.New("奖品类型和资源不能为空")
|
||||||
|
}
|
||||||
|
switch input.RewardType {
|
||||||
|
case RewardTypeProduct, RewardTypeItemCard, RewardTypeCoupon:
|
||||||
|
return input, nil
|
||||||
|
default:
|
||||||
|
return PrizeInput{}, errors.New("奖品类型无效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) buildPrizeSnapshot(ctx context.Context, tx *gorm.DB, activityID int64, input PrizeInput) (*Prize, error) {
|
||||||
|
prize := &Prize{
|
||||||
|
ActivityID: activityID,
|
||||||
|
RewardType: input.RewardType,
|
||||||
|
RewardRefID: input.RewardRefID,
|
||||||
|
Quantity: input.Quantity,
|
||||||
|
RemainingQuantity: input.Quantity,
|
||||||
|
Sort: input.Sort,
|
||||||
|
}
|
||||||
|
switch input.RewardType {
|
||||||
|
case RewardTypeProduct:
|
||||||
|
var product model.Products
|
||||||
|
if err := tx.WithContext(ctx).Where("id = ?", input.RewardRefID).First(&product).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("商品不存在: %d", input.RewardRefID)
|
||||||
|
}
|
||||||
|
prize.RewardNameSnapshot = product.Name
|
||||||
|
prize.RewardImageSnapshot = firstProductImage(product.ImagesJSON)
|
||||||
|
prize.RewardValueSnapshotCents = product.Price
|
||||||
|
prize.CostSnapshotCents = product.CostPrice
|
||||||
|
if prize.CostSnapshotCents <= 0 {
|
||||||
|
prize.CostSnapshotCents = product.Price
|
||||||
|
}
|
||||||
|
case RewardTypeItemCard:
|
||||||
|
var card model.SystemItemCards
|
||||||
|
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", input.RewardRefID).First(&card).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("道具卡不存在或未启用: %d", input.RewardRefID)
|
||||||
|
}
|
||||||
|
prize.RewardNameSnapshot = card.Name
|
||||||
|
prize.RewardImageSnapshot = ""
|
||||||
|
prize.RewardValueSnapshotCents = card.Price
|
||||||
|
prize.CostSnapshotCents = card.Price
|
||||||
|
case RewardTypeCoupon:
|
||||||
|
var coupon model.SystemCoupons
|
||||||
|
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", input.RewardRefID).First(&coupon).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("优惠券不存在或未启用: %d", input.RewardRefID)
|
||||||
|
}
|
||||||
|
prize.RewardNameSnapshot = coupon.Name
|
||||||
|
prize.RewardImageSnapshot = ""
|
||||||
|
prize.RewardValueSnapshotCents = coupon.DiscountValue
|
||||||
|
prize.CostSnapshotCents = coupon.DiscountValue
|
||||||
|
}
|
||||||
|
return prize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) fillPrizeMeta(ctx context.Context, prizes []Prize) {
|
||||||
|
for i := range prizes {
|
||||||
|
if prizes[i].RewardNameSnapshot != "" {
|
||||||
|
prizes[i].Name = prizes[i].RewardNameSnapshot
|
||||||
|
}
|
||||||
|
if prizes[i].RewardImageSnapshot != "" {
|
||||||
|
prizes[i].Image = prizes[i].RewardImageSnapshot
|
||||||
|
}
|
||||||
|
if prizes[i].RewardValueSnapshotCents > 0 {
|
||||||
|
prizes[i].PriceCents = prizes[i].RewardValueSnapshotCents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
346
internal/service/threshold_activity/draw.go
Normal file
346
internal/service/threshold_activity/draw.go
Normal 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())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
151
internal/service/threshold_activity/draw_test.go
Normal file
151
internal/service/threshold_activity/draw_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
224
internal/service/threshold_activity/participant.go
Normal file
224
internal/service/threshold_activity/participant.go
Normal 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
|
||||||
|
}
|
||||||
153
internal/service/threshold_activity/participant_test.go
Normal file
153
internal/service/threshold_activity/participant_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
202
internal/service/threshold_activity/test_helper_test.go
Normal file
202
internal/service/threshold_activity/test_helper_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/service/threshold_activity/threshold_activity.go
Normal file
52
internal/service/threshold_activity/threshold_activity.go
Normal 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
|
||||||
|
}
|
||||||
233
internal/service/threshold_activity/types.go
Normal file
233
internal/service/threshold_activity/types.go
Normal 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"`
|
||||||
|
}
|
||||||
57
internal/service/threshold_activity/winner.go
Normal file
57
internal/service/threshold_activity/winner.go
Normal 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
|
||||||
|
}
|
||||||
2
main.go
2
main.go
@ -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)
|
||||||
|
|
||||||
|
|||||||
83
migrations/20260609_threshold_activities.sql
Normal file
83
migrations/20260609_threshold_activities.sql
Normal 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='门槛活动中奖记录';
|
||||||
Loading…
x
Reference in New Issue
Block a user