package payment import ( "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 audit shared.OperationLogger } func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler { return &Handler{wallet: wallet, audit: audit} } func (h *Handler) ListRechargeBills(c *gin.Context) { options := shared.ListOptions(c) resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), 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)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } 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"), 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), 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), 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}) } type rechargeProductRequest struct { AmountUSDT string `json:"amountUsdt"` AmountUSDTMicro int64 `json:"amountUsdtMicro"` CoinAmount int64 `json:"coinAmount"` ProductName string `json:"productName"` Description string `json:"description"` Platform string `json:"platform"` RegionIDs []int64 `json:"regionIds"` Enabled bool `json:"enabled"` } 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 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) } }