package payment import ( "context" "sort" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/response" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/gin-gonic/gin" ) type rechargeBillSummaryBucketDTO struct { BillCount int64 `json:"billCount"` CoinAmount int64 `json:"coinAmount"` USDMinorAmount int64 `json:"usdMinorAmount"` } type rechargeBillSummaryDTO struct { Total rechargeBillSummaryBucketDTO `json:"total"` GooglePlay rechargeBillSummaryBucketDTO `json:"googlePlay"` ThirdParty rechargeBillSummaryBucketDTO `json:"thirdParty"` CoinSeller rechargeBillSummaryBucketDTO `json:"coinSeller"` } type rechargeBillRegionDTO struct { RegionID int64 `json:"regionId"` RegionCode string `json:"regionCode"` Name string `json:"name"` } type googleRechargePaidRefreshRequest struct { TransactionIDs []string `json:"transactionIds"` } type googleRechargePaidDTO struct { TransactionID string `json:"transactionId"` CurrencyCode string `json:"currencyCode"` PaidAmountMicro int64 `json:"paidAmountMicro"` TaxMicro int64 `json:"taxMicro"` NetMicro int64 `json:"netMicro"` SyncedAtMS int64 `json:"syncedAtMs"` Error string `json:"error,omitempty"` } // listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。 func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) { options := shared.ListOptions(c) tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes")) if tzOffsetMinutes == 0 { // legacy 币商充值列表可能来自 dashboard 自然日聚合,默认沿用财务页中国时区。 tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes } items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{ Keyword: options.Keyword, RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), RegionID: queryInt64(c, "region_id", "regionId"), StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), TzOffsetMinutes: tzOffsetMinutes, Page: options.Page, PageSize: options.PageSize, }) if err != nil { response.ServerError(c, "获取账单列表失败") return } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total}) } // GetRechargeBillSummary 返回与账单列表同一筛选口径的充值总和、Google 充值与三方充值聚合。 func (h *Handler) GetRechargeBillSummary(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes")) if tzOffsetMinutes == 0 { // legacy finance 的币商补充来自 dashboard 自然日聚合,默认沿用财务页中国时区。 tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes } if source, ok := h.billSources[appCode]; ok { userFilter := shared.UserIdentityFilterFromQuery(c, "") userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") if !ok { return } sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller") sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") if !ok { return } if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) { response.OK(c, rechargeBillSummaryDTO{}) return } query := legacyRechargeBillQuery{ Keyword: options.Keyword, RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), RegionID: queryInt64(c, "region_id", "regionId"), StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), TzOffsetMinutes: tzOffsetMinutes, } summary := rechargeBillSummaryDTO{} if !userFilter.IsEmpty() || !sellerFilter.IsEmpty() { // legacy 普通充值汇总没有 user/seller 过滤能力;有用户筛选时只保留后续可精确计算的财务币商桶。 } else { var err error summary, err = source.SummarizeRechargeBills(c.Request.Context(), query) if err != nil { response.ServerError(c, "获取充值汇总失败") return } } if err := h.applyFinanceCoinSellerSummary(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{ RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), Status: options.Status, PaidState: query.PaidState, Keyword: options.Keyword, RegionID: query.RegionID, StartAtMS: query.StartAtMS, EndAtMS: query.EndAtMS, TzOffsetMinutes: tzOffsetMinutes, TargetUserID: sellerUserID, HasUserOnlyFilter: userID > 0 && sellerUserID == 0, }, &summary); err != nil { response.ServerError(c, "获取充值汇总失败") return } response.OK(c, summary) return } userFilter := shared.UserIdentityFilterFromQuery(c, "") userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") if !ok { return } sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller") sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") if !ok { return } if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) { response.OK(c, rechargeBillSummaryDTO{}) return } paidState := strings.TrimSpace(firstQuery(c, "paid_state", "paidState")) var summary rechargeBillSummaryDTO if paidState == "" { resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, UserId: userID, SellerUserId: sellerUserID, RegionId: queryInt64(c, "region_id", "regionId"), RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), Status: options.Status, Keyword: options.Keyword, StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"), EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"), }) if err != nil { writeWalletError(c, err, "获取充值汇总失败") return } summary = rechargeBillSummaryDTO{ Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()), GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()), ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()), CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()), } } if err := h.applyFinanceCoinSellerSummary(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{ RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")), Status: options.Status, PaidState: paidState, Keyword: options.Keyword, RegionID: queryInt64(c, "region_id", "regionId"), StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), TzOffsetMinutes: tzOffsetMinutes, TargetUserID: sellerUserID, HasUserOnlyFilter: userID > 0 && sellerUserID == 0, }, &summary); err != nil { response.ServerError(c, "获取充值汇总失败") return } response.OK(c, summary) } func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBucket) rechargeBillSummaryBucketDTO { if bucket == nil { return rechargeBillSummaryBucketDTO{} } return rechargeBillSummaryBucketDTO{ BillCount: bucket.GetBillCount(), CoinAmount: bucket.GetCoinAmount(), USDMinorAmount: bucket.GetUsdMinorAmount(), } } func normalizedRechargeType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func nonNegativeRechargeBillBucket(bucket rechargeBillSummaryBucketDTO) rechargeBillSummaryBucketDTO { return rechargeBillSummaryBucketDTO{ BillCount: nonNegativeInt64(bucket.BillCount), CoinAmount: nonNegativeInt64(bucket.CoinAmount), USDMinorAmount: nonNegativeInt64(bucket.USDMinorAmount), } } func nonNegativeInt64(value int64) int64 { if value < 0 { return 0 } return value } // RefreshGoogleRechargePaidDetails 对指定账单触发 Google Orders API 实付明细同步,供财务补齐历史订单实付。 func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) { var request googleRechargePaidRefreshRequest if err := c.ShouldBindJSON(&request); err != nil || len(request.TransactionIDs) == 0 { response.BadRequest(c, "交易号参数不正确") return } if len(request.TransactionIDs) > 100 { response.BadRequest(c, "单次最多刷新 100 笔账单") return } appCode := appctx.FromContext(c.Request.Context()) if source, ok := h.billSources[appCode]; ok { if !source.SupportsGooglePaidSync() { response.BadRequest(c, "该 App 未配置 Google Play 包名和服务账号,无法同步谷歌实付") return } results, err := source.RefreshGooglePaidDetails(c.Request.Context(), request.TransactionIDs) if err != nil { response.ServerError(c, "查询谷歌实付明细失败") return } shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "admin_legacy_google_paid_details", "success", appCode) response.OK(c, gin.H{"items": results}) return } resp, err := h.wallet.RefreshGooglePaymentPrices(c.Request.Context(), &walletv1.RefreshGooglePaymentPricesRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, TransactionIds: request.TransactionIDs, }) if err != nil { writeWalletError(c, err, "查询谷歌实付明细失败") return } items := make([]googleRechargePaidDTO, 0, len(resp.GetPrices())) for _, price := range resp.GetPrices() { items = append(items, googleRechargePaidDTO{ TransactionID: price.GetTransactionId(), CurrencyCode: price.GetCurrencyCode(), PaidAmountMicro: price.GetPaidAmountMicro(), TaxMicro: price.GetTaxMicro(), NetMicro: price.GetNetMicro(), SyncedAtMS: price.GetSyncedAtMs(), Error: price.GetError(), }) } shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "payment_orders", "success", appCode) response.OK(c, gin.H{"items": items}) } type rechargeBillAppDTO struct { AppCode string `json:"appCode"` AppName string `json:"appName"` } // ListRechargeBillApps 返回充值详情可选的 App 目录:hyapp 在册应用 + 配置的 legacy 账单源(Yumi 等)。 func (h *Handler) ListRechargeBillApps(c *gin.Context) { items := []rechargeBillAppDTO{} seen := map[string]struct{}{} if h.userDB != nil { rows, err := h.userDB.QueryContext(c.Request.Context(), ` SELECT app_code, app_name FROM apps WHERE status = 'active' ORDER BY app_name ASC, app_code ASC`) if err != nil { response.ServerError(c, "获取 App 列表失败") return } defer rows.Close() for rows.Next() { var item rechargeBillAppDTO if err := rows.Scan(&item.AppCode, &item.AppName); err != nil { response.ServerError(c, "获取 App 列表失败") return } item.AppCode = appctx.Normalize(item.AppCode) seen[item.AppCode] = struct{}{} items = append(items, item) } if err := rows.Err(); err != nil { response.ServerError(c, "获取 App 列表失败") return } } legacyApps := make([]rechargeBillAppDTO, 0, len(h.billSources)) for _, source := range h.billSources { appCode := appctx.Normalize(source.AppCode()) if _, ok := seen[appCode]; ok { continue } legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName()}) } sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName }) items = append(items, legacyApps...) response.OK(c, gin.H{"items": items, "total": len(items)}) } // ListRechargeBillRegions 返回当前 App 的区域目录,用于充值详情按区域筛选;不受财务范围授权限制。 func (h *Handler) ListRechargeBillRegions(c *gin.Context) { appCode := appctx.FromContext(c.Request.Context()) regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode) if err != nil { response.ServerError(c, "获取区域列表失败") return } response.OK(c, gin.H{"items": regions, "total": len(regions)}) } func (h *Handler) listRechargeBillRegions(ctx context.Context, appCode string) ([]rechargeBillRegionDTO, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() items := []rechargeBillRegionDTO{} seen := map[int64]struct{}{} if h.userDB != nil { rows, err := h.userDB.QueryContext(ctx, ` SELECT region_id, region_code, name FROM regions WHERE app_code = ? AND status = 'active' ORDER BY sort_order ASC, name ASC`, appCode) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var item rechargeBillRegionDTO if err := rows.Scan(&item.RegionID, &item.RegionCode, &item.Name); err != nil { return nil, err } seen[item.RegionID] = struct{}{} items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } } // Yumi/Aslan 这类 legacy App 的区域目录在线上 likei Mongo;这里只取目录展示,账单筛选仍以 wallet 的 target_region_id 为准。 for _, source := range h.moneyRegionSources { _, sourceRegions, _, err := source.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true}) if err != nil { return nil, err } for _, region := range sourceRegions { if appctx.Normalize(region.AppCode) != appCode { continue } if _, ok := seen[region.RegionID]; ok { continue } seen[region.RegionID] = struct{}{} items = append(items, rechargeBillRegionDTO{RegionID: region.RegionID, RegionCode: region.RegionCode, Name: region.Name}) } } return items, nil }