2026-06-25 11:21:16 +08:00

549 lines
19 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"
"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/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
audit shared.OperationLogger
exchangeRates exchangeRateClient
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
}
func (h *Handler) ListRechargeBills(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: queryInt64(c, "user_id", "userId"),
SellerUserId: queryInt64(c, "seller_user_id", "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()})
}
// 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)
resp, err := h.wallet.ListTemporaryRechargeOrders(c.Request.Context(), &walletv1.ListTemporaryRechargeOrdersRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
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))
}
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
}
// 查询单条订单会进入 wallet-service 的支付状态刷新逻辑admin 页面只发起核验,不在浏览器里拼三方查询参数。
resp, err := h.wallet.GetTemporaryRechargeOrder(c.Request.Context(), &walletv1.GetTemporaryRechargeOrderRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
OrderId: orderID,
})
if err != nil {
writeWalletError(c, err, "校验三方临时支付链接失败")
return
}
response.OK(c, temporaryPaymentLinkFromProto(resp.GetOrder()))
}
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) SyncThirdPartyPaymentRates(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")),
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{})
for _, method := range methods {
currencyCode := strings.ToUpper(strings.TrimSpace(method.GetCurrencyCode()))
rate := rateResult.Rates[currencyCode]
if rate == "" {
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
}
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: rateResult.Rates,
})
}
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"`
}
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 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)
}
}