Compare commits

...

5 Commits

Author SHA1 Message Date
hy001
f1b781e3be 增加范围以及首充版本识别 2026-05-25 23:20:32 +08:00
hy001
ee5b4f5eaf 1 2026-05-25 22:55:28 +08:00
hy001
72c0d1d633 修复区域飘屏 2026-05-25 22:11:02 +08:00
hy001
5b92fef113 模拟飘屏修复 2026-05-25 21:48:29 +08:00
hy001
9aecdf34a3 持续模拟 2026-05-25 21:19:09 +08:00
9 changed files with 827 additions and 28 deletions

View File

@ -235,14 +235,15 @@ type OfficialNoticeCustomizeRequest struct {
}
type RewardGroupItem struct {
ID Int64Value `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"`
SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
ID Int64Value `json:"id"`
Type string `json:"type"`
DetailType string `json:"detailType"`
Name string `json:"name"`
Content string `json:"content"`
Quantity Int64Value `json:"quantity"`
Cover string `json:"cover"`
SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
}
type RewardGroupDetail struct {

View File

@ -4,6 +4,7 @@ import (
"net/http"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/highwin"
"chatapp3-golang/internal/service/regionimgroup"
@ -14,6 +15,7 @@ import (
func registerRegionIMGroupRoutes(
engine *gin.Engine,
cfg config.Config,
repository *repo.Repository,
javaClient authGateway,
service *regionimgroup.Service,
) {
@ -23,7 +25,7 @@ func registerRegionIMGroupRoutes(
registerRegionIMGroupAppRoutes(engine, javaClient, service)
registerRegionIMGroupInternalRoutes(engine, cfg, service)
registerRegionIMGroupAdminRoutes(engine, javaClient, service)
registerRegionIMGroupAdminRoutes(engine, repository, javaClient, service)
}
func registerRegionIMGroupAppRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
@ -63,10 +65,14 @@ func registerRegionIMGroupInternalRoutes(engine *gin.Engine, cfg config.Config,
})
}
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, javaClient authGateway, service *regionimgroup.Service) {
func registerRegionIMGroupAdminRoutes(engine *gin.Engine, repository *repo.Repository, javaClient authGateway, service *regionimgroup.Service) {
registerRegionIMGroupAdminGroup(engine.Group("/region/config"), javaClient, service)
registerRegionIMGroupAdminGroup(engine.Group("/resident-activity/voice-room-red-packet"), javaClient, service)
registerRegionBroadcastSimulationAdminGroup(engine.Group("/operate/region-broadcast"), javaClient, service)
var worker *highwin.BroadcastSimulationWorker
if repository != nil {
worker = highwin.NewBroadcastSimulationWorker(repository.DB, repository.Redis, service)
}
registerRegionBroadcastSimulationAdminGroup(engine.Group("/operate/region-broadcast"), javaClient, service, worker)
}
func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service) {
@ -107,7 +113,7 @@ func registerRegionIMGroupAdminGroup(group *gin.RouterGroup, javaClient authGate
})
}
func registerRegionBroadcastSimulationAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service) {
func registerRegionBroadcastSimulationAdminGroup(group *gin.RouterGroup, javaClient authGateway, service *regionimgroup.Service, worker *highwin.BroadcastSimulationWorker) {
group.Use(consoleAuthMiddleware(javaClient))
group.POST("/simulate", func(c *gin.Context) {
var req highwin.BroadcastSimulationRequest
@ -122,4 +128,23 @@ func registerRegionBroadcastSimulationAdminGroup(group *gin.RouterGroup, javaCli
}
writeOK(c, resp)
})
group.GET("/worker/status", func(c *gin.Context) {
writeOK(c, worker.Status())
})
group.POST("/worker/start", func(c *gin.Context) {
var req highwin.ContinuousSimulationRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, regionimgroup.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := worker.Start(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/worker/stop", func(c *gin.Context) {
writeOK(c, worker.Stop())
})
}

View File

@ -93,7 +93,7 @@ func NewRouter(
registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, cfg, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
registerRegionIMGroupRoutes(engine, cfg, repository, javaClient, services.RegionIMGroup)
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
registerWeekStarRoutes(engine, javaClient, services.WeekStar)

View File

@ -41,6 +41,9 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
}
return nil, err
}
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) {
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
}
@ -115,6 +118,7 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
appVersion := normalizeRechargeAppVersion(event)
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
eventID := strings.TrimSpace(event.EventID)
if eventID == "" && sourceOrderID != "" {
@ -135,6 +139,7 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
PayPlatform: payPlatform,
PaymentMethod: paymentMethod,
AppVersion: appVersion,
SourceOrderID: sourceOrderID,
OccurredAt: occurredAt,
}, nil
@ -357,6 +362,34 @@ func isAcceptedPaymentMethod(paymentMethod string, _ string) bool {
return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty
}
func normalizeRechargeAppVersion(event RechargeEvent) string {
return firstNonEmpty(
event.AppVersion.String(),
event.ClientVersion.String(),
event.Version.String(),
event.ReqVersion.String(),
rechargePayloadString(event.Payload, "appVersion", "clientVersion", "version", "reqVersion"),
)
}
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
if len(payload) == 0 {
return ""
}
var values map[string]any
if err := json.Unmarshal(payload, &values); err != nil {
return ""
}
for _, key := range keys {
if value, ok := values[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
func firstPositiveAmountCents(values ...string) int64 {
for _, value := range values {
cents, err := parseAmountCents(value)

View File

@ -120,7 +120,7 @@ func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
for _, item := range detail.RewardConfigList {
items = append(items, RewardItem{
ID: int64(item.ID),
Type: strings.TrimSpace(item.Type),
Type: rewardItemDisplayType(item),
Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity),
@ -131,6 +131,29 @@ func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
return items
}
func rewardItemDisplayType(item integration.RewardGroupItem) string {
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
if itemType == "PROPS" {
if detailType != "" && detailType != "PROPS" {
return detailType
}
if isVipRewardItem(item) {
return "NOBLE_VIP"
}
}
return itemType
}
func isVipRewardItem(item integration.RewardGroupItem) bool {
name := strings.ToUpper(strings.TrimSpace(item.Name))
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
return strings.Contains(name, "VIP") ||
strings.Contains(name, "NOBLE") ||
strings.Contains(remark, "VIP") ||
strings.Contains(remark, "NOBLE")
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return ""

View File

@ -50,14 +50,15 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
Name: "VIP礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(11),
Type: "PROPS",
Name: "VIP 1",
Content: "2049402425177018400",
Quantity: integration.Int64Value(30),
Cover: "",
SourceURL: "https://example.com/vip-short-badge.png",
Remark: "30 天",
ID: integration.Int64Value(11),
Type: "PROPS",
DetailType: "NOBLE_VIP",
Name: "VIP 1",
Content: "2049402425177018400",
Quantity: integration.Int64Value(30),
Cover: "",
SourceURL: "https://example.com/vip-short-badge.png",
Remark: "30 天",
},
},
}
@ -78,6 +79,59 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
if got := resp.LevelConfigs[0].RewardItems[0].Cover; got != "https://example.com/vip-short-badge.png" {
t.Fatalf("vip cover = %q", got)
}
if got := resp.LevelConfigs[0].RewardItems[0].Type; got != "NOBLE_VIP" {
t.Fatalf("vip type = %q", got)
}
}
func TestRewardItemsReturnConcretePropsType(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[3001] = integration.RewardGroupDetail{
ID: integration.Int64Value(3001),
Name: "道具礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(21),
Type: "PROPS",
DetailType: "RIDE",
Name: "Luxury sports car",
Content: "2044620024357908482",
Quantity: integration.Int64Value(7),
Cover: "https://example.com/ride.png",
Remark: "Luxury sports car",
},
{
ID: integration.Int64Value(22),
Type: "PROPS",
Name: "VIP 3",
Content: "2050200000000000003",
Quantity: integration.Int64Value(3),
Cover: "https://example.com/vip3.png",
Remark: "VIP 3",
},
},
}
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
items := resp.LevelConfigs[0].RewardItems
if len(items) != 2 {
t.Fatalf("reward items = %+v", items)
}
if items[0].Type != "RIDE" {
t.Fatalf("ride type = %q", items[0].Type)
}
if items[1].Type != "NOBLE_VIP" {
t.Fatalf("vip fallback type = %q", items[1].Type)
}
}
func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
@ -108,6 +162,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999",
"payPlatform": "APPLE",
"paymentMethod": "APPLE",
"appVersion": "1.5.0",
"sourceOrderId": "1"
}`))
if err != nil {
@ -124,6 +179,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "499",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "LOW"
}`))
if err != nil {
@ -140,6 +196,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK",
"occurredAt": "2026-05-21T12:00:00Z"
}`))
@ -166,6 +223,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK"
}`))
if err != nil {
@ -185,6 +243,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999",
"payPlatform": "CUSTOM_PAY",
"paymentMethod": "THIRD_PARTY",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM"
}`))
if err != nil {
@ -204,6 +263,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999",
"payPlatform": "CUSTOM_GOOGLE_GATEWAY",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM_GOOGLE"
}`))
if err != nil {
@ -225,6 +285,108 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
}
}
func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
service, gateway, db := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"levelConfigs": [
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
oldVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OLD_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.4.9",
"sourceOrderId": "OLD_VERSION"
}`))
if err != nil {
t.Fatalf("old version ProcessRechargeEvent() error = %v", err)
}
if oldVersion.Processed || oldVersion.Reason != "app_version_not_supported" {
t.Fatalf("old version response = %+v", oldVersion)
}
missingVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:MISSING_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"sourceOrderId": "MISSING_VERSION"
}`))
if err != nil {
t.Fatalf("missing version ProcessRechargeEvent() error = %v", err)
}
if missingVersion.Processed || missingVersion.Reason != "app_version_not_supported" {
t.Fatalf("missing version response = %+v", missingVersion)
}
var count int64
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after unsupported versions: %v", err)
}
if count != 0 {
t.Fatalf("grant record count after unsupported versions = %d", count)
}
supported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:SUPPORTED_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "SUPPORTED_VERSION"
}`))
if err != nil {
t.Fatalf("supported version ProcessRechargeEvent() error = %v", err)
}
if !supported.Processed || supported.Status != statusSuccess {
t.Fatalf("supported version response = %+v", supported)
}
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after supported version: %v", err)
}
if count != 1 {
t.Fatalf("grant record count after supported version = %d", count)
}
}
func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
tests := []struct {
name string
appVersion string
want bool
}{
{name: "missing", appVersion: "", want: false},
{name: "below", appVersion: "1.4.9", want: false},
{name: "equal", appVersion: "1.5.0", want: true},
{name: "short equal", appVersion: "1.5", want: true},
{name: "above", appVersion: "1.5.1", want: true},
{name: "suffix", appVersion: "1.5.0+100", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion); got != tt.want {
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
}
})
}
}
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -274,12 +436,13 @@ func testRewardGroup(id int64, name string) integration.RewardGroupDetail {
Name: name,
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(11),
Type: "PROPS",
Name: name + "奖励",
Content: "10001",
Quantity: integration.Int64Value(1),
Cover: "https://example.com/reward.png",
ID: integration.Int64Value(11),
Type: "PROPS",
DetailType: "RIDE",
Name: name + "奖励",
Content: "10001",
Quantity: integration.Int64Value(1),
Cover: "https://example.com/reward.png",
},
},
}

View File

@ -176,6 +176,10 @@ type RechargeEvent struct {
Currency string `json:"currency"`
PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"`
AppVersion flexibleString `json:"appVersion"`
ClientVersion flexibleString `json:"clientVersion"`
Version flexibleString `json:"version"`
ReqVersion flexibleString `json:"reqVersion"`
SourceOrderID flexibleString `json:"sourceOrderId"`
OrderID flexibleString `json:"orderId"`
OccurredAt flexibleString `json:"occurredAt"`
@ -219,6 +223,7 @@ type normalizedRechargeEvent struct {
Currency string
PayPlatform string
PaymentMethod string
AppVersion string
SourceOrderID string
OccurredAt time.Time
}

