374 lines
17 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"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
"time"
)
// 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)
}
// GetRechargeBillSummary 按与账单列表一致的筛选口径聚合充值总和、Google 充值与三方充值。
func (s *Service) GetRechargeBillSummary(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error) {
if s.repository == nil {
return ledger.RechargeBillSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.SummarizeRechargeBills(ctx, query)
}
// GetRechargeBillOverview 汇总财务概览的按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (s *Service) GetRechargeBillOverview(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMinutes int32) (ledger.RechargeBillOverview, error) {
if s.repository == nil {
return ledger.RechargeBillOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.GetRechargeBillOverview(ctx, query, tzOffsetMinutes)
}
// GooglePaymentPriceResult 是单笔 Google 账单实付明细刷新结果Err 非空表示该笔刷新失败但不影响其他账单。
type GooglePaymentPriceResult struct {
TransactionID string
CurrencyCode string
PaidAmountMicro int64
TaxAmountMicro int64
NetAmountMicro int64
SyncedAtMS int64
Err string
}
const maxGooglePaymentPriceRefreshBatch = 100
// RefreshGooglePaymentPrices 用 Play Orders API 拉取账单的用户实付币种、总额、税额和净收入并写回 payment_orders。
// 服务账号需要 Play Console 的“查看财务数据”权限;单笔失败只标记该笔的 Err整批继续。
func (s *Service) RefreshGooglePaymentPrices(ctx context.Context, appCode string, transactionIDs []string) ([]GooglePaymentPriceResult, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.googlePlay == nil {
return nil, xerr.New(xerr.Unavailable, "google play client is not configured")
}
if len(transactionIDs) == 0 {
return []GooglePaymentPriceResult{}, nil
}
if len(transactionIDs) > maxGooglePaymentPriceRefreshBatch {
return nil, xerr.New(xerr.InvalidArgument, "too many transaction_ids")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
refs, err := s.repository.ListGooglePaymentOrderRefs(ctx, appCode, transactionIDs)
if err != nil {
return nil, err
}
results := make([]GooglePaymentPriceResult, 0, len(transactionIDs))
for _, transactionID := range transactionIDs {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
continue
}
ref, ok := refs[transactionID]
if !ok {
results = append(results, GooglePaymentPriceResult{TransactionID: transactionID, Err: "not a google payment bill"})
continue
}
result := s.refreshGooglePaymentPrice(ctx, appCode, ref)
results = append(results, result)
}
return results, nil
}
func (s *Service) refreshGooglePaymentPrice(ctx context.Context, appCode string, ref ledger.GooglePaymentOrderRef) GooglePaymentPriceResult {
result := GooglePaymentPriceResult{TransactionID: ref.TransactionID}
if strings.TrimSpace(ref.ProviderOrderID) == "" {
result.Err = "google order id is missing"
return result
}
order, err := s.googlePlay.GetOrder(ctx, ref.PackageName, ref.ProviderOrderID)
if err != nil {
result.Err = err.Error()
return result
}
details := ledger.GooglePaymentPaidDetails{
CurrencyCode: order.CurrencyCode,
PaidAmountMicro: order.TotalAmountMicro,
TaxAmountMicro: order.TaxAmountMicro,
NetAmountMicro: order.NetAmountMicro,
SyncedAtMS: s.now().UnixMilli(),
}
if err := s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, ref.PaymentOrderID, details); err != nil {
result.Err = err.Error()
return result
}
result.CurrencyCode = details.CurrencyCode
result.PaidAmountMicro = details.PaidAmountMicro
result.TaxAmountMicro = details.TaxAmountMicro
result.NetAmountMicro = details.NetAmountMicro
result.SyncedAtMS = details.SyncedAtMS
return result
}
// 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.PackageName == "" || command.PurchaseToken == "" || (command.ProductID <= 0 && command.ProductCode == "") {
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.googleRechargeProductForPayment(ctx, command)
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 && 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")
}
// 旧 App 首充曾把钱包内部 product_code 当成确认参数;上面的兼容只负责放行历史入参,
// 真正的 Google 商品事实仍以 purchase.ProductID 对齐 product_name避免内部编码绕过支付商品校验。
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
}
// 入账已完成,实付明细只影响财务展示:异步拉 Orders API失败静默后台可手动刷新补数。
s.scheduleGooglePaymentPriceRefresh(command.AppCode, receipt.PaymentOrderID, command.PackageName, purchase.OrderID)
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
}
// scheduleGooglePaymentPriceRefresh 在支付确认返回后异步同步实付明细,不给支付主链路增加延迟或失败面。
func (s *Service) scheduleGooglePaymentPriceRefresh(appCode string, paymentOrderID string, packageName string, orderID string) {
if s.googlePlay == nil || s.repository == nil ||
strings.TrimSpace(paymentOrderID) == "" || strings.TrimSpace(orderID) == "" {
return
}
syncedAt := s.now().UnixMilli()
go func() {
ctx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appCode), 10*time.Second)
defer cancel()
order, err := s.googlePlay.GetOrder(ctx, packageName, orderID)
if err != nil {
return
}
_ = s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, paymentOrderID, ledger.GooglePaymentPaidDetails{
CurrencyCode: order.CurrencyCode,
PaidAmountMicro: order.TotalAmountMicro,
TaxAmountMicro: order.TaxAmountMicro,
NetAmountMicro: order.NetAmountMicro,
SyncedAtMS: syncedAt,
})
}()
}
func (s *Service) googleRechargeProductForPayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.RechargeProduct, error) {
if command.ProductID > 0 {
return s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
}
// 首充弹窗只持有 Google 商品 ID不再依赖 App 充值商品列表返回 product_id
// 这里仍然回到钱包内部商品配置取币数、金额和区域,避免客户端自报充值规格。
return s.repository.GetGoogleRechargeProductByCode(ctx, command.AppCode, command.ProductCode)
}
func validateRechargeProductCommand(command ledger.RechargeProductCommand, requireProductID bool) error {
if requireProductID && command.ProductID <= 0 {
return xerr.New(xerr.InvalidArgument, "product_id is required")
}
// 隐藏的 Google 首充壳商品可以只承载支付金额和 SKU不直接给普通金币
// 是否发首充资源组由 activity-service 根据 WalletRechargeRecorded 的 google_product_id 决定。
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
}