package roomturnoverreward import ( "context" "database/sql" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" activityv1 "hyapp.local/api/proto/activity/v1" "github.com/gin-gonic/gin" ) type Handler struct { activity activityclient.Client userDB *sql.DB audit shared.OperationLogger } func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler { return &Handler{activity: activity, userDB: userDB, audit: audit} } type configRequest struct { Enabled bool `json:"enabled"` Tiers []tierDTO `json:"tiers"` } type configDTO struct { AppCode string `json:"app_code"` Enabled bool `json:"enabled"` Tiers []tierDTO `json:"tiers"` UpdatedByAdminID int64 `json:"updated_by_admin_id"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type tierDTO struct { TierID int64 `json:"tier_id"` TierCode string `json:"tier_code"` TierName string `json:"tier_name"` ThresholdCoinSpent int64 `json:"threshold_coin_spent"` RewardCoinAmount int64 `json:"reward_coin_amount"` Status string `json:"status"` SortOrder int32 `json:"sort_order"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type settlementDTO struct { SettlementID string `json:"settlement_id"` RoomID string `json:"room_id"` OwnerUserID int64 `json:"owner_user_id,string"` OwnerUser *userDTO `json:"owner_user,omitempty"` PeriodStartMS int64 `json:"period_start_ms"` PeriodEndMS int64 `json:"period_end_ms"` CoinSpent int64 `json:"coin_spent"` TierID int64 `json:"tier_id"` TierCode string `json:"tier_code"` ThresholdCoinSpent int64 `json:"threshold_coin_spent"` RewardCoinAmount int64 `json:"reward_coin_amount"` Status string `json:"status"` WalletCommandID string `json:"wallet_command_id"` WalletTransactionID string `json:"wallet_transaction_id"` FailureReason string `json:"failure_reason"` SettledAtMS int64 `json:"settled_at_ms"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type userDTO struct { UserID int64 `json:"user_id,string"` DisplayUserID string `json:"display_user_id"` Username string `json:"username"` Avatar string `json:"avatar"` } func (h *Handler) GetConfig(c *gin.Context) { // 后台服务只透传到 activity-service 获取当前 App 配置;活动规则 owner 不在 admin-server 内部复制。 resp, err := h.activity.GetRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.GetRoomTurnoverRewardConfigRequest{Meta: h.meta(c)}) if err != nil { response.ServerError(c, "获取房间流水奖励配置失败") return } response.OK(c, configFromProto(resp.GetConfig())) } func (h *Handler) UpdateConfig(c *gin.Context) { var req configRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "房间流水奖励配置参数不正确") return } tiers := make([]*activityv1.RoomTurnoverRewardTier, 0, len(req.Tiers)) for _, tier := range req.Tiers { // 后台只做输入清洗和操作者透传;阈值递增、奖励金额和启用状态等业务校验统一由 activity-service 执行。 tiers = append(tiers, &activityv1.RoomTurnoverRewardTier{ TierId: tier.TierID, TierCode: strings.TrimSpace(tier.TierCode), TierName: strings.TrimSpace(tier.TierName), ThresholdCoinSpent: tier.ThresholdCoinSpent, RewardCoinAmount: tier.RewardCoinAmount, Status: strings.TrimSpace(tier.Status), SortOrder: tier.SortOrder, }) } resp, err := h.activity.UpdateRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.UpdateRoomTurnoverRewardConfigRequest{ Meta: h.meta(c), Enabled: req.Enabled, Tiers: tiers, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } item := configFromProto(resp.GetConfig()) // 配置修改必须留下后台审计资源 ID,后续排查结算金额变化时可以回到具体 App 配置记录。 shared.OperationLogWithResourceID(c, h.audit, "update-room-turnover-reward", "room_turnover_reward_configs", item.AppCode, "success", "") response.OK(c, item) } func (h *Handler) ListSettlements(c *gin.Context) { // 列表筛选沿用通用 ListOptions:status 过滤结算状态,keyword 用作 room_id 精确查询,避免后台接口引入模糊扫表。 options := shared.ListOptions(c) ownerKeyword := strings.TrimSpace(firstQuery(c, "owner_user_keyword", "ownerUserKeyword")) ownerUserID, ownerMatched, ownerOK := h.resolveOwnerUserID(c.Request.Context(), ownerKeyword) if !ownerOK { response.ServerError(c, "查询房主信息失败") return } if !ownerMatched { ownerUserID = queryInt64(c, "owner_user_id", "ownerUserId") } if !ownerMatched && ownerKeyword != "" { response.OK(c, response.Page{Items: []settlementDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } resp, err := h.activity.ListRoomTurnoverRewardSettlements(c.Request.Context(), &activityv1.ListRoomTurnoverRewardSettlementsRequest{ Meta: h.meta(c), Status: strings.TrimSpace(options.Status), RoomId: strings.TrimSpace(options.Keyword), OwnerUserId: ownerUserID, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取房间流水奖励结算记录失败") return } items := make([]settlementDTO, 0, len(resp.GetSettlements())) for _, settlement := range resp.GetSettlements() { items = append(items, settlementFromProto(settlement)) } // activity-service 返回的是结算事实快照;房主昵称、头像和短号只服务后台展示,所以在 admin-server 按当前页 owner_user_id 批量补齐。 if err := h.fillOwnerUsers(c.Request.Context(), items); err != nil { response.ServerError(c, "补全房主信息失败") return } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) RetrySettlement(c *gin.Context) { settlementID := strings.TrimSpace(c.Param("settlement_id")) if settlementID == "" { response.BadRequest(c, "结算记录 ID 不正确") return } // retry 只把指定 settlement 交还 activity-service;是否重新取房主、是否调用钱包和是否幂等都由活动服务闭环处理。 resp, err := h.activity.RetryRoomTurnoverRewardSettlement(c.Request.Context(), &activityv1.RetryRoomTurnoverRewardSettlementRequest{ Meta: h.meta(c), SettlementId: settlementID, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } item := settlementFromProto(resp.GetSettlement()) shared.OperationLogWithResourceID(c, h.audit, "retry-room-turnover-reward", "room_turnover_reward_settlements", settlementID, "success", "") response.OK(c, item) } func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { return &activityv1.RequestMeta{ // 使用后台请求 ID 串起 admin-server、activity-service 和 wallet-service 日志,AppCode 来自当前后台上下文。 RequestId: middleware.CurrentRequestID(c), Caller: "admin-server", AppCode: appctx.FromContext(c.Request.Context()), SentAtMs: time.Now().UnixMilli(), } } func (h *Handler) resolveOwnerUserID(ctx context.Context, keyword string) (int64, bool, bool) { if keyword == "" { return 0, false, true } if h.userDB == nil { // userDB 缺失时只允许正整数长 ID 精确筛选,避免把短号或昵称误当成 settlement.owner_user_id 查询。 userID, matched, err := shared.ResolveExactUserIDOrNumericFallback(ctx, nil, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) return userID, matched, err == nil } // 表头筛选先把运营输入解析成唯一房主 user_id,再交给 activity-service 的 owner_user_id 条件,保证分页 total 和列表数据同源。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { return 0, false, false } return userID, matched, true } func (h *Handler) fillOwnerUsers(ctx context.Context, settlements []settlementDTO) error { if len(settlements) == 0 { return nil } ids := collectOwnerUserIDs(settlements) if len(ids) == 0 { return nil } users, err := h.loadOwnerUsers(ctx, ids) if err != nil { return err } for index := range settlements { if user, ok := users[settlements[index].OwnerUserID]; ok { settlements[index].OwnerUser = &user continue } // 历史结算即使用户资料缺失,也必须保留收款人长 ID,方便后台继续追踪发奖对象。 settlements[index].OwnerUser = &userDTO{UserID: settlements[index].OwnerUserID} } return nil } func (h *Handler) loadOwnerUsers(ctx context.Context, ids []int64) (map[int64]userDTO, error) { if h.userDB == nil || len(ids) == 0 { return map[int64]userDTO{}, nil } args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...) rows, err := h.userDB.QueryContext(ctx, ` SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') FROM users WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`, args..., ) if err != nil { return nil, err } defer rows.Close() users := make(map[int64]userDTO, len(ids)) for rows.Next() { var user userDTO if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil { return nil, err } users[user.UserID] = user } if err := rows.Err(); err != nil { return nil, err } return users, nil } func configFromProto(config *activityv1.RoomTurnoverRewardConfig) configDTO { if config == nil { return configDTO{Tiers: []tierDTO{}} } tiers := make([]tierDTO, 0, len(config.GetTiers())) for _, tier := range config.GetTiers() { tiers = append(tiers, tierFromProto(tier)) } return configDTO{ AppCode: config.GetAppCode(), Enabled: config.GetEnabled(), Tiers: tiers, UpdatedByAdminID: config.GetUpdatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), UpdatedAtMS: config.GetUpdatedAtMs(), } } func collectOwnerUserIDs(settlements []settlementDTO) []int64 { seen := make(map[int64]struct{}, len(settlements)) ids := make([]int64, 0, len(settlements)) for _, settlement := range settlements { if settlement.OwnerUserID <= 0 { continue } if _, ok := seen[settlement.OwnerUserID]; ok { continue } seen[settlement.OwnerUserID] = struct{}{} ids = append(ids, settlement.OwnerUserID) } return ids } func int64Args(ids []int64) []any { args := make([]any, 0, len(ids)) for _, id := range ids { args = append(args, id) } return args } func placeholders(count int) string { if count <= 0 { return "" } parts := make([]string, count) for i := range parts { parts[i] = "?" } return strings.Join(parts, ",") } func queryInt64(c *gin.Context, keys ...string) int64 { value := strings.TrimSpace(firstQuery(c, keys...)) if value == "" { return 0 } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed < 0 { return 0 } return parsed } func firstQuery(c *gin.Context, keys ...string) string { for _, key := range keys { if value := c.Query(key); value != "" { return value } } return "" } func tierFromProto(tier *activityv1.RoomTurnoverRewardTier) tierDTO { if tier == nil { return tierDTO{} } return tierDTO{ TierID: tier.GetTierId(), TierCode: tier.GetTierCode(), TierName: tier.GetTierName(), ThresholdCoinSpent: tier.GetThresholdCoinSpent(), RewardCoinAmount: tier.GetRewardCoinAmount(), Status: tier.GetStatus(), SortOrder: tier.GetSortOrder(), CreatedAtMS: tier.GetCreatedAtMs(), UpdatedAtMS: tier.GetUpdatedAtMs(), } } func settlementFromProto(settlement *activityv1.RoomTurnoverRewardSettlement) settlementDTO { if settlement == nil { return settlementDTO{} } return settlementDTO{ SettlementID: settlement.GetSettlementId(), RoomID: settlement.GetRoomId(), OwnerUserID: settlement.GetOwnerUserId(), PeriodStartMS: settlement.GetPeriodStartMs(), PeriodEndMS: settlement.GetPeriodEndMs(), CoinSpent: settlement.GetCoinSpent(), TierID: settlement.GetTierId(), TierCode: settlement.GetTierCode(), ThresholdCoinSpent: settlement.GetThresholdCoinSpent(), RewardCoinAmount: settlement.GetRewardCoinAmount(), Status: settlement.GetStatus(), WalletCommandID: settlement.GetWalletCommandId(), WalletTransactionID: settlement.GetWalletTransactionId(), FailureReason: settlement.GetFailureReason(), SettledAtMS: settlement.GetSettledAtMs(), CreatedAtMS: settlement.GetCreatedAtMs(), UpdatedAtMS: settlement.GetUpdatedAtMs(), } }