2026-07-09 19:57:39 +08:00

793 lines
29 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 payment
import (
"context"
"database/sql"
"errors"
"math"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const usdtMicroUnit int64 = 1_000_000
type Handler struct {
wallet walletclient.Client
userDB *sql.DB
walletDB *sql.DB
store *repository.Store
audit shared.OperationLogger
exchangeRates exchangeRateClient
moneyRegionSources []MoneyRegionSource
billSources map[string]RechargeBillSource
}
type HandlerOption func(*Handler)
func WithMoneyRegionSources(sources ...MoneyRegionSource) HandlerOption {
return func(h *Handler) {
for _, source := range sources {
if source != nil {
h.moneyRegionSources = append(h.moneyRegionSources, source)
}
}
}
}
// WithRechargeBillSources 注册 legacy AppYumi 等)的外部账单源;命中 app_code 的充值明细和汇总直接读外部源。
func WithRechargeBillSources(sources ...RechargeBillSource) HandlerOption {
return func(h *Handler) {
for _, source := range sources {
if source == nil {
continue
}
if h.billSources == nil {
h.billSources = map[string]RechargeBillSource{}
}
h.billSources[appctx.Normalize(source.AppCode())] = source
}
}
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger, options ...HandlerOption) *Handler {
handler := &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
// Handler option 让 legacy likei 区域源保持可选;本地/测试未配置线上 Mongo 时payment 仍只读本地 hyapp_user 主数据。
for _, option := range options {
if option != nil {
option(handler)
}
}
return handler
}
func (h *Handler) ListRechargeBills(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType"))
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, response.Page{Items: []rechargeBillDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
source, hasSource := h.billSources[appCode]
normalizedType := normalizedRechargeType(rechargeType)
if normalizedType == "" || normalizedType == "coin_seller" {
query := financeCoinSellerRechargeQuery{
RechargeType: rechargeType,
Status: options.Status,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
Page: options.Page,
PageSize: options.PageSize,
RequestID: middleware.CurrentRequestID(c),
UserID: userID,
}
var items []rechargeBillDTO
var total int64
var err error
if normalizedType == "coin_seller" {
items, total, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query)
} else {
var legacySource RechargeBillSource
if hasSource {
legacySource = source
}
items, total, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, legacySource, query)
}
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "获取账单用户资料失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
return
}
if hasSource {
h.listLegacyRechargeBills(c, source)
return
}
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: rechargeType,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
items := make([]rechargeBillDTO, 0, len(resp.GetBills()))
for _, item := range resp.GetBills() {
items = append(items, rechargeBillFromProto(item))
}
// 账单事实仍由 wallet-service 返回;这里只按本页出现的付款用户和币商用户批量补展示资料,避免列表渲染时逐行查用户。
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "获取账单用户资料失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) resolveRechargeBillUserFilter(c *gin.Context, appCode string, filter shared.UserIdentityFilter, invalidMessage string, keys ...string) (int64, bool, bool) {
keyword := strings.TrimSpace(firstQuery(c, keys...))
if filter.IsEmpty() && keyword == "" {
return 0, false, true
}
if !filter.IsEmpty() {
userID, matched, err := shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, filter, time.Now().UnixMilli())
if err != nil {
response.ServerError(c, "查询账单用户信息失败")
return 0, false, false
}
return userID, matched, true
}
// 充值账单事实在 wallet-service筛选字段只能传内部 user_id后台先把长 ID、默认短号、当前展示号和 active 靓号解析成同一个稳定 ID。
userID, _, err := shared.ResolveExactUserIDOrNumericFallback(c.Request.Context(), h.userDB, appCode, keyword, time.Now().UnixMilli())
if err != nil {
if errors.Is(err, shared.ErrInvalidUserIdentityFilter) {
response.BadRequest(c, invalidMessage)
return 0, false, false
}
response.ServerError(c, "查询账单用户信息失败")
return 0, false, false
}
return userID, true, true
}
// fillRechargeBillUsers 将用户资料写回当前页账单 DTO缺失资料会在 DTO 层保留长 ID避免影响账单事实展示。
func (h *Handler) fillRechargeBillUsers(ctx context.Context, appCode string, items []rechargeBillDTO) error {
userIDs := collectRechargeBillUserIDs(items)
if len(userIDs) == 0 {
return nil
}
profiles, err := h.loadRechargeBillUsers(ctx, appCode, userIDs)
if err != nil {
return err
}
for index := range items {
items[index].User = rechargeBillUserOrFallback(profiles[items[index].UserID], items[index].UserID)
items[index].Seller = rechargeBillUserOrFallback(profiles[items[index].SellerUserID], items[index].SellerUserID)
}
return nil
}
// loadRechargeBillUsers 按 app_code 和用户 ID 批量读取展示资料;国家只服务后台账单展示,账单金额和区域事实仍以 wallet-service 为准。
func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]rechargeBillUserDTO, error) {
if h == nil || h.userDB == nil {
return nil, errors.New("user mysql is not configured")
}
// 本查询只读取 users/countries 表里的展示字段,按照 app_code 和 user_id 精确命中;区域和账单筛选仍以 wallet-service 返回为准。
rows, err := h.userDB.QueryContext(ctx, `
SELECT u.user_id, COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.avatar, ''),
COALESCE(u.country, ''), COALESCE(c.country_name, ''), COALESCE(c.country_display_name, '')
FROM users u
LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
WHERE u.app_code = ? AND u.user_id IN (`+placeholders(len(userIDs))+`)`,
append([]any{appCode}, int64Args(userIDs)...)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
profiles := make(map[int64]rechargeBillUserDTO, len(userIDs))
for rows.Next() {
var profile rechargeBillUserDTO
var userID int64
if err := rows.Scan(
&userID,
&profile.DisplayUserID,
&profile.Username,
&profile.Avatar,
&profile.CountryCode,
&profile.CountryName,
&profile.CountryDisplayName,
); err != nil {
return nil, err
}
profile.UserID = strconv.FormatInt(userID, 10)
profiles[userID] = profile
}
if err := rows.Err(); err != nil {
return nil, err
}
return profiles, nil
}
func (h *Handler) ListRechargeProducts(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.wallet.ListAdminRechargeProducts(c.Request.Context(), &walletv1.ListAdminRechargeProductsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
AudienceType: strings.TrimSpace(firstQuery(c, "audience_type", "audienceType")),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
writeWalletError(c, err, "获取内购配置失败")
return
}
items := make([]rechargeProductDTO, 0, len(resp.GetProducts()))
for _, item := range resp.GetProducts() {
items = append(items, rechargeProductFromProto(item))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) CreateRechargeProduct(c *gin.Context) {
var request rechargeProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "内购配置参数不正确")
return
}
amountMicro, err := parseUSDTMicro(request.AmountUSDT, request.AmountUSDTMicro)
if err != nil {
response.BadRequest(c, "金额USDT不正确")
return
}
req := &walletv1.CreateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AmountMicro: amountMicro,
CoinAmount: request.CoinAmount,
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
AudienceType: strings.TrimSpace(request.AudienceType),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
}
if request.AppVisible != nil {
req.AppVisible = request.AppVisible
}
resp, err := h.wallet.CreateRechargeProduct(c.Request.Context(), req)
if err != nil {
writeWalletError(c, err, "创建内购配置失败")
return
}
item := rechargeProductFromProto(resp.GetProduct())
shared.OperationLogWithResourceID(c, h.audit, "create-recharge-product", "wallet_recharge_products", strconv.FormatInt(item.ProductID, 10), "success", item.ProductCode)
response.Created(c, item)
}
func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
productID, ok := productID(c)
if !ok {
return
}
var request rechargeProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "内购配置参数不正确")
return
}
amountMicro, err := parseUSDTMicro(request.AmountUSDT, request.AmountUSDTMicro)
if err != nil {
response.BadRequest(c, "金额USDT不正确")
return
}
req := &walletv1.UpdateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID,
AmountMicro: amountMicro,
CoinAmount: request.CoinAmount,
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
AudienceType: strings.TrimSpace(request.AudienceType),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
}
if request.AppVisible != nil {
req.AppVisible = request.AppVisible
}
resp, err := h.wallet.UpdateRechargeProduct(c.Request.Context(), req)
if err != nil {
writeWalletError(c, err, "修改内购配置失败")
return
}
item := rechargeProductFromProto(resp.GetProduct())
shared.OperationLogWithResourceID(c, h.audit, "update-recharge-product", "wallet_recharge_products", strconv.FormatInt(item.ProductID, 10), "success", item.ProductCode)
response.OK(c, item)
}
func (h *Handler) DeleteRechargeProduct(c *gin.Context) {
productID, ok := productID(c)
if !ok {
return
}
_, err := h.wallet.DeleteRechargeProduct(c.Request.Context(), &walletv1.DeleteRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "删除内购配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "delete-recharge-product", "wallet_recharge_products", strconv.FormatInt(productID, 10), "success", "")
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListThirdPartyPaymentChannels(c *gin.Context) {
resp, err := h.wallet.ListThirdPartyPaymentChannels(c.Request.Context(), &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
Status: strings.TrimSpace(firstQuery(c, "status")),
IncludeDisabledMethods: true,
})
if err != nil {
writeWalletError(c, err, "获取三方支付渠道失败")
return
}
items := make([]thirdPartyPaymentChannelDTO, 0, len(resp.GetChannels()))
for _, item := range resp.GetChannels() {
items = append(items, thirdPartyPaymentChannelFromProto(item))
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) ListTemporaryPaymentLinks(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.Normalize(firstNonEmptyString(firstQuery(c, "app_code", "appCode"), appctx.FromContext(c.Request.Context())))
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.store != nil && !moneyAccessAllowsApp(access, appCode) {
response.Forbidden(c, "没有该 App 的财务范围")
return
}
resp, err := h.wallet.ListTemporaryRechargeOrders(c.Request.Context(), &walletv1.ListTemporaryRechargeOrdersRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
Status: options.Status,
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
writeWalletError(c, err, "获取三方临时支付链接失败")
return
}
items := make([]temporaryPaymentLinkDTO, 0, len(resp.GetOrders()))
for _, item := range resp.GetOrders() {
items = append(items, temporaryPaymentLinkFromProto(item))
}
if h.store != nil {
items, err = h.enrichAndFilterTemporaryLinks(appCode, items, access, queryInt64(c, "region_id", "regionId"), queryUint(c, "operator_user_id", "operatorUserId"))
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: int64(len(items))})
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) GetTemporaryPaymentLink(c *gin.Context) {
orderID := strings.TrimSpace(c.Param("order_id"))
if orderID == "" {
response.BadRequest(c, "支付链接订单号不能为空")
return
}
appCode := appctx.Normalize(firstNonEmptyString(firstQuery(c, "app_code", "appCode"), appctx.FromContext(c.Request.Context())))
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.store != nil && !moneyAccessAllowsApp(access, appCode) {
response.Forbidden(c, "没有该 App 的财务范围")
return
}
// 查询单条订单会进入 wallet-service 的支付状态刷新逻辑admin 页面只发起核验,不在浏览器里拼三方查询参数。
resp, err := h.wallet.GetTemporaryRechargeOrder(c.Request.Context(), &walletv1.GetTemporaryRechargeOrderRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
OrderId: orderID,
})
if err != nil {
writeWalletError(c, err, "校验三方临时支付链接失败")
return
}
item := temporaryPaymentLinkFromProto(resp.GetOrder())
if h.store != nil {
items, err := h.enrichAndFilterTemporaryLinks(appCode, []temporaryPaymentLinkDTO{item}, access, 0, 0)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
if len(items) == 0 {
response.Forbidden(c, "没有该支付链接的财务范围")
return
}
item = items[0]
}
response.OK(c, item)
}
func (h *Handler) SetThirdPartyPaymentMethodStatus(c *gin.Context) {
methodID := queryPathInt64(c, "method_id")
if methodID <= 0 {
response.BadRequest(c, "支付方式ID不正确")
return
}
var request thirdPartyPaymentMethodStatusRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付方式状态参数不正确")
return
}
resp, err := h.wallet.SetThirdPartyPaymentMethodStatus(c.Request.Context(), &walletv1.SetThirdPartyPaymentMethodStatusRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: methodID,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改支付方式状态失败")
return
}
item := thirdPartyPaymentMethodFromProto(resp.GetMethod())
shared.OperationLogWithResourceID(c, h.audit, "update-third-party-payment-method-status", "third_party_payment_methods", strconv.FormatInt(methodID, 10), "success", item.Status)
response.OK(c, item)
}
func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) {
methodID := queryPathInt64(c, "method_id")
if methodID <= 0 {
response.BadRequest(c, "支付方式ID不正确")
return
}
var request thirdPartyPaymentRateRequest
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.USDToCurrencyRate) == "" {
response.BadRequest(c, "支付汇率参数不正确")
return
}
resp, err := h.wallet.UpdateThirdPartyPaymentRate(c.Request.Context(), &walletv1.UpdateThirdPartyPaymentRateRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: methodID,
UsdToCurrencyRate: strings.TrimSpace(request.USDToCurrencyRate),
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改支付方式汇率失败")
return
}
item := thirdPartyPaymentMethodFromProto(resp.GetMethod())
shared.OperationLogWithResourceID(c, h.audit, "update-third-party-payment-rate", "third_party_payment_methods", strconv.FormatInt(methodID, 10), "success", item.USDToCurrencyRate)
response.OK(c, item)
}
func (h *Handler) SyncThirdPartyPaymentMethods(c *gin.Context) {
var request thirdPartyPaymentMethodSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付方式同步参数不正确")
return
}
}
providerCode := strings.TrimSpace(firstNonEmptyString(request.ProviderCode, firstQuery(c, "provider_code", "providerCode"), "v5pay"))
resp, err := h.wallet.SyncThirdPartyPaymentMethods(c.Request.Context(), &walletv1.SyncThirdPartyPaymentMethodsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: providerCode,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "同步支付方式失败")
return
}
dto := thirdPartyPaymentMethodSyncFromProto(resp)
shared.OperationLog(c, h.audit, "sync-third-party-payment-methods", "third_party_payment_methods", "success", dto.ProviderCode)
response.OK(c, dto)
}
func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
var request thirdPartyPaymentRateSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
// 同步接口历史上允许空 body 直接触发;现在弹窗只新增上浮比例,空 body 仍按 0% 处理,避免旧后台或脚本调用被破坏。
if err := c.ShouldBindJSON(&request); err != nil || !validThirdPartyPaymentRateMarkup(request.MarkupPercent) {
response.BadRequest(c, "支付汇率同步参数不正确")
return
}
}
resp, err := h.wallet.ListThirdPartyPaymentChannels(c.Request.Context(), &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
IncludeDisabledMethods: true,
})
if err != nil {
writeWalletError(c, err, "获取三方支付方式失败")
return
}
methods := collectThirdPartyPaymentMethods(resp.GetChannels())
currencies := collectThirdPartyPaymentCurrencies(methods)
if len(currencies) == 0 {
response.OK(c, thirdPartyPaymentRateSyncDTO{UpdatedCount: 0})
return
}
// 汇率同步必须以服务端实时拉取结果为准:前端只传运营确认的上浮比例,原始汇率、失败切换和最终写库都留在服务端收敛。
rateClient := h.exchangeRates
if rateClient == nil {
rateClient = newDefaultExchangeRateClient()
}
rateResult, err := rateClient.LatestUSDRates(c.Request.Context(), currencies)
if err != nil {
response.ServerError(c, "同步支付汇率失败")
return
}
updated := 0
skipped := make(map[string]struct{})
writtenRates := make(map[string]string, len(rateResult.Rates))
for _, method := range methods {
currencyCode := strings.ToUpper(strings.TrimSpace(method.GetCurrencyCode()))
rate, ok := applyThirdPartyPaymentRateMarkup(rateResult.Rates[currencyCode], request.MarkupPercent)
if !ok {
skipped[currencyCode] = struct{}{}
continue
}
if _, err := h.wallet.UpdateThirdPartyPaymentRate(c.Request.Context(), &walletv1.UpdateThirdPartyPaymentRateRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: method.GetMethodId(),
UsdToCurrencyRate: rate,
OperatorUserId: actorID(c),
}); err != nil {
writeWalletError(c, err, "写入支付汇率失败")
return
}
writtenRates[currencyCode] = rate
updated++
}
skippedCurrencies := sortedMapKeys(skipped)
shared.OperationLog(c, h.audit, "sync-third-party-payment-rates", "third_party_payment_methods", "success", strings.Join(rateResult.SourceNames, ","))
response.OK(c, thirdPartyPaymentRateSyncDTO{
UpdatedCount: updated,
SkippedCount: len(skippedCurrencies),
MissingCurrencies: skippedCurrencies,
SourceNames: rateResult.SourceNames,
Sources: rateResult.Sources,
Rates: writtenRates,
})
}
type rechargeProductRequest struct {
AmountUSDT string `json:"amountUsdt"`
AmountUSDTMicro int64 `json:"amountUsdtMicro"`
CoinAmount int64 `json:"coinAmount"`
ProductName string `json:"productName"`
Description string `json:"description"`
AudienceType string `json:"audienceType"`
Platform string `json:"platform"`
RegionIDs []int64 `json:"regionIds"`
Enabled bool `json:"enabled"`
AppVisible *bool `json:"appVisible"`
}
type thirdPartyPaymentMethodStatusRequest struct {
Enabled bool `json:"enabled"`
}
type thirdPartyPaymentRateRequest struct {
USDToCurrencyRate string `json:"usdToCurrencyRate"`
}
type thirdPartyPaymentMethodSyncRequest struct {
ProviderCode string `json:"providerCode"`
}
type thirdPartyPaymentRateSyncRequest struct {
MarkupPercent float64 `json:"markupPercent"`
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}
func queryPathInt64(c *gin.Context, name string) int64 {
value := strings.TrimSpace(c.Param(name))
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed
}
func productID(c *gin.Context) (int64, bool) {
value := strings.TrimSpace(c.Param("product_id"))
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
response.BadRequest(c, "内购配置 ID 不正确")
return 0, false
}
return parsed, true
}
func actorID(c *gin.Context) int64 {
return int64(shared.ActorFromContext(c).UserID)
}
func parseUSDTMicro(amountText string, amountMicro int64) (int64, error) {
if amountMicro > 0 {
return amountMicro, nil
}
raw := strings.TrimSpace(amountText)
if raw == "" {
return 0, errors.New("amount is required")
}
if strings.HasPrefix(raw, "+") {
raw = strings.TrimPrefix(raw, "+")
}
parts := strings.Split(raw, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, errors.New("amount format is invalid")
}
if !allDigits(parts[0]) {
return 0, errors.New("amount integer part is invalid")
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || whole > math.MaxInt64/usdtMicroUnit {
return 0, errors.New("amount is too large")
}
var fraction int64
if len(parts) == 2 {
if parts[1] == "" || len(parts[1]) > 6 || !allDigits(parts[1]) {
return 0, errors.New("amount fraction part is invalid")
}
padded := parts[1] + strings.Repeat("0", 6-len(parts[1]))
fraction, err = strconv.ParseInt(padded, 10, 64)
if err != nil {
return 0, errors.New("amount fraction part is invalid")
}
}
if whole > (math.MaxInt64-fraction)/usdtMicroUnit {
return 0, errors.New("amount is too large")
}
value := whole*usdtMicroUnit + fraction
if value <= 0 {
return 0, errors.New("amount must be positive")
}
return value, nil
}
func allDigits(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return value != ""
}
func validThirdPartyPaymentRateMarkup(markupPercent float64) bool {
// 上浮比例来自后台运营弹窗,只允许 0-100 的有限数值;负数会压低支付汇率,过大数值会直接放大用户本币支付金额。
return !math.IsNaN(markupPercent) && !math.IsInf(markupPercent, 0) && markupPercent >= 0 && markupPercent <= 100
}
func applyThirdPartyPaymentRateMarkup(rateText string, markupPercent float64) (string, bool) {
rawRate, err := strconv.ParseFloat(strings.TrimSpace(rateText), 64)
if err != nil || rawRate <= 0 || !validThirdPartyPaymentRateMarkup(markupPercent) {
return "", false
}
// 汇率源返回的是实时 USD->本币原值;写库前先叠加运营上浮比例,再按金额量级向上取整,保证 H5 下单汇率不低于实时汇率。
markedRate := rawRate * (1 + markupPercent/100)
if markedRate > 50 {
// 高汇率币种(例如 IDR展示和支付金额都不应出现小数减去极小量只抵消 float 乘法毛刺,真实 18510.1 仍会进到 18511。
return strconv.FormatFloat(math.Ceil(markedRate-1e-9), 'f', 0, 64), true
}
// 低汇率币种仍保留原有“最多 1 位小数且向上取”的策略,避免 SAR/BHD 这类币种因强行整数化放大支付金额。
roundedRate := math.Ceil(markedRate*10-1e-9) / 10
return trimOneDecimalRate(strconv.FormatFloat(roundedRate, 'f', 1, 64)), true
}
func trimOneDecimalRate(rateText string) string {
// 产品要求“最多 1 位小数”,整数汇率不保留无意义的 .0,避免后台列表和人工核对时出现多余精度。
return strings.TrimSuffix(rateText, ".0")
}
func writeWalletError(c *gin.Context, err error, fallback string) {
message := fallback
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {
message = st.Message()
}
switch status.Code(err) {
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
response.BadRequest(c, message)
case codes.NotFound:
response.NotFound(c, message)
default:
response.ServerError(c, fallback)
}
}