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 } // New 组装支付后台 handler;wallet 负责账单和商品事实,userDB 只补后台列表需要的用户展示资料。 func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler { return &Handler{wallet: wallet, userDB: userDB, audit: audit} } 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) 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) } 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) } }