374 lines
17 KiB
Go
374 lines
17 KiB
Go
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
|
||
}
|