View File

@ -0,0 +1,75 @@
package firstrechargereward
import (
"strconv"
"strings"
)
const firstRechargeRewardMinAppVersion = "1.5.0"
func isFirstRechargeRewardAppVersionSupported(appVersion string) bool {
appVersion = strings.TrimSpace(appVersion)
if appVersion == "" {
return false
}
return compareAppVersion(appVersion, firstRechargeRewardMinAppVersion) >= 0
}
func compareAppVersion(left, right string) int {
leftParts := parseAppVersion(left)
rightParts := parseAppVersion(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
var lv, rv int
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv > rv {
return 1
}
if lv < rv {
return -1
}
}
return 0
}
func parseAppVersion(version string) []int {
version = strings.TrimSpace(version)
if version == "" {
return []int{0}
}
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
result = append(result, 0)
continue
}
digits := strings.Builder{}
for _, r := range part {
if r < '0' || r > '9' {
break
}
digits.WriteRune(r)
}
if digits.Len() == 0 {
result = append(result, 0)
continue
}
value, err := strconv.Atoi(digits.String())
if err != nil {
result = append(result, 0)
continue
}
result = append(result, value)
}
return result
}

View File

@ -3,18 +3,24 @@ package highwin
import (
"context"
"fmt"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/regionimgroup"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const (
SimulationKindLuckyGift = "LUCKY_GIFT"
SimulationKindGameWin = "GAME_WIN"
adminTimeLayout = "2006-01-02 15:04:05"
)
type BroadcastSimulationRequest struct {
@ -45,6 +51,216 @@ type BroadcastSimulationResponse struct {
Data map[string]any `json:"data"`
}
type BroadcastSimulationWorker struct {
db *gorm.DB
redis *redis.Client
broadcaster RegionBroadcaster
mu sync.Mutex
cancel context.CancelFunc
config ContinuousSimulationRequest
status ContinuousSimulationStatus
}
type ContinuousSimulationRequest struct {
SysOrigin string `json:"sysOrigin"`
RegionCodes []string `json:"regionCodes"`
EnableLuckyGift bool `json:"enableLuckyGift"`
EnableGameWin bool `json:"enableGameWin"`
MinDelaySeconds int `json:"minDelaySeconds"`
MaxDelaySeconds int `json:"maxDelaySeconds"`
BetAmounts []int64 `json:"betAmounts"`
Multiples []float64 `json:"multiples"`
GameWinAmounts []int64 `json:"gameWinAmounts"`
LuckyGiftWinAmounts []int64 `json:"luckyGiftWinAmounts"`
GiftCandyOptions []int64 `json:"giftCandyOptions"`
GiftQuantityOptions []int64 `json:"giftQuantityOptions"`
}
type ContinuousSimulationStatus struct {
Running bool `json:"running"`
SysOrigin string `json:"sysOrigin"`
RegionCodes []string `json:"regionCodes"`
StartedAt string `json:"startedAt,omitempty"`
LastRunAt string `json:"lastRunAt,omitempty"`
LastError string `json:"lastError,omitempty"`
NextDelaySec int `json:"nextDelaySec"`
LuckyGiftCount int64 `json:"luckyGiftCount"`
GameWinCount int64 `json:"gameWinCount"`
TotalCount int64 `json:"totalCount"`
Config any `json:"config,omitempty"`
UpdateTime time.Time `json:"-"`
}
type continuousCandidateUser struct {
ID int64
}
type continuousCandidateGame struct {
GameID string
Cover string
}
type continuousCandidateRoom struct {
RoomID int64
}
func NewBroadcastSimulationWorker(db *gorm.DB, redisClient *redis.Client, broadcaster RegionBroadcaster) *BroadcastSimulationWorker {
return &BroadcastSimulationWorker{
db: db,
redis: redisClient,
broadcaster: broadcaster,
}
}
func (w *BroadcastSimulationWorker) Start(ctx context.Context, req ContinuousSimulationRequest) (*ContinuousSimulationStatus, error) {
req = normalizeContinuousRequest(req)
if err := validateContinuousRequest(req); err != nil {
return nil, err
}
if w == nil || w.db == nil || w.redis == nil || w.broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "simulation_worker_not_configured", "simulation worker is not configured")
}
w.mu.Lock()
if w.cancel != nil {
w.cancel()
}
workerCtx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.config = req
w.status = ContinuousSimulationStatus{
Running: true,
SysOrigin: req.SysOrigin,
RegionCodes: append([]string(nil), req.RegionCodes...),
StartedAt: time.Now().Format(adminTimeLayout),
Config: req,
}
status := w.status
w.mu.Unlock()
go w.run(workerCtx, req)
return &status, nil
}
func (w *BroadcastSimulationWorker) Stop() *ContinuousSimulationStatus {
if w == nil {
return &ContinuousSimulationStatus{}
}
w.mu.Lock()
defer w.mu.Unlock()
if w.cancel != nil {
w.cancel()
w.cancel = nil
}
w.status.Running = false
w.status.NextDelaySec = 0
w.status.UpdateTime = time.Now()
status := w.status
status.Config = w.config
return &status
}
func (w *BroadcastSimulationWorker) Status() *ContinuousSimulationStatus {
if w == nil {
return &ContinuousSimulationStatus{}
}
w.mu.Lock()
defer w.mu.Unlock()
status := w.status
status.Config = w.config
return &status
}
func (w *BroadcastSimulationWorker) run(ctx context.Context, req ContinuousSimulationRequest) {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
delay := randomDelaySeconds(rng, req.MinDelaySeconds, req.MaxDelaySeconds)
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.NextDelaySec = delay
})
timer := time.NewTimer(time.Duration(delay) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.Running = false
status.NextDelaySec = 0
})
return
case <-timer.C:
}
w.runOnce(ctx, req, rng)
}
}
func (w *BroadcastSimulationWorker) runOnce(ctx context.Context, req ContinuousSimulationRequest, rng *rand.Rand) {
regionCode := req.RegionCodes[rng.Intn(len(req.RegionCodes))]
userID, err := w.randomOfflineUser(ctx, req.SysOrigin, rng)
if err != nil {
w.setLastError(err)
return
}
roomID, err := w.randomActiveRoom(ctx, req.SysOrigin, rng)
if err != nil {
w.setLastError(err)
return
}
if req.EnableLuckyGift {
winAmount := pickInt64(rng, req.LuckyGiftWinAmounts)
if winAmount <= 0 {
winAmount = pickInt64(rng, req.GameWinAmounts)
}
err := w.sendContinuous(ctx, BroadcastSimulationRequest{
SysOrigin: req.SysOrigin,
RegionCode: regionCode,
Kind: SimulationKindLuckyGift,
SenderUserID: strconv.FormatInt(userID, 10),
RoomID: strconv.FormatInt(roomID, 10),
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
WinAmount: strconv.FormatInt(winAmount, 10),
GiftCandy: strconv.FormatInt(pickInt64(rng, req.GiftCandyOptions), 10),
GiftQuantity: strconv.FormatInt(pickInt64(rng, req.GiftQuantityOptions), 10),
ProviderOrder: fmt.Sprintf("worker-lucky-%d", time.Now().UnixNano()),
})
if err != nil {
w.setLastError(err)
} else {
w.incrementCount(true)
}
}
if req.EnableGameWin {
game, err := w.randomGame(ctx, req.SysOrigin)
if err != nil {
w.setLastError(err)
return
}
err = w.sendContinuous(ctx, BroadcastSimulationRequest{
SysOrigin: req.SysOrigin,
RegionCode: regionCode,
Kind: SimulationKindGameWin,
SenderUserID: strconv.FormatInt(userID, 10),
RoomID: strconv.FormatInt(roomID, 10),
Multiple: formatFloat(pickFloat64(rng, req.Multiples)),
WinAmount: strconv.FormatInt(pickInt64(rng, req.GameWinAmounts), 10),
GameID: game.GameID,
GameURL: game.Cover,
GameRoundID: fmt.Sprintf("worker-game-%d", time.Now().UnixNano()),
BetAmount: strconv.FormatInt(pickInt64(rng, req.BetAmounts), 10),
})
if err != nil {
w.setLastError(err)
} else {
w.incrementCount(false)
}
}
}
func SendSimulationBroadcast(
ctx context.Context,
broadcaster RegionBroadcaster,
@ -111,6 +327,264 @@ func SendSimulationBroadcast(
}, nil
}
func (w *BroadcastSimulationWorker) sendContinuous(ctx context.Context, req BroadcastSimulationRequest) error {
_, err := SendSimulationBroadcast(ctx, w.broadcaster, req)
return err
}
func (w *BroadcastSimulationWorker) randomOfflineUser(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
onlineIDs := w.onlineUserIDs(ctx, sysOrigin)
query := w.db.WithContext(ctx).
Table("user_base_info AS user").
Select("user.id AS id").
Where("user.origin_sys = ?", sysOrigin).
Where("user.id > 0")
if len(onlineIDs) > 0 && len(onlineIDs) <= 1000 {
query = query.Where("user.id NOT IN ?", onlineIDs)
}
var users []continuousCandidateUser
if err := query.Order("RAND()").Limit(50).Scan(&users).Error; err != nil {
return 0, err
}
filtered := make([]continuousCandidateUser, 0, len(users))
onlineSet := make(map[int64]struct{}, len(onlineIDs))
for _, id := range onlineIDs {
onlineSet[id] = struct{}{}
}
for _, user := range users {
if user.ID <= 0 {
continue
}
if _, online := onlineSet[user.ID]; online {
continue
}
filtered = append(filtered, user)
}
if len(filtered) == 0 {
return 0, common.NewAppError(http.StatusNotFound, "simulation_offline_user_not_found", "offline user not found")
}
return filtered[rng.Intn(len(filtered))].ID, nil
}
func (w *BroadcastSimulationWorker) randomActiveRoom(ctx context.Context, sysOrigin string, rng *rand.Rand) (int64, error) {
rooms, err := w.activeRooms(ctx, sysOrigin, 10)
if err != nil {
return 0, err
}
if len(rooms) == 0 {
return 0, nil
}
return rooms[rng.Intn(len(rooms))].RoomID, nil
}
func (w *BroadcastSimulationWorker) randomGame(ctx context.Context, sysOrigin string) (continuousCandidateGame, error) {
var game continuousCandidateGame
err := w.db.WithContext(ctx).
Table("sys_game_list_config").
Select("game_id AS game_id, cover AS cover").
Where("sys_origin = ? AND cover <> ''", sysOrigin).
Order("RAND()").
Limit(1).
Scan(&game).Error
if err != nil {
return continuousCandidateGame{}, err
}
if strings.TrimSpace(game.GameID) == "" || strings.TrimSpace(game.Cover) == "" {
return continuousCandidateGame{}, common.NewAppError(http.StatusNotFound, "simulation_game_not_found", "game cover not found")
}
return game, nil
}
func (w *BroadcastSimulationWorker) activeRooms(ctx context.Context, sysOrigin string, minUsers int64) ([]continuousCandidateRoom, error) {
var cursor uint64
var rooms []continuousCandidateRoom
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
for {
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return nil, err
}
for _, key := range keys {
count, err := w.redis.SCard(ctx, key).Result()
if err != nil || count <= minUsers {
continue
}
roomID, err := strconv.ParseInt(strings.TrimPrefix(key, prefix), 10, 64)
if err != nil || roomID <= 0 {
continue
}
rooms = append(rooms, continuousCandidateRoom{RoomID: roomID})
}
cursor = next
if cursor == 0 {
break
}
}
return rooms, nil
}
func (w *BroadcastSimulationWorker) onlineUserIDs(ctx context.Context, sysOrigin string) []int64 {
var cursor uint64
prefix := fmt.Sprintf("voice_room:online:%s:", strings.ToUpper(strings.TrimSpace(sysOrigin)))
seen := map[int64]struct{}{}
for {
keys, next, err := w.redis.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return nil
}
for _, key := range keys {
members, err := w.redis.SMembers(ctx, key).Result()
if err != nil {
continue
}
for _, raw := range members {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil && id > 0 {
seen[id] = struct{}{}
}
}
}
cursor = next
if cursor == 0 {
break
}
}
ids := make([]int64, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
return ids
}
func (w *BroadcastSimulationWorker) updateStatus(fn func(*ContinuousSimulationStatus)) {
w.mu.Lock()
defer w.mu.Unlock()
fn(&w.status)
w.status.UpdateTime = time.Now()
}
func (w *BroadcastSimulationWorker) setLastError(err error) {
if err == nil {
return
}
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.LastError = err.Error()
status.LastRunAt = time.Now().Format(adminTimeLayout)
})
}
func (w *BroadcastSimulationWorker) incrementCount(luckyGift bool) {
w.updateStatus(func(status *ContinuousSimulationStatus) {
status.LastError = ""
status.LastRunAt = time.Now().Format(adminTimeLayout)
status.TotalCount++
if luckyGift {
status.LuckyGiftCount++
} else {
status.GameWinCount++
}
})
}
func normalizeContinuousRequest(req ContinuousSimulationRequest) ContinuousSimulationRequest {
req.SysOrigin = strings.ToUpper(strings.TrimSpace(req.SysOrigin))
req.RegionCodes = normalizeStringList(req.RegionCodes)
if req.MinDelaySeconds < 0 {
req.MinDelaySeconds = 0
}
if req.MaxDelaySeconds < req.MinDelaySeconds {
req.MaxDelaySeconds = req.MinDelaySeconds
}
req.BetAmounts = positiveInt64List(req.BetAmounts, []int64{100, 200, 500, 1000})
req.Multiples = positiveFloat64List(req.Multiples, []float64{10, 20, 50})
req.GameWinAmounts = positiveInt64List(req.GameWinAmounts, []int64{1000, 2000, 5000})
req.LuckyGiftWinAmounts = positiveInt64List(req.LuckyGiftWinAmounts, []int64{1000, 2000, 5000})
req.GiftCandyOptions = positiveInt64List(req.GiftCandyOptions, []int64{100, 200, 500})
req.GiftQuantityOptions = positiveInt64List(req.GiftQuantityOptions, []int64{1, 5, 10})
return req
}
func validateContinuousRequest(req ContinuousSimulationRequest) error {
if req.SysOrigin == "" {
return common.NewAppError(http.StatusBadRequest, "missing_sys_origin", "sysOrigin is required")
}
if len(req.RegionCodes) == 0 {
return common.NewAppError(http.StatusBadRequest, "missing_region_codes", "regionCodes is required")
}
if !req.EnableLuckyGift && !req.EnableGameWin {
return common.NewAppError(http.StatusBadRequest, "missing_broadcast_type", "enable at least one broadcast type")
}
return nil
}
func normalizeStringList(values []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func positiveInt64List(values []int64, fallback []int64) []int64 {
result := make([]int64, 0, len(values))
for _, value := range values {
if value > 0 {
result = append(result, value)
}
}
if len(result) == 0 {
return append([]int64(nil), fallback...)
}
return result
}
func positiveFloat64List(values []float64, fallback []float64) []float64 {
result := make([]float64, 0, len(values))
for _, value := range values {
if value >= ThresholdMultiple {
result = append(result, value)
}
}
if len(result) == 0 {
return append([]float64(nil), fallback...)
}
return result
}
func randomDelaySeconds(rng *rand.Rand, min, max int) int {
if max <= min {
return min
}
return min + rng.Intn(max-min+1)
}
func pickInt64(rng *rand.Rand, values []int64) int64 {
if len(values) == 0 {
return 0
}
return values[rng.Intn(len(values))]
}
func pickFloat64(rng *rand.Rand, values []float64) float64 {
if len(values) == 0 {
return 0
}
return values[rng.Intn(len(values))]
}
func formatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func simulationMessageType(kind string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(kind)) {
case SimulationKindLuckyGift: