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 } 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) } } } } // New 组装支付后台 handler;wallet 负责账单和商品事实,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()) 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 } 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: 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"), 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 } resp, err := h.wallet.CreateRechargeProduct(c.Request.Context(), &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 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 } resp, err := h.wallet.UpdateRechargeProduct(c.Request.Context(), &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 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"` } 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->本币原值;写库前统一叠加运营设置的上浮比例,再按 1 位小数向上取,保证 H5 下单看到的支付汇率不低于实时汇率。 markedRate := rawRate * (1 + markupPercent/100) 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) } }