793 lines
29 KiB
Go
793 lines
29 KiB
Go
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 App(Yumi 等)的外部账单源;命中 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 组装支付后台 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())
|
||
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)
|
||
}
|
||
}
|