2026-06-11 18:14:04 +08:00

1159 lines
61 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 wallet
import (
"context"
"strings"
"time"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
)
const walletBadgeGrantMetric = "wallet_badge_grant"
// Repository 隔离账务存储,扣费必须由 repository 保证余额和幂等。
type Repository interface {
DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error)
BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error)
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error)
GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error)
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error)
ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error)
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error)
CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error)
CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error)
ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error)
GetRedPacketConfig(ctx context.Context, appCode string) (ledger.RedPacketConfig, error)
UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error)
CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error)
ClaimRedPacket(ctx context.Context, command ledger.RedPacketClaimCommand) (ledger.RedPacketClaimReceipt, error)
ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error)
GetRedPacket(ctx context.Context, appCode string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error)
ExpireRedPackets(ctx context.Context, appCode string, batchSize int32) (ledger.RedPacketExpireResult, error)
RetryRedPacketRefund(ctx context.Context, appCode string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error)
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error)
GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error)
GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error)
ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error)
ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error)
GetRechargeProduct(ctx context.Context, appCode string, productID int64) (ledger.RechargeProduct, error)
ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error)
MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error
CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error
ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error)
UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error)
GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error)
ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error)
CreateExternalRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string) (ledger.ExternalRechargeOrder, error)
MarkExternalRechargeOrderRedirected(ctx context.Context, appCode string, orderID string, providerOrderID string, payURL string, payloadJSON string) (ledger.ExternalRechargeOrder, error)
AttachExternalRechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error)
CreditExternalRechargeOrder(ctx context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error)
MarkExternalRechargeOrderFailed(ctx context.Context, appCode string, orderID string, reason string, payloadJSON string) (ledger.ExternalRechargeOrder, error)
GetExternalRechargeOrder(ctx context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error)
GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error)
ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error)
ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error)
GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error)
PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error)
DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error)
GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error)
ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error)
UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error)
ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error)
ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error)
GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error)
CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error)
UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error)
SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error)
ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error)
GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error)
CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error)
UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error)
SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error)
ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error)
ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error)
CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error)
UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error)
SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error)
DeleteGiftConfig(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error)
UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error)
GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error)
GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error)
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error)
BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error)
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error)
UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error)
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error)
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error)
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error)
ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error)
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error
MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error
}
// ActivityBadgeClient 是 wallet -> activity 的最小投影接口,避免 wallet 依赖 activity 内部包。
type ActivityBadgeClient interface {
ConsumeAchievementEvent(ctx context.Context, req *activityv1.ConsumeAchievementEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error)
}
// GooglePlayClient 隔离 Google Play Developer APIservice 只依赖购买校验和消耗能力。
type GooglePlayClient interface {
GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error)
ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error
}
// Service 承载钱包账务用例。
type Service struct {
repository Repository
activity ActivityBadgeClient
googlePlay GooglePlayClient
mifaPay MifaPayClient
tronUSDT TronUSDTClient
externalRecharge ExternalRechargeConfig
now func() time.Time
}
// New 创建钱包服务。
func New(repository Repository, activity ...ActivityBadgeClient) *Service {
var activityClient ActivityBadgeClient
if len(activity) > 0 {
activityClient = activity[0]
}
return &Service{repository: repository, activity: activityClient, now: time.Now}
}
// SetGooglePlayClient 注入 Google Play Developer API 客户端;未配置时 confirm 返回依赖不可用。
func (s *Service) SetGooglePlayClient(client GooglePlayClient) {
s.googlePlay = client
}
// SetMifaPayClient 注入 MiFaPay 下单和验签客户端;未配置时 H5 MiFaPay 下单返回依赖不可用。
func (s *Service) SetMifaPayClient(client MifaPayClient) {
s.mifaPay = client
}
// SetTronUSDTClient 注入 TRON TRC20 交易校验客户端;未配置时 USDT tx 提交只会保留待确认订单。
func (s *Service) SetTronUSDTClient(client TronUSDTClient) {
s.tronUSDT = client
}
// SetExternalRechargeConfig 保存 H5 外部充值的站内公共配置,不把密钥或地址散落到 handler。
func (s *Service) SetExternalRechargeConfig(config ExternalRechargeConfig) {
s.externalRecharge = normalizeExternalRechargeConfig(config)
}
// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。
func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" {
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete")
}
if command.GiftCount <= 0 {
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive")
}
if command.TargetIsHost && command.TargetHostRegionID <= 0 {
// 主播周期钻石后续要按区域工资政策结算;只有 active host profile 且带区域时才允许入账。
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required")
}
if s.repository == nil {
return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.DebitGift(ctx, command)
}
// BatchDebitGift 校验多目标送礼扣费命令,并要求 repository 在同一事务中完成全部目标结算。
func (s *Service) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) {
if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.GiftID == "" {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete")
}
if command.GiftCount <= 0 {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive")
}
if len(command.Targets) == 0 {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift targets are required")
}
seen := make(map[int64]struct{}, len(command.Targets))
for _, target := range command.Targets {
if target.CommandID == "" || target.TargetUserID <= 0 {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift target is incomplete")
}
if _, exists := seen[target.TargetUserID]; exists {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift target is duplicated")
}
seen[target.TargetUserID] = struct{}{}
if target.TargetIsHost && target.TargetHostRegionID <= 0 {
// 主播工资入账必须带该目标自己的主播区域,不能复用房间区域或其他目标快照。
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required")
}
}
if s.repository == nil {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.BatchDebitGift(ctx, command)
}
// ProcessHostSalarySettlementBatch 执行主播/代理工资结算批处理;日结和半月结不清空周期钻石,月底只做逻辑清算标记。
func (s *Service) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) {
if s.repository == nil {
return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
switch strings.TrimSpace(command.SettlementType) {
case ledger.HostSalarySettlementModeDaily, ledger.HostSalarySettlementModeHalfMonth, ledger.HostSalarySettlementTypeMonthEnd:
default:
return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid")
}
if command.BatchSize <= 0 {
command.BatchSize = 100
}
if command.BatchSize > 500 {
command.BatchSize = 500
}
if strings.TrimSpace(command.RunID) == "" || strings.TrimSpace(command.WorkerID) == "" {
return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "run_id and worker_id are required")
}
command.AppCode = appcode.Normalize(command.AppCode)
if command.NowMs <= 0 {
command.NowMs = s.now().UTC().UnixMilli()
}
if strings.TrimSpace(command.CycleKey) == "" {
command.CycleKey = hostSalaryCycleKey(command.NowMs)
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ProcessHostSalarySettlementBatch(ctx, command)
}
func hostSalaryCycleKey(nowMs int64) string {
// 结算周期按 UTC 月锚定,和送礼入账的 host_period_diamond_accounts.cycle_key 保持同一口径。
return time.UnixMilli(nowMs).UTC().Format("2006-01")
}
// GetBalances 返回用户多资产余额;鉴权范围由 gateway 或后台入口控制。
func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) {
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
normalized := make([]string, 0, len(assetTypes))
for _, assetType := range assetTypes {
assetType = strings.ToUpper(strings.TrimSpace(assetType))
if assetType == "" {
continue
}
if !ledger.ValidAssetType(assetType) {
return nil, xerr.New(xerr.InvalidArgument, "asset_type is invalid")
}
normalized = append(normalized, assetType)
}
return s.repository.GetBalances(ctx, userID, normalized)
}
// GetActiveHostSalaryPolicy 读取主播所在区域当前生效的工资政策,返回的 policy 与工资结算使用同一张 runtime 表。
func (s *Service) GetActiveHostSalaryPolicy(ctx context.Context, appCode string, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) {
if regionID <= 0 {
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "region_id is required")
}
if s.repository == nil {
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
settlementMode = strings.TrimSpace(settlementMode)
if settlementMode != "" && settlementMode != ledger.HostSalarySettlementModeDaily && settlementMode != ledger.HostSalarySettlementModeHalfMonth {
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_mode is invalid")
}
triggerMode = strings.TrimSpace(triggerMode)
if triggerMode != "" && triggerMode != ledger.HostSalarySettlementTriggerAutomatic && triggerMode != ledger.HostSalarySettlementTriggerManual {
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_trigger_mode is invalid")
}
if nowMs <= 0 {
nowMs = s.now().UTC().UnixMilli()
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
// H5 展示可以不传 trigger mode此时 automatic/manual 政策都可展示;结算任务仍会传具体 trigger 精确匹配。
return s.repository.GetActiveHostSalaryPolicy(ctx, regionID, settlementMode, triggerMode, nowMs)
}
// GetHostSalaryProgress 返回主播当前工资周期的钻石累计;没有收礼账户时返回 0避免 H5 用其他等级系统兜底导致进度口径错误。
func (s *Service) GetHostSalaryProgress(ctx context.Context, appCode string, userID int64, cycleKey string, nowMs int64) (ledger.HostSalaryProgress, error) {
if userID <= 0 {
return ledger.HostSalaryProgress{}, xerr.New(xerr.InvalidArgument, "host_user_id is required")
}
if s.repository == nil {
return ledger.HostSalaryProgress{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if nowMs <= 0 {
nowMs = s.now().UTC().UnixMilli()
}
cycleKey = strings.TrimSpace(cycleKey)
if cycleKey == "" {
cycleKey = hostSalaryCycleKey(nowMs)
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
return s.repository.GetHostSalaryProgress(ctx, userID, cycleKey)
}
// AdminCreditAsset 执行后台手动调账amount 为正数入账、负数扣账,审计字段必须随交易一起落库。
func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount == 0 {
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "admin adjustment command is incomplete")
}
command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType))
if !ledger.ValidAssetType(command.AssetType) {
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "asset_type is invalid")
}
if command.Reason == "" || command.EvidenceRef == "" {
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "reason and evidence_ref are required")
}
if s.repository == nil {
return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.AdminCreditAsset(ctx, command)
}
// AdminCreditCoinSellerStock 只给 COIN_SELLER_COIN 库存入账USDT 进货和金币补偿使用不同账务类型。
func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
if command.CommandID == "" || command.SellerUserID <= 0 || command.OperatorUserID <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock command is incomplete")
}
if command.SellerCountryID <= 0 || command.SellerRegionID <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "seller country and region are required")
}
if len(command.CommandID) > 128 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
if command.CoinAmount <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin_amount must be positive")
}
command.StockType = ledger.NormalizeCoinSellerStockType(command.StockType)
if command.StockType == "" {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
}
command.PaidCurrencyCode = strings.ToUpper(strings.TrimSpace(command.PaidCurrencyCode))
command.PaymentRef = strings.TrimSpace(command.PaymentRef)
command.EvidenceRef = strings.TrimSpace(command.EvidenceRef)
command.Reason = strings.TrimSpace(command.Reason)
if len(command.PaidCurrencyCode) > 8 || len(command.PaymentRef) > 128 || len(command.EvidenceRef) > 256 || len(command.Reason) > 512 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock text fields are too long")
}
switch command.StockType {
case ledger.StockTypeUSDTPurchase:
if command.PaidCurrencyCode == "" {
command.PaidCurrencyCode = ledger.PaidCurrencyUSDT
}
if command.PaidCurrencyCode != ledger.PaidCurrencyUSDT || command.PaidAmountMicro <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt purchase requires paid amount")
}
case ledger.StockTypeCoinCompensation:
if command.PaidCurrencyCode != "" || command.PaidAmountMicro != 0 || command.PaymentRef != "" {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin compensation must not include paid amount")
}
default:
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
}
if s.repository == nil {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.AdminCreditCoinSellerStock(ctx, command)
}
// TransferCoinFromSeller 执行币商专用金币转普通金币;身份和区域都由 gateway/user-service 校验并传入。
func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller transfer command is incomplete")
}
if command.SellerRegionID <= 0 || command.TargetRegionID <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target region are required")
}
if command.TargetCountryID <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "target country is required")
}
if command.SellerRegionID != command.TargetRegionID {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Conflict, "seller and target region must match")
}
if strings.TrimSpace(command.Reason) == "" {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.Reason = strings.TrimSpace(command.Reason)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.TransferCoinFromSeller(ctx, command)
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间gateway 用它给 H5 展示预计到账金币。
func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if regionID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "region_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
return s.repository.ListCoinSellerSalaryExchangeRateTiers(ctx, appCode, regionID, includeDisabled)
}
// ExchangeSalaryToCoin 校验工资兑换命令;身份归属由 gateway 校验wallet 只接受明确的工资资产和正向美分金额。
func (s *Service) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.SalaryUSDMinor <= 0 {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange command is incomplete")
}
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
if !ledger.ValidSalaryAssetType(command.SalaryAssetType) {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "salary exchange"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange text fields are too long")
}
if s.repository == nil {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ExchangeSalaryToCoin(ctx, command)
}
// TransferSalaryToCoinSeller 校验工资转币商命令;比例区间命中和双边入账由 repository 在同一事务内完成。
func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) {
if command.CommandID == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.SalaryUSDMinor <= 0 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer command is incomplete")
}
if command.SourceUserID == command.SellerUserID {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "source and seller must be different")
}
if command.RegionID <= 0 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required")
}
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
if !ledger.ValidSalaryAssetType(command.SalaryAssetType) {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "salary transfer"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer text fields are too long")
}
if s.repository == nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.TransferSalaryToCoinSeller(ctx, command)
}
// CreditTaskReward 发放任务奖励金币;任务完成判断属于 activity-service这里只负责账本幂等入账。
func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.TaskID == "" || command.CycleKey == "" {
return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "task reward command is incomplete")
}
command.TaskType = strings.ToLower(strings.TrimSpace(command.TaskType))
if command.TaskType != "daily" && command.TaskType != "exclusive" {
return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "task_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.TaskRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreditTaskReward(ctx, command)
}
// CreditLuckyGiftReward 发放幸运礼物中奖金币;抽奖资格和金额已由 activity-service 决策,钱包只做幂等入账。
func (s *Service) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.RoomID == "" || command.GiftID == "" || command.PoolID == "" {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "lucky gift reward command is incomplete")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.DrawID = strings.TrimSpace(command.DrawID)
command.RoomID = strings.TrimSpace(command.RoomID)
command.GiftID = strings.TrimSpace(command.GiftID)
command.PoolID = strings.TrimSpace(command.PoolID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreditLuckyGiftReward(ctx, command)
}
// CreditRoomTurnoverReward 发放每周房间流水奖励金币;结算归 activity-service钱包只负责幂等入账。
func (s *Service) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
// 钱包只接受 activity-service 已经生成好的结算命令;缺 settlement、room 或正向金额时直接拒绝,避免钱包自己推断活动规则。
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.SettlementID == "" || command.RoomID == "" {
return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "room turnover reward command is incomplete")
}
// 周期、流水和档位是账务元数据的一部分,必须随交易落库,后续对账才能从钱包交易反查是哪一周、哪个房间、哪个档位。
if command.PeriodStartMS <= 0 || command.PeriodEndMS <= command.PeriodStartMS || command.CoinSpent <= 0 || command.TierID <= 0 {
return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "room turnover reward period or tier is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.SettlementID = strings.TrimSpace(command.SettlementID)
command.RoomID = strings.TrimSpace(command.RoomID)
command.TierCode = strings.TrimSpace(command.TierCode)
// AppCode 归一化后写入 context让 repository 的交易、账户和 outbox 都落到同一个 App 分区。
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreditRoomTurnoverReward(ctx, command)
}
// CreditInviteActivityReward 发放邀请活动金币奖励;达标和重复领取判断属于 activity-service钱包只负责幂等入账。
func (s *Service) CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) {
// 钱包入口再次校验最小账务语义,避免上游 bug 生成空 claim 或 0 金额交易。
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.ClaimID == "" || command.TierID <= 0 || command.CycleKey == "" {
return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward command is incomplete")
}
command.RewardType = strings.ToLower(strings.TrimSpace(command.RewardType))
if command.RewardType != "recharge" && command.RewardType != "valid_invite" && command.RewardType != "invite_inviter" && command.RewardType != "invite_invitee" {
return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.ClaimID = strings.TrimSpace(command.ClaimID)
command.TierCode = strings.TrimSpace(command.TierCode)
command.CycleKey = strings.TrimSpace(command.CycleKey)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreditInviteActivityReward(ctx, command)
}
// ApplyGameCoinChange 是游戏平台唯一的 COIN 改账入口,方向由 op_type 控制。
func (s *Service) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.PlatformCode == "" || command.GameID == "" || command.ProviderOrderID == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game coin command is incomplete")
}
command.OpType = ledger.NormalizeGameOpType(command.OpType)
if command.OpType == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
}
if command.CoinAmount <= 0 {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
}
command.RequestHash = strings.TrimSpace(command.RequestHash)
if command.RequestHash == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "request_hash is required")
}
if s.repository == nil {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.PlatformCode = strings.TrimSpace(command.PlatformCode)
command.GameID = strings.TrimSpace(command.GameID)
command.ProviderOrderID = strings.TrimSpace(command.ProviderOrderID)
command.ProviderRoundID = strings.TrimSpace(command.ProviderRoundID)
command.RoomID = strings.TrimSpace(command.RoomID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ApplyGameCoinChange(ctx, command)
}
// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。
func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListRechargeBills(ctx, query)
}
// GetWalletOverview 返回钱包首页摘要,余额和能力开关都由 wallet-service 统一给出。
func (s *Service) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) {
if userID <= 0 {
return ledger.WalletOverview{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.WalletOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetWalletOverview(ctx, userID)
}
// GetWalletValueSummary 返回我的页钱包卡片摘要,只读取 COIN 账户。
func (s *Service) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) {
if userID <= 0 {
return ledger.WalletValueSummary{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.WalletValueSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetWalletValueSummary(ctx, userID)
}
// GetUserGiftWall 返回用户收礼聚合投影;收礼事实只来自成功送礼账务,不读取房间运行态。
func (s *Service) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) {
if userID <= 0 {
return ledger.UserGiftWall{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetUserGiftWall(ctx, userID)
}
// ProjectPendingGiftWallEvents 消费钱包 outbox 中的送礼成功事件,异步维护用户礼物墙投影。
func (s *Service) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL)
}
// ProcessWalletProjectionMessage consumes one wallet_outbox MQ fact for wallet-owned projections.
func (s *Service) ProcessWalletProjectionMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if s.repository == nil {
return false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, message.AppCode)
handled := false
projected, err := s.repository.ProjectGiftWallMessage(ctx, workerID+"-gift-wall", message, lockTTL)
if err != nil {
return handled, err
}
handled = handled || projected
projected, err = s.projectBadgeGrantMessage(ctx, workerID+"-badge-grant", message, lockTTL)
if err != nil {
return handled, err
}
handled = handled || projected
return handled, nil
}
// ProjectPendingBadgeGrantEvents relays wallet resource grant outbox facts to activity badge display projection.
func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.activity == nil {
return 0, xerr.New(xerr.Unavailable, "activity badge client is not configured")
}
if limit <= 0 {
limit = 50
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
nowMS := s.now().UnixMilli()
events, err := s.repository.ClaimPendingBadgeGrantEvents(ctx, workerID, nowMS, lockTTL, limit)
if err != nil {
return 0, err
}
processed := 0
for _, event := range events {
processed++
if err := s.deliverBadgeGrantProjection(ctx, event); err != nil {
return processed, err
}
}
return processed, nil
}
func (s *Service) projectBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
if !isBadgeGrantWalletProjectionMessage(message) {
return false, nil
}
if s.activity == nil {
return false, xerr.New(xerr.Unavailable, "activity badge client is not configured")
}
nowMS := s.now().UnixMilli()
event, claimed, err := s.repository.ClaimBadgeGrantMessage(ctx, workerID, message, nowMS, lockTTL)
if err != nil || !claimed {
return claimed, err
}
if err := s.deliverBadgeGrantProjection(ctx, event); err != nil {
return true, err
}
return true, nil
}
func (s *Service) deliverBadgeGrantProjection(ctx context.Context, event resourcedomain.BadgeGrantOutbox) error {
reqCtx := appcode.WithContext(ctx, event.AppCode)
resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{
Meta: &activityv1.RequestMeta{
Caller: "wallet-service",
AppCode: event.AppCode,
SentAtMs: s.now().UnixMilli(),
},
EventId: event.EventID,
EventType: event.EventType,
SourceService: "wallet-service",
UserId: event.UserID,
MetricType: walletBadgeGrantMetric,
Value: 1,
OccurredAtMs: event.CreatedAtMS,
DimensionsJson: event.PayloadJSON,
})
if consumeErr != nil {
return s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli())
}
if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) {
return s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli())
}
return s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli())
}
func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) bool {
if strings.TrimSpace(message.AssetType) != "RESOURCE" {
return false
}
switch message.EventType {
case "ResourceGranted", "ResourceGroupGranted":
return true
default:
return false
}
}
func isBadgeGrantProjectionStatusTerminal(status string) bool {
switch status {
case "consumed", "skipped", "duplicate":
return true
default:
return false
}
}
// ListRechargeProducts 返回区域化充值档位region_id 必须由 gateway 从 user-service 资料解析。
func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) {
if userID <= 0 || regionID <= 0 {
return nil, nil, xerr.New(xerr.InvalidArgument, "user_id and region_id are required")
}
if strings.TrimSpace(platform) != "" {
platform = ledger.NormalizeRechargeProductPlatform(platform)
if platform == "" {
return nil, nil, xerr.New(xerr.InvalidArgument, "platform is invalid")
}
}
audienceType = ledger.NormalizeRechargeAudienceType(audienceType)
if audienceType == "" {
return nil, nil, xerr.New(xerr.InvalidArgument, "audience_type is invalid")
}
if s.repository == nil {
return nil, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.ListRechargeProducts(ctx, userID, regionID, platform, audienceType)
}
// ListAdminRechargeProducts 返回后台配置列表;后台入口负责 RBAC 和审计。
func (s *Service) ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
if strings.TrimSpace(query.Status) != "" {
query.Status = ledger.NormalizeRechargeProductStatus(query.Status)
if query.Status == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "status is invalid")
}
}
if strings.TrimSpace(query.Platform) != "" {
query.Platform = ledger.NormalizeRechargeProductPlatform(query.Platform)
if query.Platform == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "platform is invalid")
}
}
if strings.TrimSpace(query.AudienceType) != "" {
query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType)
if query.AudienceType == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "audience_type is invalid")
}
}
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListAdminRechargeProducts(ctx, query)
}
// CreateRechargeProduct 创建内购商品配置;首版充值资源只允许发 COIN。
func (s *Service) CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) {
if s.repository == nil {
return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
if err := validateRechargeProductCommand(command, false); err != nil {
return ledger.RechargeProduct{}, err
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreateRechargeProduct(ctx, command)
}
// UpdateRechargeProduct 覆盖内购商品配置和支持区域,避免状态散落在后台库。
func (s *Service) UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) {
if s.repository == nil {
return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
if err := validateRechargeProductCommand(command, true); err != nil {
return ledger.RechargeProduct{}, err
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.UpdateRechargeProduct(ctx, command)
}
// DeleteRechargeProduct 删除尚未接入 provider order 的配置事实;订单实现后这里需要先做引用检查。
func (s *Service) DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error {
if productID <= 0 {
return xerr.New(xerr.InvalidArgument, "product_id is required")
}
if s.repository == nil {
return xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
return s.repository.DeleteRechargeProduct(ctx, appcode.FromContext(ctx), productID)
}
// ConfirmGooglePayment 校验 Google Play purchase token原子入账并记录支付审计。
func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.GooglePaymentReceipt, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.CommandID = strings.TrimSpace(command.CommandID)
command.ProductCode = strings.TrimSpace(command.ProductCode)
command.PackageName = strings.TrimSpace(command.PackageName)
command.PurchaseToken = strings.TrimSpace(command.PurchaseToken)
command.OrderID = strings.TrimSpace(command.OrderID)
if command.CommandID == "" || command.UserID <= 0 || command.RegionID <= 0 || command.ProductID <= 0 || command.PackageName == "" || command.PurchaseToken == "" {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is incomplete")
}
if len(command.CommandID) > 128 || len(command.PackageName) > 256 || len(command.PurchaseToken) > 4096 || len(command.OrderID) > 128 {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is too long")
}
if s.repository == nil {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.googlePlay == nil {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "google play client is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not active")
}
if product.Platform != ledger.RechargeProductPlatformAndroid || product.Channel != ledger.RechargeChannelGoogle {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "recharge product is not google play product")
}
if command.ProductCode != "" && command.ProductCode != product.ProductName {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "product_code does not match")
}
if !rechargeProductSupportsRegion(product, command.RegionID) {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not available in user region")
}
purchase, err := s.googlePlay.GetProductPurchase(ctx, command.PackageName, command.PurchaseToken)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if purchase.PackageName == "" {
purchase.PackageName = command.PackageName
}
if purchase.PackageName != command.PackageName {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "package_name does not match")
}
if purchase.PurchaseState != ledger.GooglePurchaseStatePurchased {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase is not purchased")
}
if purchase.ProductID != "" && purchase.ProductID != product.ProductName {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google product_id does not match recharge product")
}
if command.OrderID != "" && purchase.OrderID != "" && command.OrderID != purchase.OrderID {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google order_id does not match")
}
receipt, err := s.repository.ConfirmGooglePayment(ctx, command, product, purchase)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed {
return receipt, nil
}
consumeProductID := product.ProductCode
if purchase.ProductID != "" {
consumeProductID = purchase.ProductID
}
if err := s.googlePlay.ConsumeProduct(ctx, command.PackageName, consumeProductID, command.PurchaseToken); err != nil {
return receipt, nil
}
if err := s.repository.MarkGooglePaymentConsumed(ctx, command.AppCode, receipt.PaymentOrderID); err != nil {
return receipt, nil
}
receipt.ConsumeState = ledger.PaymentConsumeStateConsumed
return receipt, nil
}
// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。
func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) {
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetDiamondExchangeConfig(ctx, userID)
}
// ListWalletTransactions 返回当前用户钱包流水,避免我的页首屏扫描流水表。
func (s *Service) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) {
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
query.AssetType = strings.ToUpper(strings.TrimSpace(query.AssetType))
if query.AssetType != "" && !ledger.ValidAssetType(query.AssetType) {
return nil, 0, xerr.New(xerr.InvalidArgument, "asset_type is invalid")
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListWalletTransactions(ctx, query)
}
// ListVipPackages 返回可购买 VIP 包和当前会员状态。
func (s *Service) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) {
if userID <= 0 {
return ledger.UserVip{}, nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.UserVip{}, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.ListVipPackages(ctx, userID)
}
// GetMyVip 返回当前用户会员状态;过期会员会按 active=false 投影。
func (s *Service) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) {
if userID <= 0 {
return ledger.UserVip{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.UserVip{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetMyVip(ctx, userID)
}
// PurchaseVip 执行 VIP 购买、升级或续期;降级由 repository 在锁内按当前会员状态拒绝。
func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.Level <= 0 {
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip purchase command is incomplete")
}
if s.repository == nil {
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.PurchaseVip(ctx, command)
}
// DebitCPBreakupFee 扣除解除关系费用;关系是否可解除仍由 user-service 的 pending/confirm 流程决定。
func (s *Service) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.RelationshipID) == "" || strings.TrimSpace(command.RelationType) == "" {
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee command is incomplete")
}
if len(command.CommandID) > 128 {
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
if command.Amount <= 0 {
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee amount must be positive")
}
if s.repository == nil {
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.RelationshipID = strings.TrimSpace(command.RelationshipID)
command.RelationType = strings.ToLower(strings.TrimSpace(command.RelationType))
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.DebitCPBreakupFee(ctx, command)
}
// GrantVip 是活动和后台赠送 VIP 的统一入口;购买仍走 PurchaseVip 完成扣费。
func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 {
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant command is incomplete")
}
if len(command.CommandID) > 128 {
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
command.GrantSource = ledger.NormalizeVipGrantSource(command.GrantSource)
switch command.GrantSource {
case ledger.VipGrantSourceAdmin, ledger.VipGrantSourceManagerCenter:
if command.OperatorUserID <= 0 {
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is required")
}
case ledger.VipGrantSourceActivity:
if command.OperatorUserID < 0 {
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is invalid")
}
default:
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant_source is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = command.GrantSource
}
if len(command.Reason) > 512 {
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "reason is too long")
}
if s.repository == nil {
return ledger.GrantVipReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.GrantVip(ctx, command)
}
// ListAdminVipLevels 返回后台 VIP 配置页的 10 级配置快照。
func (s *Service) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.ListAdminVipLevels(ctx)
}
// UpdateAdminVipLevels 保存后台 VIP 等级配置;完整校验下沉到 repository保证和购买事务一致。
func (s *Service) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) {
if operatorUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "operator_user_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
normalized := make([]ledger.AdminVipLevelCommand, 0, len(levels))
for _, level := range levels {
level.Name = strings.TrimSpace(level.Name)
level.Status = strings.ToLower(strings.TrimSpace(level.Status))
normalized = append(normalized, level)
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.UpdateAdminVipLevels(ctx, normalized, operatorUserID)
}
func validateRechargeProductCommand(command ledger.RechargeProductCommand, requireProductID bool) error {
if requireProductID && command.ProductID <= 0 {
return xerr.New(xerr.InvalidArgument, "product_id is required")
}
if command.AmountMicro <= 0 || command.CoinAmount <= 0 || command.OperatorUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "recharge product amount and operator are required")
}
if strings.TrimSpace(command.ProductName) == "" || len([]rune(strings.TrimSpace(command.ProductName))) > 128 {
return xerr.New(xerr.InvalidArgument, "product_name is invalid")
}
if len([]rune(strings.TrimSpace(command.Description))) > 512 {
return xerr.New(xerr.InvalidArgument, "description is too long")
}
if ledger.NormalizeRechargeProductPlatform(command.Platform) == "" {
return xerr.New(xerr.InvalidArgument, "platform is invalid")
}
if ledger.NormalizeRechargeAudienceType(command.AudienceType) == "" {
return xerr.New(xerr.InvalidArgument, "audience_type is invalid")
}
if len(command.RegionIDs) == 0 {
return xerr.New(xerr.InvalidArgument, "region_ids are required")
}
seen := make(map[int64]struct{}, len(command.RegionIDs))
for _, regionID := range command.RegionIDs {
if regionID <= 0 {
return xerr.New(xerr.InvalidArgument, "region_id is invalid")
}
if _, ok := seen[regionID]; ok {
return xerr.New(xerr.InvalidArgument, "region_id is duplicated")
}
seen[regionID] = struct{}{}
}
return nil
}
func rechargeProductSupportsRegion(product ledger.RechargeProduct, regionID int64) bool {
for _, productRegionID := range product.RegionIDs {
if productRegionID == regionID {
return true
}
}
return false
}