389 lines
14 KiB
Go
389 lines
14 KiB
Go
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"))
|
||
ownerFilter := shared.UserIdentityFilterFromQuery(c, "owner")
|
||
ownerUserID, ownerMatched, ownerOK := h.resolveOwnerUserID(c.Request.Context(), ownerKeyword, ownerFilter)
|
||
if !ownerOK {
|
||
response.ServerError(c, "查询房主信息失败")
|
||
return
|
||
}
|
||
if !ownerMatched {
|
||
ownerUserID = queryInt64(c, "owner_user_id", "ownerUserId")
|
||
}
|
||
if !ownerMatched && (ownerKeyword != "" || !ownerFilter.IsEmpty()) {
|
||
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, filter shared.UserIdentityFilter) (int64, bool, bool) {
|
||
if filter.IsEmpty() && keyword == "" {
|
||
return 0, false, true
|
||
}
|
||
if !filter.IsEmpty() {
|
||
userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli())
|
||
return userID, matched, err == nil
|
||
}
|
||
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(),
|
||
}
|
||
}
|