967 lines
49 KiB
Go
967 lines
49 KiB
Go
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)
|
||
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
|
||
CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, 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) ([]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
|
||
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)
|
||
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)
|
||
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 API,service 只依赖购买校验和消耗能力。
|
||
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
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if 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)
|
||
// 不传 settlement_mode 时按同区域最新 active policy 返回,用于 H5 展示平台当前规则。
|
||
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 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.SellerUserID == command.TargetUserID {
|
||
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target must be different")
|
||
}
|
||
if command.SellerRegionID <= 0 || command.TargetRegionID <= 0 {
|
||
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target region are 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)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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) ([]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")
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
// 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:
|
||
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 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
|
||
}
|