2026-06-25 17:07:01 +08:00

384 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
// 列表筛选沿用通用 ListOptionsstatus 过滤结算状态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(),
}
}