455 lines
17 KiB
Go
455 lines
17 KiB
Go
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"`
|
||
}
|
||
|
||
type financeThirdPartyRechargeStats struct {
|
||
Verified rechargeBillSummaryBucketDTO
|
||
GrantedOverlap rechargeBillSummaryBucketDTO
|
||
VerifiedDaily map[string]rechargeBillSummaryBucketDTO
|
||
GrantedDaily map[string]rechargeBillSummaryBucketDTO
|
||
VerifiedRegions []repository.CoinSellerRechargeOrderRegionStatsBucket
|
||
}
|
||
|
||
type financeThirdPartyRechargeQuery struct {
|
||
RechargeType string
|
||
Status string
|
||
Keyword string
|
||
RegionID int64
|
||
StartAtMS int64
|
||
EndAtMS int64
|
||
TzOffsetMinutes int32
|
||
TargetUserID int64
|
||
HasUserOnlyFilter bool
|
||
}
|
||
|
||
// 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 {
|
||
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,
|
||
}
|
||
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
|
||
return
|
||
}
|
||
summary, err := source.SummarizeRechargeBills(c.Request.Context(), query)
|
||
if err != nil {
|
||
response.ServerError(c, "获取充值汇总失败")
|
||
return
|
||
}
|
||
if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
|
||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||
Status: options.Status,
|
||
Keyword: options.Keyword,
|
||
RegionID: query.RegionID,
|
||
StartAtMS: query.StartAtMS,
|
||
EndAtMS: query.EndAtMS,
|
||
TzOffsetMinutes: tzOffsetMinutes,
|
||
}, &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
|
||
}
|
||
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.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
|
||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||
Status: options.Status,
|
||
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 (h *Handler) applyFinanceThirdPartySummary(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, summary *rechargeBillSummaryDTO) error {
|
||
if summary == nil {
|
||
return nil
|
||
}
|
||
stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rechargeType := normalizedRechargeType(query.RechargeType)
|
||
if rechargeType == "" || rechargeType == "third_party" {
|
||
// 财务页 thirdParty 只表示后台工单校验通过的币商充值外部订单;普通 MifaPay/V5Pay/USDT H5 支付不能进入该桶。
|
||
summary.ThirdParty = stats.Verified
|
||
} else {
|
||
summary.ThirdParty = rechargeBillSummaryBucketDTO{}
|
||
}
|
||
if rechargeType != "" && rechargeType != "coin_seller" {
|
||
summary.CoinSeller = rechargeBillSummaryBucketDTO{}
|
||
}
|
||
if rechargeType != "" && rechargeType != "google_play_recharge" {
|
||
summary.GooglePlay = rechargeBillSummaryBucketDTO{}
|
||
}
|
||
if rechargeType == "" {
|
||
// coinSeller 桶保持全量展示;total 扣掉已落账重叠部分,避免同一财务工单同时经 thirdParty 与 coinSeller 重复计入总额。
|
||
summary.Total = nonNegativeRechargeBillBucket(rechargeBillSummaryBucketDTO{
|
||
BillCount: summary.GooglePlay.BillCount + summary.CoinSeller.BillCount + summary.ThirdParty.BillCount - stats.GrantedOverlap.BillCount,
|
||
CoinAmount: summary.GooglePlay.CoinAmount + summary.CoinSeller.CoinAmount + summary.ThirdParty.CoinAmount - stats.GrantedOverlap.CoinAmount,
|
||
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.CoinSeller.USDMinorAmount + summary.ThirdParty.USDMinorAmount - stats.GrantedOverlap.USDMinorAmount,
|
||
})
|
||
return nil
|
||
}
|
||
legacyRecomputeSummaryTotal(summary)
|
||
return nil
|
||
}
|
||
|
||
func (h *Handler) financeThirdPartyRechargeStats(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery) (financeThirdPartyRechargeStats, error) {
|
||
stats := financeThirdPartyRechargeStats{
|
||
VerifiedDaily: map[string]rechargeBillSummaryBucketDTO{},
|
||
GrantedDaily: map[string]rechargeBillSummaryBucketDTO{},
|
||
}
|
||
rechargeType := normalizedRechargeType(query.RechargeType)
|
||
if rechargeType != "" && rechargeType != "third_party" && rechargeType != "coin_seller" {
|
||
return stats, nil
|
||
}
|
||
if h == nil || h.store == nil || query.HasUserOnlyFilter {
|
||
return stats, nil
|
||
}
|
||
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
|
||
AppCode: appCode,
|
||
Status: query.Status,
|
||
RegionID: query.RegionID,
|
||
TargetUserID: query.TargetUserID,
|
||
Keyword: query.Keyword,
|
||
CreatedFromMS: query.StartAtMS,
|
||
CreatedToMS: query.EndAtMS,
|
||
TzOffsetMinutes: query.TzOffsetMinutes,
|
||
})
|
||
if err != nil {
|
||
return stats, err
|
||
}
|
||
stats.Verified = rechargeBillSummaryBucketDTO{BillCount: repoStats.VerifiedCount, CoinAmount: repoStats.VerifiedCoinAmount, USDMinorAmount: repoStats.VerifiedUSDMinor}
|
||
stats.GrantedOverlap = rechargeBillSummaryBucketDTO{BillCount: repoStats.GrantedCount, CoinAmount: repoStats.GrantedCoinAmount, USDMinorAmount: repoStats.GrantedUSDMinor}
|
||
stats.VerifiedDaily = financeThirdPartyDailyStats(repoStats.VerifiedDaily)
|
||
stats.GrantedDaily = financeThirdPartyDailyStats(repoStats.GrantedDaily)
|
||
stats.VerifiedRegions = repoStats.VerifiedRegionBuckets
|
||
return stats, nil
|
||
}
|
||
|
||
func financeThirdPartyDailyStats(in map[string]repository.CoinSellerRechargeOrderStatsBucket) map[string]rechargeBillSummaryBucketDTO {
|
||
out := make(map[string]rechargeBillSummaryBucketDTO, len(in))
|
||
for date, bucket := range in {
|
||
out[date] = rechargeBillSummaryBucketDTO{BillCount: bucket.Count, CoinAmount: bucket.CoinAmount, USDMinorAmount: bucket.USDMinor}
|
||
}
|
||
return out
|
||
}
|
||
|
||
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
|
||
}
|