2026-05-26 01:02:12 +08:00

776 lines
39 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/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)
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, 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)
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)
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)
ApplyWithdrawal(ctx context.Context, command ledger.WithdrawalCommand) (ledger.WithdrawalRequest, ledger.AssetBalance, 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)
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)
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, 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
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 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)
}
// 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)
}
// 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)
}
// 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)
}
// 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++
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 {
if markErr := s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli()); markErr != nil {
return processed, markErr
}
continue
}
if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) {
if markErr := s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli()); markErr != nil {
return processed, markErr
}
continue
}
if err := s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli()); err != nil {
return processed, err
}
}
return processed, nil
}
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.ProductCode {
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.ProductCode {
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)
}
// ApplyWithdrawal 冻结主播美元余额并创建待人工审核提现申请。
func (s *Service) ApplyWithdrawal(ctx context.Context, command ledger.WithdrawalCommand) (ledger.WithdrawalRequest, ledger.AssetBalance, error) {
if command.CommandID == "" || command.UserID <= 0 || command.Amount <= 0 {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.InvalidArgument, "withdrawal command is incomplete")
}
command.PayoutAccount = strings.TrimSpace(command.PayoutAccount)
command.Reason = strings.TrimSpace(command.Reason)
if command.PayoutAccount == "" || len(command.PayoutAccount) > 256 || len(command.Reason) > 512 {
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.InvalidArgument, "withdrawal payout account is invalid")
}
if s.repository == nil {
return ledger.WithdrawalRequest{}, 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.ApplyWithdrawal(ctx, command)
}
// 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
}