修复房间奖励
This commit is contained in:
parent
3c66c5718a
commit
d0bb8a496c
@ -293,7 +293,7 @@ func main() {
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(userDB, roomClient, robotClient, auditHandler),
|
||||
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
||||
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
||||
|
||||
@ -136,7 +136,12 @@ func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomReques
|
||||
if s.roomClient == nil {
|
||||
return RobotRoom{}, fmt.Errorf("room service client is not configured")
|
||||
}
|
||||
if _, err := normalizeCreateRobotRoomRequest(req); err != nil {
|
||||
normalized, err := normalizeCreateRobotRoomRequest(req)
|
||||
if err != nil {
|
||||
return RobotRoom{}, err
|
||||
}
|
||||
req = normalized
|
||||
if err := s.applyOwnerProfileToRobotRoomRequest(ctx, &req); err != nil {
|
||||
return RobotRoom{}, err
|
||||
}
|
||||
created, err := s.roomClient.CreateRobotRoom(ctx, roomclient.CreateRobotRoomRequest{
|
||||
@ -168,6 +173,40 @@ func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomReques
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) applyOwnerProfileToRobotRoomRequest(ctx context.Context, req *createRobotRoomRequest) error {
|
||||
if s.userDB == nil {
|
||||
return fmt.Errorf("user database is not configured")
|
||||
}
|
||||
owners, err := s.queryRoomOwners(ctx, []int64{req.OwnerRobotUserID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, ok := owners[req.OwnerRobotUserID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: 房主机器人资料不存在", ErrInvalidArgument)
|
||||
}
|
||||
roomName, roomAvatar, err := robotRoomProfileFromOwner(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 机器人房间对外展示必须跟随房主机器人资料,避免后台表单或脚本传入的自定义值让房间卡片和房主身份不一致。
|
||||
req.RoomName = roomName
|
||||
req.RoomAvatar = roomAvatar
|
||||
return nil
|
||||
}
|
||||
|
||||
func robotRoomProfileFromOwner(owner RoomOwner) (string, string, error) {
|
||||
name := strings.TrimSpace(owner.Username)
|
||||
avatar := strings.TrimSpace(owner.Avatar)
|
||||
if name == "" {
|
||||
return "", "", fmt.Errorf("%w: 房主机器人名称不能为空", ErrInvalidArgument)
|
||||
}
|
||||
if avatar == "" {
|
||||
return "", "", fmt.Errorf("%w: 房主机器人头像不能为空", ErrInvalidArgument)
|
||||
}
|
||||
return name, avatar, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetRobotRoomStatus(ctx context.Context, roomID string, status string, actor shared.Actor) (RobotRoom, error) {
|
||||
if s.roomClient == nil {
|
||||
return RobotRoom{}, fmt.Errorf("room service client is not configured")
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package roomadmin
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
|
||||
tests := []struct {
|
||||
@ -39,3 +42,35 @@ func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) {
|
||||
name, avatar, err := robotRoomProfileFromOwner(RoomOwner{
|
||||
Username: " Robot Host ",
|
||||
Avatar: " https://cdn.example.com/robot.png ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("robot room owner profile should be valid: %v", err)
|
||||
}
|
||||
if name != "Robot Host" || avatar != "https://cdn.example.com/robot.png" {
|
||||
t.Fatalf("owner profile mismatch: name=%q avatar=%q", name, avatar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
owner RoomOwner
|
||||
}{
|
||||
{name: "missing_name", owner: RoomOwner{Avatar: "https://cdn.example.com/robot.png"}},
|
||||
{name: "missing_avatar", owner: RoomOwner{Username: "Robot Host"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, err := robotRoomProfileFromOwner(test.owner)
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("expected ErrInvalidArgument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package roomturnoverreward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -16,11 +19,12 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
activity activityclient.Client
|
||||
userDB *sql.DB
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{activity: activity, audit: audit}
|
||||
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{activity: activity, userDB: userDB, audit: audit}
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
@ -50,23 +54,31 @@ type tierDTO struct {
|
||||
}
|
||||
|
||||
type settlementDTO struct {
|
||||
SettlementID string `json:"settlement_id"`
|
||||
RoomID string `json:"room_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"`
|
||||
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"`
|
||||
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) {
|
||||
@ -117,12 +129,26 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||
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),
|
||||
Page: int32(options.Page),
|
||||
PageSize: int32(options.PageSize),
|
||||
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, "获取房间流水奖励结算记录失败")
|
||||
@ -132,6 +158,11 @@ func (h *Handler) ListSettlements(c *gin.Context) {
|
||||
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()})
|
||||
}
|
||||
|
||||
@ -166,6 +197,100 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||
}
|
||||
}
|
||||
|
||||
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 查询。
|
||||
if parsed, err := strconv.ParseInt(keyword, 10, 64); err == nil && parsed > 0 {
|
||||
return parsed, true, true
|
||||
}
|
||||
return 0, false, true
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
|
||||
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
|
||||
args := append([]any{appCode}, matchArgs...)
|
||||
args = append(args, orderArgs...)
|
||||
// 表头筛选先把运营输入解析成唯一房主 user_id,再交给 activity-service 的 owner_user_id 条件,保证分页 total 和列表数据同源。
|
||||
rows, err := h.userDB.QueryContext(ctx, `
|
||||
SELECT u.user_id
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND `+matchSQL+`
|
||||
ORDER BY
|
||||
`+orderSQL+`,
|
||||
u.user_id DESC
|
||||
LIMIT 1`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, false, rows.Err() == nil
|
||||
}
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
return userID, true, rows.Err() == nil
|
||||
}
|
||||
|
||||
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{}}
|
||||
@ -184,6 +309,62 @@ func configFromProto(config *activityv1.RoomTurnoverRewardConfig) configDTO {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@ -21,7 +21,7 @@ const (
|
||||
GrantSourceAgencyOpening = "agency_opening"
|
||||
)
|
||||
|
||||
// Reward 是代理开业流水档位;RankNo 只保留为档位展示顺序和旧字段兼容,结算命中只看 ThresholdCoinSpent。
|
||||
// Reward 是代理开业贡献档位;RankNo 只保留为档位展示顺序和旧字段兼容,结算命中只看 ThresholdCoinSpent。
|
||||
type Reward struct {
|
||||
AppCode string
|
||||
CycleID string
|
||||
@ -124,6 +124,7 @@ type Qualification struct {
|
||||
}
|
||||
|
||||
// GiftEvent is the room-service gift fact projected into the applicant room owner's opening revenue.
|
||||
// CoinSpent keeps the legacy field name but stores RoomGiftSent.gift_value/HeatValue.
|
||||
type GiftEvent struct {
|
||||
EventID string
|
||||
RoomID string
|
||||
|
||||
@ -13,7 +13,7 @@ const (
|
||||
EventStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Tier 是后台配置的房间流水奖励档位,结算只发命中的最高有效档。
|
||||
// Tier 是后台配置的房间贡献奖励档位,结算只发命中的最高有效档。
|
||||
type Tier struct {
|
||||
TierID int64
|
||||
TierCode string
|
||||
@ -36,7 +36,7 @@ type Config struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// Progress 是单房间单 UTC 周期的金币流水聚合。
|
||||
// Progress 是单房间单贡献周期的 HeatValue 聚合;CoinSpent 是历史字段名,运行值为房间贡献值。
|
||||
type Progress struct {
|
||||
AppCode string
|
||||
RoomID string
|
||||
@ -48,7 +48,7 @@ type Progress struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// RoomGiftEvent 是 room-service RoomGiftSent 事实映射后的最小聚合输入。
|
||||
// RoomGiftEvent 是 room-service RoomGiftSent 事实映射后的最小聚合输入;CoinSpent 字段承载 gift_value/HeatValue。
|
||||
type RoomGiftEvent struct {
|
||||
EventID string
|
||||
EventType string
|
||||
@ -64,7 +64,7 @@ type EventResult struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
// Settlement 是某个房间单个 UTC 周期的奖励发放事实。
|
||||
// Settlement 是某个房间单个贡献周期的奖励发放事实;CoinSpent 字段承载周期 HeatValue。
|
||||
type Settlement struct {
|
||||
SettlementID string
|
||||
AppCode string
|
||||
|
||||
@ -199,7 +199,8 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
return 0, err
|
||||
}
|
||||
roomID := strings.TrimSpace(envelope.GetRoomId())
|
||||
if gift.GetIsRobotGift() || roomID == "" || gift.GetCoinSpent() <= 0 {
|
||||
contributionValue := gift.GetGiftValue()
|
||||
if gift.GetIsRobotGift() || roomID == "" || contributionValue <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||||
@ -212,8 +213,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
RoomID: roomID,
|
||||
RoomOwnerUserID: ownerUserID,
|
||||
TargetUserID: gift.GetTargetUserId(),
|
||||
CoinSpent: gift.GetCoinSpent(),
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
// 开业流水只消费 wallet 结算后的 HeatValue,也就是 RoomGiftSent.gift_value;原始扣费 coin_spent 不再参与奖励达标。
|
||||
CoinSpent: contributionValue,
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -81,6 +81,7 @@ func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) {
|
||||
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
|
||||
SenderUserId: 9,
|
||||
TargetUserId: 88,
|
||||
GiftValue: 900,
|
||||
CoinSpent: 1200,
|
||||
})
|
||||
if err != nil {
|
||||
@ -103,7 +104,7 @@ func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) {
|
||||
if room.roomID != "room-1" {
|
||||
t.Fatalf("expected room lookup by event room_id, got %q", room.roomID)
|
||||
}
|
||||
if repo.consumed.RoomOwnerUserID != 42 || repo.consumed.TargetUserID != 88 || repo.consumed.CoinSpent != 1200 {
|
||||
if repo.consumed.RoomOwnerUserID != 42 || repo.consumed.TargetUserID != 88 || repo.consumed.CoinSpent != 900 {
|
||||
t.Fatalf("unexpected consumed event: %+v", repo.consumed)
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ type RoomQueryClient interface {
|
||||
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
|
||||
}
|
||||
|
||||
// Service 承载房间流水奖励 App 查询、后台配置、事件聚合和每周结算。
|
||||
// Service 承载房间贡献奖励 App 查询、后台配置、事件聚合和每周结算。
|
||||
type Service struct {
|
||||
repository Repository
|
||||
wallet WalletClient
|
||||
@ -114,7 +114,7 @@ func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ow
|
||||
config.Tiers = sortTiersForDisplay(config.Tiers)
|
||||
result.Config = config
|
||||
} else {
|
||||
// 未配置不是异常:后台首次进入或活动未上线时,H5 仍需要周期时间和空档位来渲染默认态。
|
||||
// 未配置不是异常:后台首次进入或活动未上线时,H5 仍需要贡献周期时间和空档位来渲染默认态。
|
||||
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
|
||||
}
|
||||
if result.RoomID == "" {
|
||||
@ -141,7 +141,7 @@ func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ow
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HandleRoomEvent 把 room-service 已提交送礼事实聚合到对应 UTC 周期房间流水。
|
||||
// HandleRoomEvent 把 room-service 已提交送礼事实聚合到对应贡献周期房间贡献值。
|
||||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (domain.EventResult, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.EventResult{}, err
|
||||
@ -160,8 +160,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
if gift.GetIsRobotGift() {
|
||||
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||||
}
|
||||
if strings.TrimSpace(envelope.GetRoomId()) == "" || gift.GetCoinSpent() <= 0 {
|
||||
// 缺房间或 0 金币不会形成有效房间贡献;这类事件本身没有可补偿数据,跳过比失败重放更安全。
|
||||
contributionValue := gift.GetGiftValue()
|
||||
if strings.TrimSpace(envelope.GetRoomId()) == "" || contributionValue <= 0 {
|
||||
// 房间流水奖励只消费 wallet 结算后的 HeatValue,也就是 RoomGiftSent.gift_value;原始扣费 coin_spent 不再参与达标口径。
|
||||
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||||
}
|
||||
occurred := time.UnixMilli(envelope.GetOccurredAtMs()).UTC()
|
||||
@ -177,7 +178,7 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
EventType: envelope.GetEventType(),
|
||||
AppCode: envelope.GetAppCode(),
|
||||
RoomID: strings.TrimSpace(envelope.GetRoomId()),
|
||||
CoinSpent: gift.GetCoinSpent(),
|
||||
CoinSpent: contributionValue,
|
||||
OccurredAtMS: occurred.UnixMilli(),
|
||||
}, start.UnixMilli(), end.UnixMilli(), s.now().UTC().UnixMilli())
|
||||
}
|
||||
@ -200,7 +201,7 @@ func (s *Service) ListSettlements(ctx context.Context, query domain.SettlementQu
|
||||
return s.repository.ListRoomTurnoverRewardSettlements(ctx, query)
|
||||
}
|
||||
|
||||
// ProcessSettlementBatch 先为上一 UTC 周期补齐 settlement,再处理 pending 发币。
|
||||
// ProcessSettlementBatch 先为上一完整贡献周期补齐 settlement,再处理 pending 发币。
|
||||
func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (domain.BatchResult, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.BatchResult{}, err
|
||||
@ -216,7 +217,7 @@ func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (do
|
||||
// 活动未配置或关闭时不创建 settlement,也不触碰历史 pending;运营重新开启后再由 cron 正常补偿。
|
||||
return domain.BatchResult{}, nil
|
||||
}
|
||||
// 只结算上一个完整 UTC 周期;当前周仍在持续接收 RoomGiftSent,不能提前冻结或清空。
|
||||
// 只结算上一个完整贡献周期;当前周期仍在持续接收 RoomGiftSent,不能提前冻结或清空。
|
||||
periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
|
||||
result := domain.BatchResult{}
|
||||
progresses, err := s.repository.ListUnsettledRoomTurnoverRewardProgress(ctx, periodStart.UnixMilli(), batchSize)
|
||||
@ -500,15 +501,19 @@ func MatchHighestTier(tiers []domain.Tier, coinSpent int64) (domain.Tier, bool)
|
||||
return matched, found
|
||||
}
|
||||
|
||||
// WeekWindow 返回给定时间所在 UTC 周一 00:00 到下一周一 00:00 的左闭右开窗口。
|
||||
// WeekWindow 返回给定时间所在 UTC 周一 01:00 到下一周一 01:00 的左闭右开窗口。
|
||||
func WeekWindow(now time.Time) (time.Time, time.Time) {
|
||||
utc := now.UTC()
|
||||
dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||||
// 贡献周期比 UTC 0 点延后一小时切换,确保 00:00 后的结算任务仍能稳定处理上一周期榜单。
|
||||
if utc.Before(dayStart.Add(time.Hour)) {
|
||||
dayStart = dayStart.AddDate(0, 0, -1)
|
||||
}
|
||||
weekday := int(dayStart.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
start := dayStart.AddDate(0, 0, 1-weekday)
|
||||
start := dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour)
|
||||
return start, start.AddDate(0, 0, 7)
|
||||
}
|
||||
|
||||
|
||||
@ -6,12 +6,15 @@ import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||||
)
|
||||
|
||||
func TestWeekWindowUsesUTCMondayBoundary(t *testing.T) {
|
||||
func TestWeekWindowUsesUTCOneMondayBoundary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
now string
|
||||
@ -19,28 +22,34 @@ func TestWeekWindowUsesUTCMondayBoundary(t *testing.T) {
|
||||
wantEnd string
|
||||
}{
|
||||
{
|
||||
name: "monday zero belongs to new week",
|
||||
name: "monday zero still belongs to previous week",
|
||||
now: "2026-06-01T00:00:00Z",
|
||||
wantStart: "2026-06-01T00:00:00Z",
|
||||
wantEnd: "2026-06-08T00:00:00Z",
|
||||
wantStart: "2026-05-25T01:00:00Z",
|
||||
wantEnd: "2026-06-01T01:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "monday one starts new contribution week",
|
||||
now: "2026-06-01T01:00:00Z",
|
||||
wantStart: "2026-06-01T01:00:00Z",
|
||||
wantEnd: "2026-06-08T01:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "sunday belongs to current week",
|
||||
now: "2026-06-07T23:59:59Z",
|
||||
wantStart: "2026-06-01T00:00:00Z",
|
||||
wantEnd: "2026-06-08T00:00:00Z",
|
||||
wantStart: "2026-06-01T01:00:00Z",
|
||||
wantEnd: "2026-06-08T01:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "cross month keeps utc week",
|
||||
now: "2026-07-01T12:30:00Z",
|
||||
wantStart: "2026-06-29T00:00:00Z",
|
||||
wantEnd: "2026-07-06T00:00:00Z",
|
||||
wantStart: "2026-06-29T01:00:00Z",
|
||||
wantEnd: "2026-07-06T01:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "cross year keeps utc week",
|
||||
now: "2027-01-01T08:00:00Z",
|
||||
wantStart: "2026-12-28T00:00:00Z",
|
||||
wantEnd: "2027-01-04T00:00:00Z",
|
||||
wantStart: "2026-12-28T01:00:00Z",
|
||||
wantEnd: "2027-01-04T01:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
@ -59,11 +68,11 @@ func TestWeekWindowUsesUTCMondayBoundary(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPreviousWeekWindowReturnsCompletedUTCWeek(t *testing.T) {
|
||||
start, end := PreviousWeekWindow(mustParseTime(t, "2026-06-08T00:00:00Z"))
|
||||
if got := start.Format(time.RFC3339); got != "2026-06-01T00:00:00Z" {
|
||||
start, end := PreviousWeekWindow(mustParseTime(t, "2026-06-08T01:00:00Z"))
|
||||
if got := start.Format(time.RFC3339); got != "2026-06-01T01:00:00Z" {
|
||||
t.Fatalf("unexpected previous start: %s", got)
|
||||
}
|
||||
if got := end.Format(time.RFC3339); got != "2026-06-08T00:00:00Z" {
|
||||
if got := end.Format(time.RFC3339); got != "2026-06-08T01:00:00Z" {
|
||||
t.Fatalf("unexpected previous end: %s", got)
|
||||
}
|
||||
}
|
||||
@ -118,6 +127,39 @@ func TestRetrySettlementRejectsTierNotMatchedWithoutGrantSideEffects(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) {
|
||||
repo := &roomGiftEventRepository{}
|
||||
svc := New(repo, nil, nil)
|
||||
svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-03T12:00:00Z") })
|
||||
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
|
||||
GiftValue: 900,
|
||||
CoinSpent: 1200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal gift: %v", err)
|
||||
}
|
||||
result, err := svc.HandleRoomEvent(context.Background(), &roomeventsv1.EventEnvelope{
|
||||
EventId: "room-gift-heat-value",
|
||||
EventType: "RoomGiftSent",
|
||||
RoomId: "room-1",
|
||||
AppCode: appcode.Default,
|
||||
OccurredAtMs: mustParseTime(t, "2026-06-03T10:00:00Z").UnixMilli(),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleRoomEvent returned error: %v", err)
|
||||
}
|
||||
if result.Status != domain.EventStatusConsumed {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if repo.event.CoinSpent != 900 {
|
||||
t.Fatalf("room turnover reward must consume gift_value/HeatValue, got %+v", repo.event)
|
||||
}
|
||||
if got := time.UnixMilli(repo.periodStartMS).UTC().Format(time.RFC3339); got != "2026-06-01T01:00:00Z" {
|
||||
t.Fatalf("unexpected period start: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
@ -134,6 +176,67 @@ type retryBoundaryRepository struct {
|
||||
failCalls int
|
||||
}
|
||||
|
||||
type roomGiftEventRepository struct {
|
||||
event domain.RoomGiftEvent
|
||||
periodStartMS int64
|
||||
periodEndMS int64
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) {
|
||||
return domain.Config{}, false, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) UpdateRoomTurnoverRewardConfig(context.Context, domain.Config, int64) (domain.Config, error) {
|
||||
return domain.Config{}, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) ConsumeRoomTurnoverGiftEvent(_ context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, _ int64) (domain.EventResult, error) {
|
||||
r.event = event
|
||||
r.periodStartMS = periodStartMS
|
||||
r.periodEndMS = periodEndMS
|
||||
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) GetRoomTurnoverRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) {
|
||||
return domain.Progress{}, false, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) {
|
||||
return domain.Settlement{}, false, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) ListRoomTurnoverRewardSettlements(context.Context, domain.SettlementQuery) ([]domain.Settlement, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) ListUnsettledRoomTurnoverRewardProgress(context.Context, int64, int) ([]domain.Progress, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) InsertRoomTurnoverRewardSettlement(context.Context, domain.Settlement, int64) (domain.Settlement, bool, error) {
|
||||
return domain.Settlement{}, false, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) ListPendingRoomTurnoverRewardSettlements(context.Context, int) ([]domain.Settlement, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) GetRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) {
|
||||
return domain.Settlement{}, false, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) ResetRoomTurnoverRewardSettlementForRetry(context.Context, string, int64, int64) (domain.Settlement, error) {
|
||||
return domain.Settlement{}, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) MarkRoomTurnoverRewardSettlementGranted(context.Context, string, string, int64) (domain.Settlement, error) {
|
||||
return domain.Settlement{}, nil
|
||||
}
|
||||
|
||||
func (r *roomGiftEventRepository) MarkRoomTurnoverRewardSettlementFailed(context.Context, string, string, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *retryBoundaryRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) {
|
||||
panic("unexpected GetRoomTurnoverRewardConfig")
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
|
||||
rank_no INT NOT NULL COMMENT '档位序号',
|
||||
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中该档需要的代理房间流水金币',
|
||||
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中该档需要的代理房间贡献值 HeatValue',
|
||||
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '奖励金币',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
@ -45,7 +45,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
|
||||
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
|
||||
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
|
||||
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期内开业流水金币',
|
||||
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '历史字段名:周期内开业房间贡献值 HeatValue',
|
||||
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算命中的档位序号',
|
||||
reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值',
|
||||
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '结算奖励金币',
|
||||
@ -71,7 +71,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
|
||||
application_id VARCHAR(96) NOT NULL COMMENT '命中的申请 ID',
|
||||
agency_id BIGINT NOT NULL COMMENT '代理 ID',
|
||||
target_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID',
|
||||
score_delta BIGINT NOT NULL COMMENT '本次流水金币',
|
||||
score_delta BIGINT NOT NULL COMMENT '本次房间贡献值 HeatValue',
|
||||
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, source_event_id),
|
||||
|
||||
@ -17,7 +17,7 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error {
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
|
||||
tier_name VARCHAR(128) NOT NULL COMMENT '档位名称',
|
||||
threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间送礼金币流水',
|
||||
threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间贡献值 HeatValue',
|
||||
reward_coin_amount BIGINT NOT NULL COMMENT '奖励房主金币数',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT 'active/inactive',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
@ -33,7 +33,7 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error {
|
||||
event_type VARCHAR(96) NOT NULL COMMENT '事件类型',
|
||||
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||
coin_spent BIGINT NOT NULL COMMENT '本事件送礼金币消耗',
|
||||
coin_spent BIGINT NOT NULL COMMENT '历史字段名:本事件房间贡献值 HeatValue',
|
||||
status VARCHAR(32) NOT NULL COMMENT '消费状态',
|
||||
consumed_at_ms BIGINT NOT NULL COMMENT '消费时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
@ -46,7 +46,7 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error {
|
||||
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||
period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束',
|
||||
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '本周期房间送礼金币流水',
|
||||
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '历史字段名:本周期房间贡献值 HeatValue',
|
||||
last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
@ -60,7 +60,7 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error {
|
||||
owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主',
|
||||
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||
period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束',
|
||||
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '周期房间流水',
|
||||
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '历史字段名:周期房间贡献值 HeatValue',
|
||||
tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位 ID',
|
||||
tier_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '命中档位编码',
|
||||
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位阈值',
|
||||
|
||||
@ -47,6 +47,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '关闭操作管理员名称',
|
||||
closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '关闭时间,UTC epoch ms',
|
||||
heat BIGINT NOT NULL DEFAULT 0 COMMENT '热度值',
|
||||
weekly_contribution BIGINT NOT NULL DEFAULT 0 COMMENT '当前贡献周期房间贡献,周一 01:00 UTC 自动切新周期',
|
||||
contribution_week_start_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'weekly_contribution 所属 UTC 周一 01:00 毫秒',
|
||||
online_count INT NOT NULL DEFAULT 0 COMMENT '在线数量',
|
||||
seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量',
|
||||
occupied_seat_count INT NOT NULL DEFAULT 0 COMMENT '占用麦位数量',
|
||||
@ -59,6 +61,7 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id),
|
||||
KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间列表项表';
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ func (s *Service) AdminListRooms(ctx context.Context, req *roomv1.AdminListRooms
|
||||
OwnerUserID: req.GetOwnerUserId(),
|
||||
SortBy: strings.TrimSpace(req.GetSortBy()),
|
||||
SortDirection: strings.TrimSpace(req.GetSortDirection()),
|
||||
NowMS: s.clock.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -42,7 +43,7 @@ func (s *Service) AdminListRooms(ctx context.Context, req *roomv1.AdminListRooms
|
||||
|
||||
func (s *Service) AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest) (*roomv1.AdminGetRoomResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
room, exists, err := s.repository.AdminGetRoom(ctx, req.GetRoomId())
|
||||
room, exists, err := s.repository.AdminGetRoom(ctx, req.GetRoomId(), s.clock.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -354,6 +354,12 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
||||
LastGiftAtMS: now.UnixMilli(),
|
||||
},
|
||||
},
|
||||
roomWeeklyContribution: &RoomWeeklyContributionIncrement{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RoomID: current.RoomID,
|
||||
GiftValue: heatValue,
|
||||
OccurredMS: now.UnixMilli(),
|
||||
},
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
// 同步广播只选 GiftSent 主事件作为客户端房间系统消息,Heat/Rank 通过字段携带。
|
||||
EventID: giftEvents[0].EventID,
|
||||
@ -390,6 +396,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
||||
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
|
||||
result.roomGiftLeaderboard = nil
|
||||
result.roomUserGiftStats = nil
|
||||
result.roomWeeklyContribution = nil
|
||||
}
|
||||
return result, records, nil
|
||||
})
|
||||
|
||||
@ -79,3 +79,133 @@ func TestSendGiftWritesRoomGiftLeaderboardWithHeatValue(t *testing.T) {
|
||||
t.Fatalf("leaderboard must use heat value after global ratio, got %+v", leaderboard.increments[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoomContributionUsesCurrentUTCWeek(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{
|
||||
BillingReceiptId: "receipt-week-one",
|
||||
CoinSpent: 100,
|
||||
ChargeAmount: 100,
|
||||
HeatValue: 10,
|
||||
GiftTypeCode: "normal",
|
||||
},
|
||||
{
|
||||
BillingReceiptId: "receipt-week-two",
|
||||
CoinSpent: 70,
|
||||
ChargeAmount: 70,
|
||||
HeatValue: 7,
|
||||
GiftTypeCode: "normal",
|
||||
},
|
||||
}}
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-admin-weekly-contribution-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
Clock: now,
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
|
||||
roomID := "room-admin-weekly-contribution"
|
||||
senderID := int64(18101)
|
||||
targetID := int64(18102)
|
||||
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
||||
sendGiftForWeeklyContribution(t, ctx, svc, roomID, senderID, targetID, "cmd-week-one")
|
||||
|
||||
firstWeek, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
SortBy: "room_contribution",
|
||||
SortDirection: "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list first week rooms failed: %v", err)
|
||||
}
|
||||
if got := adminRoomHeatByID(firstWeek.GetRooms(), roomID); got != 10 {
|
||||
t.Fatalf("first week contribution mismatch: got %d want 10", got)
|
||||
}
|
||||
|
||||
now.now = time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
cutoverBuffer, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
SortBy: "room_contribution",
|
||||
SortDirection: "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list cutover buffer rooms failed: %v", err)
|
||||
}
|
||||
if got := adminRoomHeatByID(cutoverBuffer.GetRooms(), roomID); got != 10 {
|
||||
t.Fatalf("monday 00:00 UTC must keep previous contribution until UTC+1 cutover: got %d want 10", got)
|
||||
}
|
||||
|
||||
now.now = time.Date(2026, 6, 8, 1, 0, 0, 0, time.UTC)
|
||||
nextWeekBeforeGift, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
SortBy: "room_contribution",
|
||||
SortDirection: "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list next contribution week rooms failed: %v", err)
|
||||
}
|
||||
if got := adminRoomHeatByID(nextWeekBeforeGift.GetRooms(), roomID); got != 0 {
|
||||
t.Fatalf("next UTC+1 week must clear contribution before new gifts: got %d want 0", got)
|
||||
}
|
||||
|
||||
resp := sendGiftForWeeklyContribution(t, ctx, svc, roomID, senderID, targetID, "cmd-week-two")
|
||||
if resp.GetRoomHeat() != 17 {
|
||||
t.Fatalf("room heat must remain cumulative Room Cell state: got %d want 17", resp.GetRoomHeat())
|
||||
}
|
||||
nextWeekAfterGift, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
SortBy: "room_contribution",
|
||||
SortDirection: "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list next week rooms after gift failed: %v", err)
|
||||
}
|
||||
if got := adminRoomHeatByID(nextWeekAfterGift.GetRooms(), roomID); got != 7 {
|
||||
t.Fatalf("next UTC+1 week contribution must restart from new gifts: got %d want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func sendGiftForWeeklyContribution(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, senderID int64, targetID int64, commandID string) *roomv1.SendGiftResponse {
|
||||
t.Helper()
|
||||
|
||||
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: &roomv1.RequestMeta{
|
||||
RequestId: "req-" + commandID,
|
||||
CommandId: commandID,
|
||||
ActorUserId: senderID,
|
||||
RoomId: roomID,
|
||||
AppCode: appcode.Default,
|
||||
SentAtMs: time.Now().UTC().UnixMilli(),
|
||||
},
|
||||
TargetType: "user",
|
||||
TargetUserId: targetID,
|
||||
GiftId: "gift-weekly-contribution",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send gift %s failed: %v", commandID, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func adminRoomHeatByID(items []*roomv1.AdminRoomListItem, roomID string) int64 {
|
||||
for _, item := range items {
|
||||
if item.GetRoomId() == roomID {
|
||||
return item.GetHeat()
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@ -91,14 +91,15 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
LeaseToken: lease.LeaseToken,
|
||||
CreatedAtMS: now.UnixMilli(),
|
||||
},
|
||||
OutboxRecords: outboxRecords,
|
||||
RoomStatus: result.roomStatus,
|
||||
RoomSeatCount: result.roomSeatCount,
|
||||
RoomPasswordHash: result.roomPasswordHash,
|
||||
RoomMode: result.roomMode,
|
||||
VisibleRegionID: result.visibleRegionID,
|
||||
CloseInfo: result.closeInfo,
|
||||
RoomUserGiftStats: result.roomUserGiftStats,
|
||||
OutboxRecords: outboxRecords,
|
||||
RoomStatus: result.roomStatus,
|
||||
RoomSeatCount: result.roomSeatCount,
|
||||
RoomPasswordHash: result.roomPasswordHash,
|
||||
RoomMode: result.roomMode,
|
||||
VisibleRegionID: result.visibleRegionID,
|
||||
CloseInfo: result.closeInfo,
|
||||
RoomUserGiftStats: result.roomUserGiftStats,
|
||||
RoomWeeklyContribution: result.roomWeeklyContribution,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -73,6 +73,8 @@ type MutationCommit struct {
|
||||
CloseInfo *RoomCloseInfo
|
||||
// RoomUserGiftStats 是本次命令产生的房间用户送礼价值增量,必须和命令日志同事务提交。
|
||||
RoomUserGiftStats []RoomUserGiftStatIncrement
|
||||
// RoomWeeklyContribution 是后台房间贡献列的 UTC 周期读模型增量;它和命令日志同事务提交,避免送礼成功后榜单缺分。
|
||||
RoomWeeklyContribution *RoomWeeklyContributionIncrement
|
||||
}
|
||||
|
||||
// RoomUserGiftStatIncrement 表达当前房间内某个送礼用户的礼物价值增量。
|
||||
@ -84,6 +86,14 @@ type RoomUserGiftStatIncrement struct {
|
||||
LastGiftAtMS int64
|
||||
}
|
||||
|
||||
// RoomWeeklyContributionIncrement 表达一个房间在当前 UTC 周内新增的展示贡献。
|
||||
type RoomWeeklyContributionIncrement struct {
|
||||
AppCode string
|
||||
RoomID string
|
||||
GiftValue int64
|
||||
OccurredMS int64
|
||||
}
|
||||
|
||||
type RoomCloseInfo struct {
|
||||
Reason string
|
||||
AdminID uint64
|
||||
@ -200,6 +210,8 @@ type AdminRoomListQuery struct {
|
||||
OwnerUserID int64
|
||||
SortBy string
|
||||
SortDirection string
|
||||
// NowMS 用于把后台房间贡献收敛到当前 UTC 周;调用方传服务时钟,测试可以固定边界。
|
||||
NowMS int64
|
||||
}
|
||||
|
||||
type AdminRoomPinEntry struct {
|
||||
@ -615,7 +627,7 @@ type Repository interface {
|
||||
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
|
||||
ListRoomOnlineUsers(ctx context.Context, query RoomOnlineUserQuery) (RoomOnlineUserPage, error)
|
||||
AdminListRooms(ctx context.Context, query AdminRoomListQuery) ([]AdminRoomListEntry, int64, error)
|
||||
AdminGetRoom(ctx context.Context, roomID string) (AdminRoomListEntry, bool, error)
|
||||
AdminGetRoom(ctx context.Context, roomID string, nowMS int64) (AdminRoomListEntry, bool, error)
|
||||
AdminListRoomPins(ctx context.Context, query AdminRoomPinQuery) ([]AdminRoomPinEntry, int64, error)
|
||||
AdminCreateRoomPin(ctx context.Context, input AdminCreateRoomPinInput) (AdminRoomPinEntry, bool, error)
|
||||
AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (AdminRoomPinEntry, bool, error)
|
||||
|
||||
@ -253,11 +253,15 @@ func roomGiftLeaderboardWindow(period string, now time.Time) (time.Time, time.Ti
|
||||
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||
switch period {
|
||||
case roomGiftLeaderboardPeriodWeek:
|
||||
// 周房间贡献榜按 UTC 周一 01:00 切周,避免 00:00 结算任务消费上一周期时 Redis 周榜已经换 key。
|
||||
if now.UTC().Before(dayStart.Add(time.Hour)) {
|
||||
dayStart = dayStart.AddDate(0, 0, -1)
|
||||
}
|
||||
weekday := int(dayStart.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
start := dayStart.AddDate(0, 0, 1-weekday)
|
||||
start := dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour)
|
||||
return start, start.AddDate(0, 0, 7)
|
||||
case roomGiftLeaderboardPeriodMonth:
|
||||
start := time.Date(dayStart.Year(), dayStart.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@ -146,6 +146,8 @@ type mutationResult struct {
|
||||
roomGiftLeaderboard *RoomGiftLeaderboardIncrement
|
||||
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
|
||||
roomUserGiftStats []RoomUserGiftStatIncrement
|
||||
// roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。
|
||||
roomWeeklyContribution *RoomWeeklyContributionIncrement
|
||||
}
|
||||
|
||||
// New 初始化 room-service 领域服务。
|
||||
|
||||
@ -110,6 +110,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
closed_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
heat BIGINT NOT NULL DEFAULT 0,
|
||||
weekly_contribution BIGINT NOT NULL DEFAULT 0,
|
||||
contribution_week_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
online_count INT NOT NULL DEFAULT 0,
|
||||
seat_count INT NOT NULL DEFAULT 0,
|
||||
occupied_seat_count INT NOT NULL DEFAULT 0,
|
||||
@ -122,6 +124,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id),
|
||||
KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_user_presence (
|
||||
@ -371,6 +374,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
if err := r.ensureRoomAdminCloseSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureRoomWeeklyContributionSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureRoomUserPresenceSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -381,6 +387,44 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
return r.ensureOutboxRetrySchema(ctx)
|
||||
}
|
||||
|
||||
func (r *Repository) ensureRoomWeeklyContributionSchema(ctx context.Context) error {
|
||||
columns := []struct {
|
||||
column string
|
||||
statement string
|
||||
}{
|
||||
{
|
||||
column: "weekly_contribution",
|
||||
statement: `ALTER TABLE room_list_entries ADD COLUMN weekly_contribution BIGINT NOT NULL DEFAULT 0 AFTER heat`,
|
||||
},
|
||||
{
|
||||
column: "contribution_week_start_ms",
|
||||
statement: `ALTER TABLE room_list_entries ADD COLUMN contribution_week_start_ms BIGINT NOT NULL DEFAULT 0 AFTER weekly_contribution`,
|
||||
},
|
||||
}
|
||||
for _, item := range columns {
|
||||
hasColumn, err := r.columnExists(ctx, "room_list_entries", item.column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasColumn {
|
||||
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
hasIndex, err := r.indexExists(ctx, "room_list_entries", "idx_room_list_weekly_contribution")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasIndex {
|
||||
// 后台按房间贡献排序时只读当前 UTC 周贡献;索引用于收敛 active/region 过滤后的排序成本。
|
||||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD INDEX idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureRoomPinSchema(ctx context.Context) error {
|
||||
hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type")
|
||||
if err != nil {
|
||||
@ -995,6 +1039,38 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
||||
return err
|
||||
}
|
||||
}
|
||||
if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 {
|
||||
weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
|
||||
weeklyRoomID := strings.TrimSpace(weekly.RoomID)
|
||||
if weeklyRoomID == "" {
|
||||
weeklyRoomID = commit.Command.RoomID
|
||||
}
|
||||
occurredMS := weekly.OccurredMS
|
||||
if occurredMS <= 0 {
|
||||
occurredMS = commit.Command.CreatedAtMS
|
||||
}
|
||||
weekStartMS := roomContributionWeekStartMS(occurredMS)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE room_list_entries
|
||||
SET weekly_contribution = CASE
|
||||
WHEN contribution_week_start_ms = ? THEN weekly_contribution + ?
|
||||
ELSE ?
|
||||
END,
|
||||
contribution_week_start_ms = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE app_code = ? AND room_id = ?`,
|
||||
weekStartMS,
|
||||
weekly.GiftValue,
|
||||
weekly.GiftValue,
|
||||
weekStartMS,
|
||||
commit.Command.CreatedAtMS,
|
||||
weeklyAppCode,
|
||||
weeklyRoomID,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@ -2351,12 +2427,16 @@ func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.Admin
|
||||
if query.PageSize > 100 {
|
||||
query.PageSize = 100
|
||||
}
|
||||
if query.NowMS <= 0 {
|
||||
query.NowMS = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
where, args := adminRoomWhere(ctx, query)
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_list_entries rle `+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL()+where+` ORDER BY `+adminRoomOrderBy(query)+` LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
|
||||
contributionExpr := adminRoomContributionExpr(query.NowMS)
|
||||
rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL(contributionExpr)+where+` ORDER BY `+adminRoomOrderBy(query, contributionExpr)+` LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -2375,8 +2455,9 @@ func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.Admin
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AdminGetRoom(ctx context.Context, roomID string) (roomservice.AdminRoomListEntry, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx, adminRoomSelectSQL()+` WHERE rle.app_code = ? AND rle.room_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
|
||||
func (r *Repository) AdminGetRoom(ctx context.Context, roomID string, nowMS int64) (roomservice.AdminRoomListEntry, bool, error) {
|
||||
contributionExpr := adminRoomContributionExpr(nowMS)
|
||||
row := r.db.QueryRowContext(ctx, adminRoomSelectSQL(contributionExpr)+` WHERE rle.app_code = ? AND rle.room_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
|
||||
item, err := scanAdminRoom(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return roomservice.AdminRoomListEntry{}, false, nil
|
||||
@ -2387,12 +2468,12 @@ func (r *Repository) AdminGetRoom(ctx context.Context, roomID string) (roomservi
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
func adminRoomSelectSQL() string {
|
||||
func adminRoomSelectSQL(contributionExpr string) string {
|
||||
return `
|
||||
SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id,
|
||||
rle.title, rle.cover_url, rle.mode, rle.status, rle.close_reason,
|
||||
rle.closed_by_admin_id, rle.closed_by_admin_name, rle.closed_at_ms,
|
||||
rle.heat, rle.online_count, rle.seat_count, rle.occupied_seat_count,
|
||||
` + contributionExpr + ` AS room_contribution, rle.online_count, rle.seat_count, rle.occupied_seat_count,
|
||||
rle.sort_score, rle.created_at_ms, rle.updated_at_ms
|
||||
FROM room_list_entries rle `
|
||||
}
|
||||
@ -2429,19 +2510,42 @@ func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (
|
||||
return " WHERE " + strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
func adminRoomOrderBy(query roomservice.AdminRoomListQuery) string {
|
||||
func adminRoomOrderBy(query roomservice.AdminRoomListQuery, contributionExpr string) string {
|
||||
direction := "DESC"
|
||||
if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") {
|
||||
direction = "ASC"
|
||||
}
|
||||
switch strings.TrimSpace(query.SortBy) {
|
||||
case "room_contribution", "heat":
|
||||
return "rle.heat " + direction + ", rle.created_at_ms DESC, rle.room_id DESC"
|
||||
return contributionExpr + " " + direction + ", rle.created_at_ms DESC, rle.room_id DESC"
|
||||
default:
|
||||
return "rle.created_at_ms DESC, rle.room_id DESC"
|
||||
}
|
||||
}
|
||||
|
||||
func adminRoomContributionExpr(nowMS int64) string {
|
||||
weekStartMS := roomContributionWeekStartMS(nowMS)
|
||||
return "CASE WHEN rle.contribution_week_start_ms = " + strconv.FormatInt(weekStartMS, 10) + " THEN rle.weekly_contribution ELSE 0 END"
|
||||
}
|
||||
|
||||
func roomContributionWeekStartMS(nowMS int64) int64 {
|
||||
if nowMS <= 0 {
|
||||
nowMS = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
dayStart := time.UnixMilli(nowMS).UTC()
|
||||
dayStart = time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day(), 0, 0, 0, 0, time.UTC)
|
||||
// 房间贡献榜按 UTC 周一 01:00 切周,给 00:00 后的结算和榜单消费留出缓冲窗口。
|
||||
cutoff := dayStart.Add(time.Hour)
|
||||
if time.UnixMilli(nowMS).UTC().Before(cutoff) {
|
||||
dayStart = dayStart.AddDate(0, 0, -1)
|
||||
}
|
||||
weekday := int(dayStart.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
return dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
func scanAdminRoom(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomListEntry, error) {
|
||||
var item roomservice.AdminRoomListEntry
|
||||
err := scanner.Scan(
|
||||
@ -2501,7 +2605,7 @@ func (r *Repository) AdminListRoomPins(ctx context.Context, query roomservice.Ad
|
||||
|
||||
func (r *Repository) AdminCreateRoomPin(ctx context.Context, input roomservice.AdminCreateRoomPinInput) (roomservice.AdminRoomPinEntry, bool, error) {
|
||||
app := appcode.FromContext(ctx)
|
||||
room, exists, err := r.AdminGetRoom(ctx, input.RoomID)
|
||||
room, exists, err := r.AdminGetRoom(ctx, input.RoomID, input.NowMS)
|
||||
if err != nil || !exists {
|
||||
return roomservice.AdminRoomPinEntry{}, false, err
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user