zhx f3570384f2 Merge lucky gift dynamic v3 into test
# Conflicts:
#	api/proto/luckygift/v1/luckygift.pb.go
#	api/proto/luckygift/v1/luckygift.proto
#	api/proto/room/v1/room.pb.go
#	api/proto/wallet/v1/wallet.pb.go
#	services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go
#	services/lucky-gift-service/internal/service/luckygift/service.go
#	services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go
#	services/room-service/internal/room/service/gift_lucky.go
#	services/user-service/internal/service/auth/password.go
#	services/user-service/internal/service/auth/quick_account.go
#	services/user-service/internal/service/auth/third_party.go
#	services/user-service/internal/service/auth/token.go
2026-07-15 14:55:03 +08:00

315 lines
12 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 service
import (
"context"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
const (
luckyGiftStrategyFixedV2 = "fixed_v2"
luckyGiftStrategyDynamicV3 = "dynamic_v3"
)
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
if len(targetBillings) == 0 {
return nil, nil
}
if len(targetBillings) == 1 {
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0])
if err != nil {
return nil, err
}
return []*roomv1.LuckyGiftDrawResult{result}, nil
}
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings)
}
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*roomv1.LuckyGiftDrawResult, error) {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
// 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
if err != nil {
return nil, err
}
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: luckyMeta,
})
if err != nil {
return nil, err
}
if drawResp == nil || drawResp.GetResult() == nil {
return nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
}
// 同步返回只承载抽奖表现事实;返奖后的余额由 activity 在中奖落库后发送钱包私有 IM避免 HTTP 响应领先账务通知链路。
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
}
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))}
for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
if err != nil {
return nil, err
}
// 多人送礼只跨服务调用一次lucky-gift-service 在一个事务内按这个顺序推进锁和抽奖事实。
req.LuckyGifts = append(req.LuckyGifts, luckyMeta)
}
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
if err != nil {
return nil, err
}
if resp == nil || len(resp.GetResults()) != len(targetBillings) {
return nil, xerr.New(xerr.Unavailable, "lucky gift batch draw response is incomplete")
}
results := make([]*roomv1.LuckyGiftDrawResult, 0, len(targetBillings))
for index, result := range resp.GetResults() {
if result == nil {
return nil, xerr.New(xerr.Unavailable, "lucky gift batch draw result is empty")
}
results = append(results, luckyGiftResultFromProto(result, targetBillings[index].TargetUserID))
}
return results, nil
}
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*luckygiftv1.LuckyGiftMeta, error) {
paidAtMS := targetBilling.Billing.GetPaidAtMs()
if paidAtMS <= 0 && normalizeLuckyGiftStrategyVersion(cmd.LuckyGiftStrategyVersion) == luckyGiftStrategyDynamicV3 {
// dynamic_v3 所有时间窗口必须归属 wallet owner 的实际交易;缺字段通常表示 wallet 尚未滚动升级,
// 已扣费 saga 保持可恢复并等待 owner 回执补齐,绝不能拿 room mutate/recovery 时钟继续开奖。
return nil, xerr.New(xerr.Unavailable, "wallet paid_at_ms is required for dynamic lucky gift")
}
return &luckygiftv1.LuckyGiftMeta{
Meta: luckyGiftMetaFromRoom(ctx, meta),
CommandId: targetBilling.CommandID,
UserId: cmd.ActorUserID(),
TargetUserId: targetBilling.TargetUserID,
// DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
// 空值原样传给 ownerfixed_v2 兼容旧 JWTdynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。
DeviceId: cmd.DeviceID,
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: targetBilling.Billing.GetCoinSpent(),
// PaidAtMs 只来自当前目标的 wallet transactionbatch 每个 target 也保留自己的 owner 回执,
// 不使用聚合回执、Room Cell mutate 时钟或 gift_operation.created_at_ms。
PaidAtMs: paidAtMS,
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
// 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
// 充值分层和短时加权只消费 wallet 扣费回执里固化的 owner 快照room 不查询充值流水,也不按当前时间重新计算。
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(),
}, nil
}
func normalizeLuckyGiftStrategyVersion(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
// 旧 lucky-gift 版本没有返回 strategy_version它发布时只有 fixed_v2故仅该空值兼容成 fixed_v2。
return luckyGiftStrategyFixedV2
}
return value
}
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
if s.luckyGift == nil {
return false
}
switch strings.TrimSpace(giftTypeCode) {
case "lucky", "super_lucky":
return true
default:
return strings.TrimSpace(poolID) != ""
}
}
func (s *Service) withLuckyGiftSendLock(ctx context.Context, enabled bool, userID int64, fn func() error) error {
if !enabled || s == nil || s.luckyGiftSendLocker == nil {
return fn()
}
release, err := s.luckyGiftSendLocker.AcquireLuckyGiftSendLock(ctx, appcode.FromContext(ctx), userID, s.luckyGiftSendLockTTL)
if err != nil {
return err
}
if release != nil {
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
release(releaseCtx)
}()
}
return fn()
}
func luckyGiftMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *luckygiftv1.RequestMeta {
return &luckygiftv1.RequestMeta{
RequestId: meta.GetRequestId(),
Caller: "room-service",
GatewayNodeId: meta.GetGatewayNodeId(),
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: appcode.FromContext(ctx),
}
}
func luckyGiftAnchorID(roomMeta RoomMeta) string {
if roomMeta.OwnerUserID <= 0 {
return roomMeta.RoomID
}
return fmt.Sprintf("%d", roomMeta.OwnerUserID)
}
func luckyGiftResultFromProto(result *luckygiftv1.LuckyGiftDrawResult, targetUserID int64) *roomv1.LuckyGiftDrawResult {
if result == nil {
return nil
}
mapped := &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: result.GetDrawId(),
CommandId: result.GetCommandId(),
PoolId: result.GetPoolId(),
GiftId: result.GetGiftId(),
RuleVersion: result.GetRuleVersion(),
ExperiencePool: result.GetExperiencePool(),
SelectedTierId: result.GetSelectedTierId(),
MultiplierPpm: result.GetMultiplierPpm(),
BaseRewardCoins: result.GetBaseRewardCoins(),
EffectiveRewardCoins: result.GetEffectiveRewardCoins(),
RewardStatus: result.GetRewardStatus(),
StageFeedback: result.GetStageFeedback(),
HighMultiplier: result.GetHighMultiplier(),
CreatedAtMs: result.GetCreatedAtMs(),
WalletTransactionId: result.GetWalletTransactionId(),
// 这里故意不透出 activity 返回的返奖后余额。SendGift HTTP 只承诺扣费后的真实余额,中奖返奖后的余额必须等钱包落账后由 WalletBalanceChanged 私有 IM 覆盖。
CoinBalanceAfter: 0,
TargetUserId: targetUserID,
}
for _, hit := range result.GetHits() {
if hit == nil || hit.GetRewardCoins() <= 0 {
continue
}
mapped.Hits = append(mapped.Hits, &roomv1.SendGiftLuckyHit{
GiftIndex: hit.GetGiftIndex(), DrawId: hit.GetDrawId(), TargetUserId: targetUserID,
SelectedTierId: hit.GetSelectedTierId(), MultiplierPpm: hit.GetMultiplierPpm(), RewardCoins: hit.GetRewardCoins(),
})
}
return mapped
}
func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult {
if len(results) == 0 {
return nil
}
if len(results) == 1 {
return results[0]
}
aggregate := proto.Clone(results[0]).(*roomv1.LuckyGiftDrawResult)
aggregate.CommandId = commandID
aggregate.SelectedTierId = "batch"
aggregate.TargetUserId = 0
aggregate.WalletTransactionId = ""
aggregate.MultiplierPpm = 0
aggregate.BaseRewardCoins = 0
aggregate.EffectiveRewardCoins = 0
aggregate.RewardStatus = "granted"
aggregate.StageFeedback = false
aggregate.HighMultiplier = false
aggregate.CoinBalanceAfter = 0
aggregate.Hits = nil
for _, result := range results {
if result == nil {
continue
}
aggregate.RuleVersion = result.GetRuleVersion()
aggregate.ExperiencePool = result.GetExperiencePool()
aggregate.CreatedAtMs = result.GetCreatedAtMs()
aggregate.MultiplierPpm += result.GetMultiplierPpm()
aggregate.BaseRewardCoins += result.GetBaseRewardCoins()
aggregate.EffectiveRewardCoins += result.GetEffectiveRewardCoins()
aggregate.StageFeedback = aggregate.StageFeedback || result.GetStageFeedback()
aggregate.HighMultiplier = aggregate.HighMultiplier || result.GetHighMultiplier()
aggregate.Hits = append(aggregate.Hits, result.GetHits()...)
switch result.GetRewardStatus() {
case "pending":
aggregate.RewardStatus = "pending"
case "failed":
if aggregate.RewardStatus != "pending" {
aggregate.RewardStatus = "failed"
}
case "granted":
default:
if aggregate.RewardStatus == "" {
aggregate.RewardStatus = result.GetRewardStatus()
}
}
}
return aggregate
}
func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetBilling, now time.Time) *roomv1.LuckyGiftDrawResult {
if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
return nil
}
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5)
multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000)
drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli())
return &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: drawID,
CommandId: cmd.ID(),
PoolId: cmd.PoolID,
GiftId: cmd.GiftID,
MultiplierPpm: multiplier,
BaseRewardCoins: rewardCoins,
EffectiveRewardCoins: rewardCoins,
RewardStatus: "granted",
StageFeedback: true,
HighMultiplier: multiplier >= 5000000,
CreatedAtMs: now.UnixMilli(),
TargetUserId: cmd.TargetUserID,
Hits: []*roomv1.SendGiftLuckyHit{{
GiftIndex: 1, DrawId: drawID, TargetUserId: cmd.TargetUserID, MultiplierPpm: multiplier, RewardCoins: rewardCoins,
}},
}
}
func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil {
return 0
}
// SendGift 的 HTTP 余额只表示本次送礼扣费完成后的 COIN 余额lucky/super_lucky 返奖属于后续入账事实,由钱包余额 IM 更新客户端。
return billing.GetBalanceAfter()
}
func firstPositiveInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}