相关接口
This commit is contained in:
parent
cee48232f8
commit
9df03674d1
@ -252,7 +252,7 @@ func main() {
|
||||
CPRelation: cprelationmodule.New(userDB, auditHandler),
|
||||
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store, cfg),
|
||||
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn)),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Game: gamemanagementmodule.New(
|
||||
gameclient.NewGRPC(gameConn),
|
||||
|
||||
@ -56,7 +56,7 @@ type grantDTO struct {
|
||||
GrantID string `json:"grant_id"`
|
||||
AppCode string `json:"app_code"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
EventID string `json:"event_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
@ -79,7 +79,7 @@ type grantDTO struct {
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/response"
|
||||
)
|
||||
@ -13,8 +15,8 @@ type Handler struct {
|
||||
service *DashboardService
|
||||
}
|
||||
|
||||
func New(store *repository.Store, cfg config.Config) *Handler {
|
||||
return &Handler{service: NewService(store, cfg)}
|
||||
func New(store *repository.Store, cfg config.Config, user userclient.Client) *Handler {
|
||||
return &Handler{service: NewService(store, cfg, user)}
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardOverview(c *gin.Context) {
|
||||
@ -46,6 +48,7 @@ func (h *Handler) StatisticsOverview(c *gin.Context) {
|
||||
func (h *Handler) SelfGameStatisticsOverview(c *gin.Context) {
|
||||
overview, err := h.service.SelfGameStatisticsOverview(c.Request.Context(), SelfGameStatisticsQuery{
|
||||
AppCode: c.DefaultQuery("app_code", "lalu"),
|
||||
RequestID: middleware.CurrentRequestID(c),
|
||||
StartMS: parseInt64(c.Query("start_ms")),
|
||||
EndMS: parseInt64(c.Query("end_ms")),
|
||||
RegionID: parseInt64(c.Query("region_id")),
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
)
|
||||
|
||||
@ -17,6 +18,7 @@ type DashboardService struct {
|
||||
store *repository.Store
|
||||
cfg config.Config
|
||||
httpClient *http.Client
|
||||
user userclient.Client
|
||||
}
|
||||
|
||||
type StatisticsQuery struct {
|
||||
@ -31,6 +33,7 @@ type StatisticsQuery struct {
|
||||
|
||||
type SelfGameStatisticsQuery struct {
|
||||
AppCode string
|
||||
RequestID string
|
||||
StartMS int64
|
||||
EndMS int64
|
||||
RegionID int64
|
||||
@ -38,11 +41,12 @@ type SelfGameStatisticsQuery struct {
|
||||
GameID string
|
||||
}
|
||||
|
||||
func NewService(store *repository.Store, cfg config.Config) *DashboardService {
|
||||
func NewService(store *repository.Store, cfg config.Config, user userclient.Client) *DashboardService {
|
||||
return &DashboardService{
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout},
|
||||
user: user,
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,7 +97,13 @@ func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query
|
||||
values.Set("game_id", gameID)
|
||||
}
|
||||
// 数据大屏只通过 statistics-service 读取自研游戏聚合指标,避免 admin 服务绕过聚合层去扫业务明细表。
|
||||
return s.statisticsGET(ctx, "/internal/v1/statistics/self-games/overview", values)
|
||||
overview, err := s.statisticsGET(ctx, "/internal/v1/statistics/self-games/overview", values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 高风险用户只对 statistics-service 已返回的 topN user_id 做批量资料补全;头像、昵称、短 ID 不进入统计库,避免聚合链路反查用户表。
|
||||
_ = s.enrichSelfGameRiskUsers(ctx, query, overview)
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) statisticsGET(ctx context.Context, path string, values url.Values) (map[string]any, error) {
|
||||
@ -111,8 +121,85 @@ func (s *DashboardService) statisticsGET(ctx context.Context, path string, value
|
||||
return nil, fmt.Errorf("statistics service returned status %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) enrichSelfGameRiskUsers(ctx context.Context, query SelfGameStatisticsQuery, overview map[string]any) error {
|
||||
if s.user == nil || overview == nil {
|
||||
return nil
|
||||
}
|
||||
rows, ok := overview["user_risk"].([]any)
|
||||
if !ok || len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
userIDs := make([]int64, 0, len(rows))
|
||||
seen := map[int64]struct{}{}
|
||||
for _, raw := range rows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
userID := anyInt64(row["user_id"])
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
// user_id 是 16 位以上业务 ID,admin 层统一改成字符串透给 H5,避免浏览器 JSON number 精度丢失。
|
||||
row["user_id"] = strconv.FormatInt(userID, 10)
|
||||
if _, exists := seen[userID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
users, err := s.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{
|
||||
RequestID: query.RequestID,
|
||||
Caller: "admin-server",
|
||||
UserIDs: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, raw := range rows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
user := users[anyInt64(row["user_id"])]
|
||||
if user == nil {
|
||||
continue
|
||||
}
|
||||
row["avatar"] = user.Avatar
|
||||
row["nickname"] = user.Username
|
||||
row["username"] = user.Username
|
||||
row["display_user_id"] = user.DisplayUserID
|
||||
row["short_id"] = user.DisplayUserID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func anyInt64(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
parsed, _ := typed.Int64()
|
||||
return parsed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ type claimDTO struct {
|
||||
EventID string `json:"event_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
@ -83,7 +83,7 @@ type claimDTO struct {
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
|
||||
@ -1,5 +1,45 @@
|
||||
package hostorg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flexibleInt64 只用于 admin HTTP 请求入参:前端新合约会按字符串提交用户长 ID,旧页面或脚本可能仍提交 JSON number。
|
||||
// 这里同时接受两种形态,进入业务层前仍落成 int64,保证后续短 ID 解析、RPC 参数和审计日志不出现两套类型分支。
|
||||
type flexibleInt64 int64
|
||||
|
||||
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
if raw == "" || raw == "null" {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, `"`) {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(text)
|
||||
if raw == "" {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid int64 id %q", raw)
|
||||
}
|
||||
*v = flexibleInt64(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v flexibleInt64) Int64() int64 {
|
||||
return int64(v)
|
||||
}
|
||||
|
||||
type listQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
@ -14,17 +54,17 @@ type listQuery struct {
|
||||
}
|
||||
|
||||
type createBDLeaderRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
TargetUserID int64 `json:"targetUserId" binding:"required"`
|
||||
PositionAlias string `json:"positionAlias"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
||||
PositionAlias string `json:"positionAlias"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type createBDRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
TargetUserID int64 `json:"targetUserId" binding:"required"`
|
||||
ParentLeaderUserID int64 `json:"parentLeaderUserId"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
||||
ParentLeaderUserID flexibleInt64 `json:"parentLeaderUserId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type bdStatusRequest struct {
|
||||
@ -40,10 +80,10 @@ type bdLeaderPositionAliasRequest struct {
|
||||
}
|
||||
|
||||
type createCoinSellerRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Contact string `json:"contact"`
|
||||
TargetUserID int64 `json:"targetUserId" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Contact string `json:"contact"`
|
||||
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type coinSellerStatusRequest struct {
|
||||
@ -79,12 +119,12 @@ type coinSellerSalaryRateTierRequest struct {
|
||||
}
|
||||
|
||||
type createAgencyRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
OwnerUserID int64 `json:"ownerUserId" binding:"required"`
|
||||
ParentBDUserID int64 `json:"parentBdUserId"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
JoinEnabled *bool `json:"joinEnabled" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
OwnerUserID flexibleInt64 `json:"ownerUserId" binding:"required"`
|
||||
ParentBDUserID flexibleInt64 `json:"parentBdUserId"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
JoinEnabled *bool `json:"joinEnabled" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type agencyJoinEnabledRequest struct {
|
||||
|
||||
31
server/admin/internal/modules/hostorg/request_test.go
Normal file
31
server/admin/internal/modules/hostorg/request_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
package hostorg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlexibleInt64AcceptsStringAndNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int64
|
||||
}{
|
||||
{name: "string", body: `{"targetUserId":"323096761415507968"}`, want: 323096761415507968},
|
||||
{name: "number", body: `{"targetUserId":323096761415507968}`, want: 323096761415507968},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var req createBDLeaderRequest
|
||||
if err := json.Unmarshal([]byte(tc.body), &req); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
if got := req.TargetUserID.Int64(); got != tc.want {
|
||||
t.Fatalf("target user id = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -118,7 +118,7 @@ func (s *Service) ReplaceCoinSellerSalaryRates(ctx context.Context, actorID int6
|
||||
}
|
||||
|
||||
func (s *Service) CreateBDLeader(ctx context.Context, actorID int64, requestID string, req createBDLeaderRequest) (*userclient.BDProfile, error) {
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -167,17 +167,17 @@ func (s *Service) UpdateBDLeaderPositionAlias(ctx context.Context, actorID int64
|
||||
}
|
||||
|
||||
func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, req createBDRequest) (*userclient.BDProfile, error) {
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentLeaderUserID := int64(0)
|
||||
if req.ParentLeaderUserID < 0 {
|
||||
if req.ParentLeaderUserID.Int64() < 0 {
|
||||
return nil, fmt.Errorf("parent_leader_user_id must not be negative")
|
||||
}
|
||||
if req.ParentLeaderUserID > 0 {
|
||||
if req.ParentLeaderUserID.Int64() > 0 {
|
||||
// 父级 Leader 是可选关系;传入短 ID 时才解析成内部 user_id,未传时保留 0 表示独立 BD。
|
||||
parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID)
|
||||
parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -214,7 +214,7 @@ func (s *Service) DeleteBDLeader(ctx context.Context, targetUserID int64) (*user
|
||||
}
|
||||
|
||||
func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID string, req createCoinSellerRequest) (*userclient.CoinSellerProfile, error) {
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -334,17 +334,17 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell
|
||||
}
|
||||
|
||||
func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) {
|
||||
ownerUserID, err := s.resolveDisplayUserID(ctx, requestID, req.OwnerUserID)
|
||||
ownerUserID, err := s.resolveDisplayUserID(ctx, requestID, req.OwnerUserID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentBDUserID := int64(0)
|
||||
if req.ParentBDUserID < 0 {
|
||||
if req.ParentBDUserID.Int64() < 0 {
|
||||
return nil, fmt.Errorf("parent_bd_user_id must not be negative")
|
||||
}
|
||||
if req.ParentBDUserID > 0 {
|
||||
if req.ParentBDUserID.Int64() > 0 {
|
||||
// 父级 BD 是可选关系;传入短 ID 时才解析成内部 user_id,未传时保留 0 表示独立 Agency。
|
||||
parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID)
|
||||
parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ type claimDTO struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
AppCode string `json:"app_code"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
RewardType string `json:"reward_type"`
|
||||
CommandID string `json:"command_id"`
|
||||
@ -83,7 +83,7 @@ type claimDTO struct {
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
|
||||
@ -15,8 +15,8 @@ type rechargeBillDTO struct {
|
||||
RechargeType string `json:"rechargeType"`
|
||||
Status string `json:"status"`
|
||||
ExternalRef string `json:"externalRef"`
|
||||
UserID int64 `json:"userId"`
|
||||
SellerUserID int64 `json:"sellerUserId"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
SellerUserID int64 `json:"sellerUserId,string"`
|
||||
SellerRegionID int64 `json:"sellerRegionId"`
|
||||
TargetRegionID int64 `json:"targetRegionId"`
|
||||
PolicyID int64 `json:"policyId"`
|
||||
|
||||
@ -56,7 +56,7 @@ type packetDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PacketID string `json:"packet_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
SenderUserID int64 `json:"sender_user_id,string"`
|
||||
RoomID string `json:"room_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
PacketType string `json:"packet_type"`
|
||||
@ -85,7 +85,7 @@ type claimDTO struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
PacketID string `json:"packet_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
Amount int64 `json:"amount"`
|
||||
WalletTransactionID string `json:"wallet_transaction_id"`
|
||||
Status string `json:"status"`
|
||||
|
||||
@ -50,7 +50,7 @@ type configDTO struct {
|
||||
type claimDTO struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
RewardDay string `json:"reward_day"`
|
||||
RewardType string `json:"reward_type"`
|
||||
@ -67,7 +67,7 @@ type claimDTO struct {
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
|
||||
@ -52,7 +52,7 @@ type tierDTO struct {
|
||||
type settlementDTO struct {
|
||||
SettlementID string `json:"settlement_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
OwnerUserID int64 `json:"owner_user_id,string"`
|
||||
PeriodStartMS int64 `json:"period_start_ms"`
|
||||
PeriodEndMS int64 `json:"period_end_ms"`
|
||||
CoinSpent int64 `json:"coin_spent"`
|
||||
|
||||
@ -49,7 +49,7 @@ type rewardDTO struct {
|
||||
type claimDTO struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
CycleNo int64 `json:"cycle_no"`
|
||||
CheckinDay string `json:"checkin_day"`
|
||||
@ -67,7 +67,7 @@ type claimDTO struct {
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
|
||||
@ -1,5 +1,49 @@
|
||||
package teamsalarysettlement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// settlementUserID 只用于批量结算 HTTP 入参,兼容前端新合约的字符串 ID 和旧页面残留的数字 ID。
|
||||
// 解析后仍以 int64 进入候选过滤和钱包结算,保证结算引擎内部只有一种精确 ID 表示。
|
||||
type settlementUserID int64
|
||||
|
||||
func (v *settlementUserID) UnmarshalJSON(data []byte) error {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
if raw == "" || raw == "null" {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(raw, `"`) {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(text)
|
||||
if raw == "" {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid settlement user id %q", raw)
|
||||
}
|
||||
*v = settlementUserID(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func settlementUserIDValues(items []settlementUserID) []int64 {
|
||||
out := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, int64(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
@ -81,13 +125,13 @@ type recordQuery struct {
|
||||
}
|
||||
|
||||
type settleRequest struct {
|
||||
PolicyType string `json:"policy_type"`
|
||||
TriggerMode string `json:"trigger_mode"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
CountryID int64 `json:"country_id"`
|
||||
CountryCode string `json:"country_code"`
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
PolicyType string `json:"policy_type"`
|
||||
TriggerMode string `json:"trigger_mode"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
CountryID int64 `json:"country_id"`
|
||||
CountryCode string `json:"country_code"`
|
||||
UserIDs []settlementUserID `json:"user_ids"`
|
||||
}
|
||||
|
||||
type settleResultDTO struct {
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
package teamsalarysettlement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSettlementUserIDsAcceptStringAndNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
var req settleRequest
|
||||
if err := json.Unmarshal([]byte(`{"user_ids":["323096761415507968",323096761415507969]}`), &req); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
got := settlementUserIDValues(req.UserIDs)
|
||||
want := []int64{323096761415507968, 323096761415507969}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("user ids length = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("user ids[%d] = %d, want %d", index, got[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -223,18 +223,19 @@ func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID in
|
||||
if req.CycleKey == "" {
|
||||
req.CycleKey = previousMonthCycleKey(time.Now().UTC())
|
||||
}
|
||||
userIDs := settlementUserIDValues(req.UserIDs)
|
||||
userFilter := int64(0)
|
||||
if len(req.UserIDs) == 1 {
|
||||
userFilter = req.UserIDs[0]
|
||||
if len(userIDs) == 1 {
|
||||
userFilter = userIDs[0]
|
||||
}
|
||||
// 单用户结算在候选聚合阶段下推 userFilter;批量结算先算区域候选再做白名单过滤。
|
||||
candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, userFilter, time.Now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return settleResultDTO{}, err
|
||||
}
|
||||
if len(req.UserIDs) > 0 {
|
||||
if len(userIDs) > 0 {
|
||||
allowed := map[int64]struct{}{}
|
||||
for _, id := range req.UserIDs {
|
||||
for _, id := range userIDs {
|
||||
if id > 0 {
|
||||
allowed[id] = struct{}{}
|
||||
}
|
||||
@ -250,7 +251,7 @@ func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID in
|
||||
}
|
||||
sortTeamCandidates(candidates)
|
||||
|
||||
result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(req.UserIDs), Records: []recordDTO{}}
|
||||
result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(userIDs), Records: []recordDTO{}}
|
||||
profiles, _ := s.userProfiles(ctx, appCode, candidateUserIDs(candidates))
|
||||
for _, candidate := range candidates {
|
||||
result.ProcessedCount++
|
||||
|
||||
@ -73,7 +73,7 @@ type rewardDTO struct {
|
||||
|
||||
type leaderboardEntryDTO struct {
|
||||
RankNo int32 `json:"rank_no"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
Score int64 `json:"score"`
|
||||
FirstScoredAtMS int64 `json:"first_scored_at_ms"`
|
||||
LastScoredAtMS int64 `json:"last_scored_at_ms"`
|
||||
@ -86,7 +86,7 @@ type settlementDTO struct {
|
||||
SettlementID string `json:"settlement_id"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
RankNo int32 `json:"rank_no"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserID int64 `json:"user_id,string"`
|
||||
Score int64 `json:"score"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
WalletCommandID string `json:"wallet_command_id"`
|
||||
|
||||
@ -115,7 +115,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false"))
|
||||
}
|
||||
// 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。
|
||||
roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher)
|
||||
roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient)
|
||||
transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc)
|
||||
gamev1.RegisterGameAppServiceServer(server, transport)
|
||||
gamev1.RegisterGameCallbackServiceServer(server, transport)
|
||||
|
||||
@ -22,3 +22,7 @@ func (c *WalletClient) ApplyGameCoinChange(ctx context.Context, req *walletv1.Ap
|
||||
func (c *WalletClient) GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||
return c.client.GetBalances(ctx, req)
|
||||
}
|
||||
|
||||
func (c *WalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
return c.client.ListGiftConfigs(ctx, req)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/xerr"
|
||||
@ -56,6 +57,11 @@ type UserClient interface {
|
||||
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
|
||||
}
|
||||
|
||||
// GiftCatalogClient 只暴露 room-rps 配置补齐需要的礼物配置查询能力;礼物名称、图标和币价仍由 wallet-service 作为 owner 输出。
|
||||
type GiftCatalogClient interface {
|
||||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||
}
|
||||
|
||||
// Publisher 是房间 IM 投递边界;生产用腾讯 IM REST,测试可以注入 fake,service 不感知具体 SDK。
|
||||
type Publisher interface {
|
||||
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
|
||||
@ -70,6 +76,7 @@ type Config struct {
|
||||
type Service struct {
|
||||
config Config
|
||||
user UserClient
|
||||
gifts GiftCatalogClient
|
||||
pub Publisher
|
||||
|
||||
mu sync.Mutex
|
||||
@ -80,7 +87,7 @@ type Service struct {
|
||||
|
||||
// New 创建房内猜拳用例层。
|
||||
// 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。
|
||||
func New(config Config, user UserClient, publisher Publisher) *Service {
|
||||
func New(config Config, user UserClient, publisher Publisher, giftCatalog ...GiftCatalogClient) *Service {
|
||||
if config.ChallengeTimeout <= 0 {
|
||||
// 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。
|
||||
config.ChallengeTimeout = 10 * time.Minute
|
||||
@ -89,9 +96,15 @@ func New(config Config, user UserClient, publisher Publisher) *Service {
|
||||
// 揭晓动画固定 3 秒;IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。
|
||||
config.RevealCountdown = 3 * time.Second
|
||||
}
|
||||
var gifts GiftCatalogClient
|
||||
if len(giftCatalog) > 0 {
|
||||
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
||||
gifts = giftCatalog[0]
|
||||
}
|
||||
return &Service{
|
||||
config: config,
|
||||
user: user,
|
||||
gifts: gifts,
|
||||
pub: publisher,
|
||||
configs: make(map[string]*gamev1.RoomRPSConfig),
|
||||
challenges: make(map[string]*gamev1.RoomRPSChallenge),
|
||||
@ -113,10 +126,24 @@ func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSCon
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
config := s.configForAppLocked(app)
|
||||
return cloneConfig(config), s.now().UnixMilli(), nil
|
||||
cloned := cloneConfig(config)
|
||||
originalUpdatedAtMS := config.GetUpdatedAtMs()
|
||||
s.mu.Unlock()
|
||||
|
||||
enriched, changed, err := s.enrichConfigGifts(ctx, app, cloned)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if changed {
|
||||
s.mu.Lock()
|
||||
// 线上已有旧配置可能只保存 gift_id;读接口补齐成功后同步回内存,后续发起挑战也能使用真实币价和展示快照。
|
||||
if current := s.configs[app]; current != nil && current.GetUpdatedAtMs() == originalUpdatedAtMS {
|
||||
s.configs[app] = cloneConfig(enriched)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return enriched, s.now().UnixMilli(), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) {
|
||||
@ -126,7 +153,7 @@ func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.R
|
||||
app = appcode.Normalize(app)
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
|
||||
normalized, err := s.normalizeConfig(app, config)
|
||||
normalized, err := s.normalizeConfig(ctx, app, config)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -432,7 +459,7 @@ func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig {
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
|
||||
func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
|
||||
if config == nil {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps config is required")
|
||||
}
|
||||
@ -485,7 +512,13 @@ func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*ga
|
||||
Enabled: gift.GetEnabled(),
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
if fallback := defaultsByID[gift.GetGiftId()]; fallback != nil {
|
||||
gifts = append(gifts, normalized)
|
||||
}
|
||||
if _, err := s.enrichStakeGifts(ctx, app, gifts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, normalized := range gifts {
|
||||
if fallback := defaultsByID[normalized.GetGiftId()]; fallback != nil {
|
||||
if normalized.GiftName == "" {
|
||||
normalized.GiftName = fallback.GetGiftName()
|
||||
}
|
||||
@ -498,9 +531,8 @@ func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*ga
|
||||
}
|
||||
if normalized.GiftPriceCoin <= 0 {
|
||||
// 运行时必须知道当前档位折算金币,后续接钱包托管也以这个金额作为订单事实。
|
||||
normalized.GiftPriceCoin = int64(sortOrder) * 100
|
||||
normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100
|
||||
}
|
||||
gifts = append(gifts, normalized)
|
||||
}
|
||||
sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() })
|
||||
return &gamev1.RoomRPSConfig{
|
||||
@ -513,6 +545,146 @@ func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*ga
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) enrichConfigGifts(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, bool, error) {
|
||||
if config == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
changed, err := s.enrichStakeGifts(ctx, app, config.GetStakeGifts())
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return config, changed, nil
|
||||
}
|
||||
|
||||
func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gamev1.RoomRPSStakeGift) (bool, error) {
|
||||
if len(gifts) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
needGiftIDs := make([]int64, 0, len(gifts))
|
||||
seen := make(map[int64]struct{}, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
if gift == nil || gift.GetGiftId() <= 0 {
|
||||
continue
|
||||
}
|
||||
if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftPriceCoin() > 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[gift.GetGiftId()]; ok {
|
||||
continue
|
||||
}
|
||||
// 只查询缺字段的档位,避免每次读配置都把 wallet-service 当成全量礼物列表使用。
|
||||
seen[gift.GetGiftId()] = struct{}{}
|
||||
needGiftIDs = append(needGiftIDs, gift.GetGiftId())
|
||||
}
|
||||
if len(needGiftIDs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
catalog, err := s.roomRPSGiftCatalog(ctx, app, needGiftIDs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed := false
|
||||
for _, gift := range gifts {
|
||||
if gift == nil {
|
||||
continue
|
||||
}
|
||||
item := catalog[gift.GetGiftId()]
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if gift.GiftName == "" {
|
||||
if name := roomRPSGiftDisplayName(item); name != "" {
|
||||
// 后台保存 room-rps 时只提交 gift_id;运行时展示名必须来自 wallet-service 礼物配置,不能让前端猜。
|
||||
gift.GiftName = name
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if gift.GiftIconUrl == "" {
|
||||
if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
|
||||
// Flutter 的 PK 面板只消费 gift_icon_url;这里把礼物资源主图折叠成该字段,保持接口契约稳定。
|
||||
gift.GiftIconUrl = iconURL
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 {
|
||||
// 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。
|
||||
gift.GiftPriceCoin = item.GetCoinPrice()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []int64) (map[int64]*walletv1.GiftConfig, error) {
|
||||
result := make(map[int64]*walletv1.GiftConfig, len(giftIDs))
|
||||
if s.gifts == nil {
|
||||
return result, nil
|
||||
}
|
||||
app = appcode.Normalize(app)
|
||||
for _, numericGiftID := range giftIDs {
|
||||
if numericGiftID <= 0 {
|
||||
continue
|
||||
}
|
||||
giftID := strconv.FormatInt(numericGiftID, 10)
|
||||
for page := int32(1); ; page++ {
|
||||
resp, err := s.gifts.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
|
||||
RequestId: fmt.Sprintf("room-rps-config:%s:%s:%d", app, giftID, page),
|
||||
AppCode: app,
|
||||
Keyword: giftID,
|
||||
Page: page,
|
||||
PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "room rps gift config lookup failed")
|
||||
}
|
||||
for _, item := range resp.GetGifts() {
|
||||
if strings.TrimSpace(item.GetGiftId()) != giftID {
|
||||
continue
|
||||
}
|
||||
result[numericGiftID] = item
|
||||
break
|
||||
}
|
||||
if result[numericGiftID] != nil {
|
||||
break
|
||||
}
|
||||
if len(resp.GetGifts()) < 100 {
|
||||
break
|
||||
}
|
||||
if total := resp.GetTotal(); total > 0 && int64(page)*100 >= total {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string {
|
||||
if gift == nil {
|
||||
return ""
|
||||
}
|
||||
if name := strings.TrimSpace(gift.GetName()); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(gift.GetResource().GetName())
|
||||
}
|
||||
|
||||
func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string {
|
||||
if gift == nil || gift.GetResource() == nil {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{
|
||||
gift.GetResource().GetAssetUrl(),
|
||||
gift.GetResource().GetPreviewUrl(),
|
||||
gift.GetResource().GetAnimationUrl(),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if value := strings.TrimSpace(candidate); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error {
|
||||
if s.pub == nil || challenge == nil {
|
||||
return nil
|
||||
|
||||
123
services/game-service/internal/service/roomrps/service_test.go
Normal file
123
services/game-service/internal/service/roomrps/service_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package roomrps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
|
||||
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
||||
"2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "", 100),
|
||||
"2002": roomRPSWalletGift("2002", "Bell", "", "https://cdn.example.test/bell-preview.png", "", 300),
|
||||
"2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500),
|
||||
"2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000),
|
||||
})
|
||||
svc := New(Config{}, nil, nil, catalog)
|
||||
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||
|
||||
config, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
|
||||
Status: "active",
|
||||
ChallengeTimeoutMs: 600000,
|
||||
RevealCountdownMs: 3000,
|
||||
StakeGifts: []*gamev1.RoomRPSStakeGift{
|
||||
{GiftId: 2001, Enabled: true, SortOrder: 1},
|
||||
{GiftId: 2002, Enabled: true, SortOrder: 2},
|
||||
{GiftId: 2003, Enabled: true, SortOrder: 3},
|
||||
{GiftId: 2004, Enabled: true, SortOrder: 4},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rose" || got.GetGiftIconUrl() != "https://cdn.example.test/rose.png" || got.GetGiftPriceCoin() != 100 {
|
||||
t.Fatalf("first gift was not enriched from wallet catalog: %+v", got)
|
||||
}
|
||||
if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" {
|
||||
t.Fatalf("second gift should use preview_url when asset_url is empty: %+v", got)
|
||||
}
|
||||
if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "https://cdn.example.test/crown.svga" {
|
||||
t.Fatalf("third gift should use animation_url as last display fallback: %+v", got)
|
||||
}
|
||||
if len(catalog.requests) != 4 {
|
||||
t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests))
|
||||
}
|
||||
if req := catalog.requests[0]; req.GetAppCode() != "lalu" || req.GetKeyword() != "2001" || req.GetPage() != 1 || req.GetPageSize() != 100 {
|
||||
t.Fatalf("catalog lookup request mismatch: %+v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigBackfillsLegacyEmptyGiftSnapshot(t *testing.T) {
|
||||
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
||||
"3001": roomRPSWalletGift("3001", "Rocket", "https://cdn.example.test/rocket.png", "", "", 900),
|
||||
})
|
||||
svc := New(Config{}, nil, nil, catalog)
|
||||
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
|
||||
svc.configs["lalu"] = &gamev1.RoomRPSConfig{
|
||||
AppCode: "lalu",
|
||||
GameId: GameID,
|
||||
Status: "active",
|
||||
ChallengeTimeoutMs: 600000,
|
||||
RevealCountdownMs: 3000,
|
||||
StakeGifts: []*gamev1.RoomRPSStakeGift{
|
||||
{GiftId: 3001, Enabled: true, SortOrder: 1},
|
||||
{GiftId: 10002, Enabled: true, SortOrder: 2},
|
||||
{GiftId: 10003, Enabled: true, SortOrder: 3},
|
||||
{GiftId: 10004, Enabled: true, SortOrder: 4},
|
||||
},
|
||||
CreatedAtMs: 1000,
|
||||
UpdatedAtMs: 2000,
|
||||
}
|
||||
|
||||
config, _, err := svc.GetConfig(context.Background(), "lalu")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rocket" || got.GetGiftIconUrl() != "https://cdn.example.test/rocket.png" || got.GetGiftPriceCoin() != 900 {
|
||||
t.Fatalf("legacy config response was not enriched: %+v", got)
|
||||
}
|
||||
if cached := svc.configs["lalu"].GetStakeGifts()[0]; cached.GetGiftIconUrl() == "" {
|
||||
t.Fatalf("legacy config was not backfilled into service cache: %+v", cached)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGiftCatalog struct {
|
||||
gifts map[string]*walletv1.GiftConfig
|
||||
requests []*walletv1.ListGiftConfigsRequest
|
||||
}
|
||||
|
||||
func newFakeGiftCatalog(gifts map[string]*walletv1.GiftConfig) *fakeGiftCatalog {
|
||||
return &fakeGiftCatalog{gifts: gifts}
|
||||
}
|
||||
|
||||
func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
f.requests = append(f.requests, req)
|
||||
if gift := f.gifts[req.GetKeyword()]; gift != nil {
|
||||
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{gift}, Total: 1}, nil
|
||||
}
|
||||
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil
|
||||
}
|
||||
|
||||
func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig {
|
||||
return &walletv1.GiftConfig{
|
||||
AppCode: "lalu",
|
||||
GiftId: giftID,
|
||||
Name: name,
|
||||
Status: "active",
|
||||
CoinPrice: coinPrice,
|
||||
Resource: &walletv1.Resource{
|
||||
ResourceId: 100,
|
||||
ResourceType: "gift",
|
||||
Name: name,
|
||||
Status: "active",
|
||||
AssetUrl: assetURL,
|
||||
PreviewUrl: previewURL,
|
||||
AnimationUrl: animationURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
@ -978,7 +979,9 @@ func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, start
|
||||
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin+item.RobotStakeCoin)
|
||||
// platform_profit_coin 是 game-service 在结算时按真实用户侧快照写入的 user_stake - payout - refund;
|
||||
// 机器人下注只是奖池和对手侧流水,不能进入抽水率分母,否则人机局会把平台抽水率稀释一半。
|
||||
item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@ -991,13 +994,29 @@ func (r *Repository) querySelfGamePools(ctx context.Context, app, startDay, endD
|
||||
filter += " AND game_id = ?"
|
||||
args = append(args, gameID)
|
||||
}
|
||||
latestArgs := append([]any{}, args...)
|
||||
latestArgs = append(latestArgs, app)
|
||||
latestArgs = append(latestArgs, args...)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0),
|
||||
COALESCE(SUM(in_coin),0), COALESCE(SUM(out_coin),0), COALESCE(MAX(balance_after_coin),0)
|
||||
FROM stat_self_game_pool_day
|
||||
WHERE `+filter+`
|
||||
GROUP BY game_id, direction, reason
|
||||
ORDER BY game_id, direction, reason`, args...)
|
||||
SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count,
|
||||
agg.in_coin, agg.out_coin, COALESCE(latest.balance_after_coin,0)
|
||||
FROM (
|
||||
SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0) AS transaction_count,
|
||||
COALESCE(SUM(in_coin),0) AS in_coin, COALESCE(SUM(out_coin),0) AS out_coin
|
||||
FROM stat_self_game_pool_day
|
||||
WHERE `+filter+`
|
||||
GROUP BY game_id, direction, reason
|
||||
) agg
|
||||
LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.game_id = agg.game_id
|
||||
AND latest.direction = agg.direction AND latest.reason = agg.reason
|
||||
AND latest.stat_day = (
|
||||
SELECT p2.stat_day
|
||||
FROM stat_self_game_pool_day p2
|
||||
WHERE `+filter+` AND p2.game_id = agg.game_id AND p2.direction = agg.direction AND p2.reason = agg.reason
|
||||
ORDER BY p2.stat_day DESC, p2.updated_at_ms DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY agg.game_id, agg.direction, agg.reason`, latestArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -1033,10 +1052,7 @@ func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, a
|
||||
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := item.GameID + "\x00" + item.ParticipantType + "\x00" + item.RPSGesture
|
||||
if item.DicePoint > 0 {
|
||||
key = item.GameID + "\x00" + item.ParticipantType + "\x00" + string(rune(item.DicePoint))
|
||||
}
|
||||
key := selfGameParticipantRateKey(item)
|
||||
totals[key] += item.ParticipantCount
|
||||
items = append(items, item)
|
||||
}
|
||||
@ -1044,10 +1060,7 @@ func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, a
|
||||
return nil, err
|
||||
}
|
||||
for index := range items {
|
||||
key := items[index].GameID + "\x00" + items[index].ParticipantType + "\x00" + items[index].RPSGesture
|
||||
if items[index].DicePoint > 0 {
|
||||
key = items[index].GameID + "\x00" + items[index].ParticipantType + "\x00" + string(rune(items[index].DicePoint))
|
||||
}
|
||||
key := selfGameParticipantRateKey(items[index])
|
||||
if items[index].Result == "win" {
|
||||
items[index].WinRate = ratio(items[index].ParticipantCount, totals[key])
|
||||
}
|
||||
@ -1055,6 +1068,16 @@ func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, a
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func selfGameParticipantRateKey(item SelfGameParticipantStat) string {
|
||||
playKey := item.RPSGesture
|
||||
if item.DicePoint > 0 {
|
||||
playKey = strconv.FormatInt(item.DicePoint, 10)
|
||||
}
|
||||
// 玩法分布查询本身按 stake_coin 分桶返回,胜率分母也必须保留同一档位;
|
||||
// 否则同一点数或同一手势在不同下注档位的结果会互相稀释,前端每一行的胜率就不是该行口径。
|
||||
return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey
|
||||
}
|
||||
|
||||
func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) {
|
||||
filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
@ -1167,7 +1190,9 @@ func applySelfGameMatchDerived(totals *SelfGameMatchTotals) {
|
||||
totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches)
|
||||
totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches)
|
||||
totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches)
|
||||
totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin+totals.RobotStakeCoin)
|
||||
// platform_profit_coin 与真实用户下注同口径,分母只使用 user_stake_coin;
|
||||
// robot_stake_coin 是机器人对手侧流水,展示总下注时可以合并,但不能用于真实用户侧抽水率。
|
||||
totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin)
|
||||
}
|
||||
|
||||
func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) {
|
||||
@ -1269,7 +1294,7 @@ func selfGameMetricDefinitions() []MetricDefinition {
|
||||
{Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户;只统计 stat_self_game_user_day.match_count > 0。"},
|
||||
{Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
|
||||
{Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
|
||||
{Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin + robot_stake_coin - payout_coin - refund_coin;与奖池入池/出池流水分表展示。"},
|
||||
{Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合;in_coin/out_coin 是入池/出池金额,balance_after_coin 是当天同方向同原因聚合行内最新余额快照。"},
|
||||
{Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin - payout_coin - refund_coin;抽水率分母同样只用 user_stake_coin。"},
|
||||
{Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合;in_coin/out_coin 是入池/出池金额,balance_after_coin 是查询窗口内同方向同原因的最新余额快照。"},
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,3 +147,110 @@ func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *te
|
||||
t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySelfGameOverviewUsesRealUserStakeForPlatformProfitRate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
statsSchema := mysqlschema.New(t, mysqlschema.Config{
|
||||
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
|
||||
DatabasePrefix: "hy_stats_self_game_profit_test",
|
||||
})
|
||||
repository, err := Open(ctx, statsSchema.DSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Close() })
|
||||
|
||||
if _, err := repository.db.ExecContext(ctx, `
|
||||
INSERT INTO stat_self_game_match_day (
|
||||
app_code, stat_day, game_id, stake_coin, settled_matches,
|
||||
user_stake_coin, robot_stake_coin, payout_coin, refund_coin, platform_profit_coin, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', '2026-06-06', 'dice', 100, 1, 1000, 1000, 700, 100, 200, 1),
|
||||
('lalu', '2026-06-06', 'dice', 500, 1, 2000, 2000, 1500, 100, 400, 1)`); err != nil {
|
||||
t.Fatalf("seed self game match stats: %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"})
|
||||
if err != nil {
|
||||
t.Fatalf("query self game overview: %v", err)
|
||||
}
|
||||
if overview.MatchTotals.PlatformProfitRate != 0.2 {
|
||||
t.Fatalf("platform profit rate should use real user stake only: %+v", overview.MatchTotals)
|
||||
}
|
||||
if len(overview.StakeBreakdown) != 2 || overview.StakeBreakdown[0].PlatformProfitRate != 0.2 || overview.StakeBreakdown[1].PlatformProfitRate != 0.2 {
|
||||
t.Fatalf("stake platform profit rate should use real user stake only: %+v", overview.StakeBreakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySelfGameOverviewKeepsParticipantWinRateInStakeBucket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
statsSchema := mysqlschema.New(t, mysqlschema.Config{
|
||||
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
|
||||
DatabasePrefix: "hy_stats_self_game_participant_test",
|
||||
})
|
||||
repository, err := Open(ctx, statsSchema.DSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Close() })
|
||||
|
||||
if _, err := repository.db.ExecContext(ctx, `
|
||||
INSERT INTO stat_self_game_participant_day (
|
||||
app_code, stat_day, game_id, stake_coin, participant_type, result, dice_point, participant_count, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', '2026-06-06', 'dice', 100, 'user', 'win', 6, 1, 1),
|
||||
('lalu', '2026-06-06', 'dice', 100, 'user', 'lose', 6, 3, 1),
|
||||
('lalu', '2026-06-06', 'dice', 500, 'user', 'win', 6, 4, 1)`); err != nil {
|
||||
t.Fatalf("seed self game participant stats: %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"})
|
||||
if err != nil {
|
||||
t.Fatalf("query self game overview: %v", err)
|
||||
}
|
||||
rates := map[int64]float64{}
|
||||
for _, item := range overview.ParticipantDistribution {
|
||||
if item.Result == "win" {
|
||||
rates[item.StakeCoin] = item.WinRate
|
||||
}
|
||||
}
|
||||
if rates[100] != 0.25 || rates[500] != 1 {
|
||||
t.Fatalf("participant win rate should stay in stake bucket: %+v", overview.ParticipantDistribution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySelfGameOverviewUsesLatestPoolBalanceSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
statsSchema := mysqlschema.New(t, mysqlschema.Config{
|
||||
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
|
||||
DatabasePrefix: "hy_stats_self_game_pool_test",
|
||||
})
|
||||
repository, err := Open(ctx, statsSchema.DSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Close() })
|
||||
|
||||
if _, err := repository.db.ExecContext(ctx, `
|
||||
INSERT INTO stat_self_game_pool_day (
|
||||
app_code, stat_day, game_id, direction, reason, transaction_count, in_coin, out_coin, balance_after_coin, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', '2026-06-05', 'dice', 'out', 'dice_settlement', 1, 0, 100, 5000, 1),
|
||||
('lalu', '2026-06-06', 'dice', 'out', 'dice_settlement', 1, 0, 200, 4800, 2)`); err != nil {
|
||||
t.Fatalf("seed self game pool stats: %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), GameID: "dice"})
|
||||
if err != nil {
|
||||
t.Fatalf("query self game overview: %v", err)
|
||||
}
|
||||
if len(overview.PoolStats) != 1 || overview.PoolStats[0].OutCoin != 300 || overview.PoolStats[0].BalanceAfterCoin != 4800 {
|
||||
t.Fatalf("pool balance should be latest snapshot, not max balance: %+v", overview.PoolStats)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user