385 lines
14 KiB
Go
385 lines
14 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|