tokenFactory/controller/topup.go

1068 lines
35 KiB
Go
Raw Permalink 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 controller
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/Calcium-Ion/go-epay/epay"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/shopspring/decimal"
)
func GetTopUpInfo(c *gin.Context) {
// 获取支付方式
payMethods := operation_setting.PayMethods
// 如果启用了 Stripe 支付,添加到支付方法列表
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
// 检查是否已经包含 Stripe
hasStripe := false
for _, method := range payMethods {
if method["type"] == "stripe" {
hasStripe = true
break
}
}
if !hasStripe {
stripeMethod := map[string]string{
"name": "Stripe",
"type": "stripe",
"color": "rgba(var(--semi-purple-5), 1)",
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
}
payMethods = append(payMethods, stripeMethod)
}
}
// 如果启用了 Waffo 支付,添加到支付方法列表
enableWaffo := setting.WaffoEnabled &&
((!setting.WaffoSandbox &&
setting.WaffoApiKey != "" &&
setting.WaffoPrivateKey != "" &&
setting.WaffoPublicCert != "") ||
(setting.WaffoSandbox &&
setting.WaffoSandboxApiKey != "" &&
setting.WaffoSandboxPrivateKey != "" &&
setting.WaffoSandboxPublicCert != ""))
if enableWaffo {
hasWaffo := false
for _, method := range payMethods {
if method["type"] == "waffo" {
hasWaffo = true
break
}
}
if !hasWaffo {
waffoMethod := map[string]string{
"name": "Waffo (Global Payment)",
"type": "waffo",
"color": "rgba(var(--semi-blue-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
}
payMethods = append(payMethods, waffoMethod)
}
}
data := gin.H{
"enable_online_topup": (operation_setting.OnlinePayProvider == "yipay" &&
(operation_setting.YipayRequestURL != "" || operation_setting.PayAddress != "") &&
operation_setting.YipayMchNo != "" &&
operation_setting.YipayAppId != "" &&
operation_setting.YipayAppSecret != "") ||
(operation_setting.OnlinePayProvider != "yipay" &&
operation_setting.PayAddress != "" &&
operation_setting.EpayId != "" &&
operation_setting.EpayKey != ""),
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
"enable_waffo_topup": enableWaffo,
"waffo_pay_methods": func() interface{} {
if enableWaffo {
return setting.GetWaffoPayMethods()
}
return nil
}(),
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"waffo_min_topup": setting.WaffoMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
"online_pay_provider": operation_setting.OnlinePayProvider,
}
common.ApiSuccess(c, data)
}
type EpayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
}
type AmountRequest struct {
Amount int64 `json:"amount"`
}
// buildUserEpayNotifyURL 规范化用户充值异步回调地址,避免重复拼接 notify 路径。
func buildUserEpayNotifyURL(callbackAddress string) string {
normalized := strings.TrimRight(strings.TrimSpace(callbackAddress), "/")
if strings.HasSuffix(normalized, "/api/user/epay/notify") {
return normalized
}
return normalized + "/api/user/epay/notify"
}
// verifyYipayNotify 验证 Yipay 回调签名。
func verifyYipayNotify(params map[string]string) bool {
sign := strings.TrimSpace(params["sign"])
if sign == "" || operation_setting.YipayAppSecret == "" {
return false
}
expected := signYipayMD5(params, operation_setting.YipayAppSecret)
return strings.EqualFold(sign, expected)
}
// sortedParamKeys 返回回调参数的有序键列表,便于日志排查。
func sortedParamKeys(params map[string]string) []string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// signYipayMD5 生成 Yipay MD5 签名(参数按 ASCII 排序)。
func signYipayMD5(params map[string]string, appSecret string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || strings.TrimSpace(v) == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys)+1)
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", k, params[k]))
}
parts = append(parts, "key="+appSecret)
raw := strings.Join(parts, "&")
sum := md5.Sum([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// parseYipayUnifiedOrderData 解析 Jeepay/Yipay 统一下单响应中的 data 字段(文档多为 JSON 对象,部分网关序列化为 JSON 字符串)。
func parseYipayUnifiedOrderData(yipayResp map[string]any) (map[string]any, error) {
raw, ok := yipayResp["data"]
if !ok || raw == nil {
return nil, fmt.Errorf("响应缺少 data 字段")
}
if m, ok := raw.(map[string]any); ok {
return m, nil
}
s, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("data 字段类型不支持")
}
s = strings.TrimSpace(s)
if s == "" || s == "{}" {
return nil, fmt.Errorf("data 字段为空")
}
var m map[string]any
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, fmt.Errorf("data JSON 字符串解析失败: %w", err)
}
return m, nil
}
// yipayOrderStringField 从统一下单 data 中读取字符串字段(兼容驼峰与蛇形命名,忽略空值与占位 "<nil>")。
func yipayOrderStringField(m map[string]any, keys ...string) string {
for _, k := range keys {
v, ok := m[k]
if !ok || v == nil {
continue
}
s := strings.TrimSpace(fmt.Sprintf("%v", v))
if s == "" || s == "<nil>" {
continue
}
return s
}
return ""
}
// looksLikeYipayPayJumpURL 判断是否为网关返回的可拉起收银台的链接http(s)、微信/支付宝 App scheme 等)。
func looksLikeYipayPayJumpURL(s string) bool {
low := strings.ToLower(strings.TrimSpace(s))
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return true
}
if strings.HasPrefix(low, "weixin://") || strings.HasPrefix(low, "weixinpay://") {
return true
}
if strings.HasPrefix(low, "alipays://") || strings.HasPrefix(low, "alipay://") {
return true
}
return false
}
// yipayURLExtractMaxDepth 限制嵌套 map/JSON 解析深度,防止异常响应导致过深递归。
const yipayURLExtractMaxDepth = 8
// yipayPayLinkInTextRegexp 从文本中提取首个支付相关链接http(s)、weixin://、alipay(s)://)。
var yipayPayLinkInTextRegexp = regexp.MustCompile(`(?:https?://|weixin://[^\s"'<>]*|weixinpay://[^\s"'<>]*|alipay?s://[^\s"'<>]+)`)
// yipayFirstPayLinkInString 返回文本中出现的第一个可识别的支付跳转链接。
func yipayFirstPayLinkInString(s string) string {
m := yipayPayLinkInTextRegexp.FindString(s)
return strings.TrimSpace(m)
}
// yipaySortedMapKeys 返回 map 键的有序列表,便于日志排查。
func yipaySortedMapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// yipayCoerceValueToHTTPURL 将网关返回字段转为可拉起支付的链接http(s)、weixin:// 等string / JSON / 嵌套 map / 文本内嵌)。
func yipayCoerceValueToHTTPURL(v any, depth int) string {
if v == nil || depth > yipayURLExtractMaxDepth {
return ""
}
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if s == "" || s == "<nil>" {
return ""
}
if looksLikeYipayPayJumpURL(s) {
return s
}
st := strings.TrimSpace(s)
if strings.HasPrefix(st, "{") || strings.HasPrefix(st, "[") {
var raw json.RawMessage
if err := json.Unmarshal([]byte(s), &raw); err == nil {
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err == nil {
if u := yipayExtractHTTPURLFromMap(obj, depth+1); u != "" {
return u
}
}
}
}
if u := yipayFirstPayLinkInString(s); u != "" {
return u
}
return ""
case map[string]any:
return yipayExtractHTTPURLFromMap(t, depth+1)
case []any:
for _, item := range t {
if u := yipayCoerceValueToHTTPURL(item, depth+1); u != "" {
return u
}
}
return ""
default:
s := strings.TrimSpace(fmt.Sprintf("%v", t))
if looksLikeYipayPayJumpURL(s) {
return s
}
return ""
}
}
// yipayExtractHTTPURLFromMap 在 map 中按常见字段名优先查找支付链接,再浅层扫描其余字段(跳过错误信息类键)。
func yipayExtractHTTPURLFromMap(m map[string]any, depth int) string {
if m == nil || depth > yipayURLExtractMaxDepth {
return ""
}
priorityKeys := []string{
"payUrl", "pay_url", "payJumpUrl", "pay_jump_url",
"codeUrl", "code_url",
"codeImgUrl", "code_img_url",
"url", "link", "h5Url", "h5_url", "checkoutUrl", "checkout_url",
"approvalUrl", "approval_url", "redirectUrl", "redirect_url",
"mweb_url", "mwebUrl", "payData", "pay_data",
}
for _, k := range priorityKeys {
if u := yipayCoerceValueToHTTPURL(m[k], depth+1); u != "" {
return u
}
}
skipKeys := map[string]struct{}{
"errMsg": {}, "err_msg": {}, "errCode": {}, "err_code": {},
"channelErrMsg": {}, "channelErrCode": {}, "errMsgFromChannel": {},
}
for k, v := range m {
if _, skip := skipKeys[k]; skip {
continue
}
if u := yipayCoerceValueToHTTPURL(v, depth+1); u != "" {
return u
}
}
return ""
}
// extractYipayPayURLFromOrderData 从 data 中提取支付跳转 URL兼容对象型 payData、嵌套 JSON、非标准字段名表单收银台单独提示
func extractYipayPayURLFromOrderData(dataObj map[string]any) (string, error) {
payDataType := strings.ToLower(yipayOrderStringField(dataObj, "payDataType", "pay_data_type"))
payDataStr := yipayOrderStringField(dataObj, "payData", "pay_data")
payDataRaw := dataObj["payData"]
if payDataRaw == nil {
payDataRaw = dataObj["pay_data"]
}
if payDataType == "form" || strings.Contains(strings.ToLower(payDataStr), "<form") {
for _, k := range []string{"payUrl", "pay_url", "payJumpUrl", "pay_jump_url"} {
if u := yipayCoerceValueToHTTPURL(dataObj[k], 0); u != "" {
return u, nil
}
}
return "", fmt.Errorf("Yipay 返回表单收银台payDataType=form当前充值页仅支持链接跳转请在网关配置返回 payUrl/payJumpUrl或升级前端支持表单 HTML 收银台")
}
for _, key := range []string{"payUrl", "pay_url", "payJumpUrl", "pay_jump_url"} {
if u := yipayCoerceValueToHTTPURL(dataObj[key], 0); u != "" {
return u, nil
}
}
if u := yipayCoerceValueToHTTPURL(payDataRaw, 0); u != "" {
return u, nil
}
if u := yipayExtractHTTPURLFromMap(dataObj, 0); u != "" {
return u, nil
}
ec := yipayOrderStringField(dataObj, "errCode", "err_code")
em := yipayOrderStringField(dataObj, "errMsg", "err_msg")
os := yipayOrderStringField(dataObj, "orderState", "order_state")
pid := yipayOrderStringField(dataObj, "payOrderId", "pay_order_id")
common.SysLog(fmt.Sprintf(
"[Yipay] unifiedOrder data 未解析出跳转链接: keys=%v payDataType=%q payDataGoType=%T errCode=%q errMsg=%q orderState=%s payOrderId=%s",
yipaySortedMapKeys(dataObj), payDataType, payDataRaw, ec, em, os, pid,
))
if ec != "" || em != "" {
return "", fmt.Errorf("Jeepay 已创建订单 payOrderId=%sorderState=%s上游 errCode=%q errMsg=%q 且无跳转链接;请核对 PayPal 通道配置或在管理后台「Yipay 渠道扩展参数」填写 {\"payDataType\":\"payUrl\"}", pid, os, ec, em)
}
if pid != "" {
return "", fmt.Errorf("Jeepay 返回 payOrderId=%s、orderState=%s但 data 中未解析出可用支付链接(微信支付多为 codeUrl/weixin://H5 多为 mweb_urlPayPal 多为 payUrlPP_PC 需在 channelExtra 提供 cancelUrl 等)", pid, os)
}
return "", fmt.Errorf("Yipay 返回支付链接为空data 内无可用 http(s) 链接,详见服务端日志)")
}
// yipayJeepayDefaultChannelExtra 构造 yiPay/Jeepay 默认 channelExtra。
// PP_PCUnifiedOrderRQ.buildBizRQ 将 channelExtra 解析为 PPPcOrderRQ其中 cancelUrl 为 @NotBlank见 yiPay PpPc.java / PPPcOrderRQ.java
func yipayJeepayDefaultChannelExtra(wayCode string, returnURL string) string {
u := strings.ToUpper(strings.TrimSpace(wayCode))
if u == "" {
return ""
}
ret := strings.TrimSpace(returnURL)
if u == "PP_PC" {
if ret == "" {
ret = "http://127.0.0.1/console/log"
}
b, err := json.Marshal(map[string]string{
"payDataType": "payUrl",
"cancelUrl": ret,
})
if err != nil {
return ""
}
return string(b)
}
if strings.HasSuffix(u, "_PC") {
return `{"payDataType":"payUrl"}`
}
return ""
}
// mergeYipayJeepayChannelExtra 合并管理端配置的 channelExtra JSON 与 wayCode 默认值(仅补充对方 JSON 中未出现的键)。
func mergeYipayJeepayChannelExtra(userJSON string, wayCode string, returnURL string) string {
userJSON = strings.TrimSpace(userJSON)
def := yipayJeepayDefaultChannelExtra(wayCode, returnURL)
if userJSON == "" {
return def
}
if def == "" {
return userJSON
}
var u map[string]any
if err := json.Unmarshal([]byte(userJSON), &u); err != nil {
return userJSON
}
var d map[string]any
if err := json.Unmarshal([]byte(def), &d); err != nil {
return userJSON
}
for k, v := range d {
if _, exists := u[k]; !exists {
u[k] = v
}
}
out, err := json.Marshal(u)
if err != nil {
return userJSON
}
return string(out)
}
// isPayPalJeepayWayCode 判断是否为 yiPay/Jeepay 的 PayPal 产品码(如 PP_PCPayPal REST 常不支持以 CNY 下单。
func isPayPalJeepayWayCode(wayCode string) bool {
u := strings.ToUpper(strings.TrimSpace(wayCode))
return strings.HasPrefix(u, "PP_")
}
// yipayJeepayAmountMinorAndCurrency 计算统一下单的 amount最小货币单位与币种。
// payMoney 与 getPayMoney 一致单位为人民币元PayPal 通道换算为 USD 美分(汇率优先 USDExchangeRate其次 Price
func yipayJeepayAmountMinorAndCurrency(payMoney float64, wayCode string) (minor int64, currency string) {
currency = "cny"
minor = int64(math.Round(payMoney * 100))
if minor < 1 {
minor = 1
}
if !isPayPalJeepayWayCode(wayCode) {
return minor, currency
}
rate := operation_setting.USDExchangeRate
if rate <= 0 {
rate = operation_setting.Price
}
if rate <= 0 {
rate = 7.3
}
usd := payMoney / rate
minor = int64(math.Round(usd * 100))
if minor < 1 {
minor = 1
}
return minor, "usd"
}
// requestYipayOrder 按 Yipay OpenAPI 创建统一下单请求。
func requestYipayOrder(req EpayRequest, id int, payMoney float64, paymentMethod string) (string, map[string]string, error) {
requestURL := operation_setting.YipayRequestURL
if requestURL == "" {
requestURL = operation_setting.PayAddress
}
requestURL = strings.TrimRight(requestURL, "/")
if requestURL == "" {
return "", nil, fmt.Errorf("未配置 Yipay 请求地址")
}
if operation_setting.YipayMchNo == "" || operation_setting.YipayAppId == "" || operation_setting.YipayAppSecret == "" {
return "", nil, fmt.Errorf("未配置完整的 Yipay 参数")
}
callBackAddress := service.GetCallbackAddress()
returnURLString := system_setting.ServerAddress + "/console/log"
if operation_setting.YipayReturnUrl != "" {
returnURLString = operation_setting.YipayReturnUrl
}
notifyURLString := buildUserEpayNotifyURL(callBackAddress)
if operation_setting.YipayNotifyUrl != "" {
notifyURLString = operation_setting.YipayNotifyUrl
}
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
amountMinor, orderCurrency := yipayJeepayAmountMinorAndCurrency(payMoney, paymentMethod)
reqTime := strconv.FormatInt(time.Now().UnixMilli(), 10)
chExtra := mergeYipayJeepayChannelExtra(strings.TrimSpace(operation_setting.YipayChannelExtra), paymentMethod, returnURLString)
params := map[string]string{
"mchNo": operation_setting.YipayMchNo,
"appId": operation_setting.YipayAppId,
"mchOrderNo": tradeNo,
"wayCode": paymentMethod,
"amount": strconv.FormatInt(amountMinor, 10),
"currency": strings.ToLower(orderCurrency),
"clientIp": "127.0.0.1",
"subject": fmt.Sprintf("TUC%d", req.Amount),
"body": fmt.Sprintf("TUC%d", req.Amount),
"notifyUrl": notifyURLString,
"returnUrl": returnURLString,
"reqTime": reqTime,
"version": "1.0",
"signType": "MD5",
}
if chExtra != "" {
params["channelExtra"] = chExtra
}
// 调试日志:输出关键参数的可见信息,便于定位是否包含前后空格(不输出 AppSecret 明文)。
common.SysLog(fmt.Sprintf(
"[Yipay] unifiedOrder params: mchNo=%q(len=%d,trimChanged=%t), appId=%q(len=%d,trimChanged=%t), wayCode=%q, amountMinor=%s currency=%s payMoneyCNY=%.4f channelExtra=%q, notifyUrl=%q, returnUrl=%q, reqTime=%q",
params["mchNo"], len(params["mchNo"]), strings.TrimSpace(params["mchNo"]) != params["mchNo"],
params["appId"], len(params["appId"]), strings.TrimSpace(params["appId"]) != params["appId"],
params["wayCode"], params["amount"], params["currency"], payMoney, chExtra, params["notifyUrl"], params["returnUrl"], params["reqTime"],
))
params["sign"] = signYipayMD5(params, operation_setting.YipayAppSecret)
requestJSON, err := common.Marshal(params)
if err != nil {
return "", nil, err
}
unifiedOrderURL := requestURL
if !strings.Contains(strings.ToLower(unifiedOrderURL), "/api/pay/unifiedorder") {
unifiedOrderURL = requestURL + "/api/pay/unifiedOrder"
}
httpResp, err := http.Post(unifiedOrderURL, "application/json", strings.NewReader(string(requestJSON)))
if err != nil {
return "", nil, err
}
defer httpResp.Body.Close()
respBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return "", nil, err
}
var yipayResp map[string]any
if err = common.Unmarshal(respBody, &yipayResp); err != nil {
respPreview := string(respBody)
if len(respPreview) > 300 {
respPreview = respPreview[:300]
}
return "", nil, fmt.Errorf("Yipay 响应解析失败(status=%d,url=%s): %s", httpResp.StatusCode, unifiedOrderURL, respPreview)
}
code := fmt.Sprintf("%v", yipayResp["code"])
if code != "0" && code != "200" {
msg := fmt.Sprintf("%v", yipayResp["msg"])
if msg == "<nil>" || msg == "" {
if v, ok := yipayResp["message"]; ok {
msg = fmt.Sprintf("%v", v)
}
}
if msg == "<nil>" || msg == "" {
if v, ok := yipayResp["errMsg"]; ok {
msg = fmt.Sprintf("%v", v)
}
}
if msg == "<nil>" || msg == "" {
msg = string(respBody)
if len(msg) > 300 {
msg = msg[:300]
}
}
return "", nil, fmt.Errorf("Yipay 下单失败(status=%d,code=%s,url=%s): %s", httpResp.StatusCode, code, unifiedOrderURL, msg)
}
dataObj, parseErr := parseYipayUnifiedOrderData(yipayResp)
if parseErr != nil {
return "", nil, fmt.Errorf("Yipay 统一下单返回解析失败: %w", parseErr)
}
// 少数网关/代理把 payUrl、payData 等放在响应根级而非 data 内,合并后再解析。
for _, k := range []string{"payUrl", "pay_url", "payJumpUrl", "pay_jump_url", "payData", "pay_data", "payDataType", "pay_data_type"} {
if v, ok := yipayResp[k]; ok && v != nil {
if _, exists := dataObj[k]; !exists {
dataObj[k] = v
}
}
}
payURL, extractErr := extractYipayPayURLFromOrderData(dataObj)
if extractErr != nil {
return "", nil, extractErr
}
payURL = strings.TrimSpace(payURL)
amount := req.Amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(int64(amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart()
}
topUp := &model.TopUp{
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: paymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
}
if err = topUp.Insert(); err != nil {
return "", nil, err
}
return payURL, map[string]string{}, nil
}
// GetEpayClient 创建并返回在线充值客户端。
func GetEpayClient() *epay.Client {
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
}
withUrl, err := epay.NewClient(&epay.Config{
PartnerID: operation_setting.EpayId,
Key: operation_setting.EpayKey,
}, operation_setting.PayAddress)
if err != nil {
return nil
}
return withUrl
}
func getPayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
// 充值金额以“展示类型”为准:
// - USD/CNY: 前端传 amount 为金额单位TOKENS: 前端传 tokens需要换成 USD 金额
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
dAmount = dAmount.Div(dQuotaPerUnit)
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
dPrice := decimal.NewFromFloat(operation_setting.Price)
// apply optional preset discount by the original request amount (if configured), default 1.0
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
if ds > 0 {
discount = ds
}
}
dDiscount := decimal.NewFromFloat(discount)
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
return payMoney.InexactFloat64()
}
func getMinTopup() int64 {
minTopup := operation_setting.MinTopUp
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
}
return int64(minTopup)
}
// RequestEpay 创建在线充值订单并拉起支付。
func RequestEpay(c *gin.Context) {
var req EpayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < getMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
paymentMethod := req.PaymentMethod
if !operation_setting.ContainsPayMethod(paymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
if operation_setting.OnlinePayProvider == "yipay" {
payURL, params, yipayErr := requestYipayOrder(req, id, payMoney, paymentMethod)
if yipayErr != nil {
c.JSON(200, gin.H{"message": "error", "data": yipayErr.Error()})
return
}
c.JSON(200, gin.H{"message": "success", "data": params, "url": payURL})
return
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(buildUserEpayNotifyURL(callBackAddress))
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
client := GetEpayClient()
if client == nil {
c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
Type: paymentMethod,
ServiceTradeNo: tradeNo,
Name: fmt.Sprintf("TUC%d", req.Amount),
Money: strconv.FormatFloat(payMoney, 'f', 2, 64),
Device: epay.PC,
NotifyUrl: notifyUrl,
ReturnUrl: returnUrl,
})
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
amount := req.Amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(int64(amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart()
}
topUp := &model.TopUp{
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: paymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
}
err = topUp.Insert()
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(200, gin.H{"message": "success", "data": params, "url": uri})
}
// tradeNo lock
var orderLocks sync.Map
var createLock sync.Mutex
// refCountedMutex 带引用计数的互斥锁,确保最后一个使用者才从 map 中删除
type refCountedMutex struct {
mu sync.Mutex
refCount int
}
// LockOrder 尝试对给定订单号加锁
func LockOrder(tradeNo string) {
createLock.Lock()
var rcm *refCountedMutex
if v, ok := orderLocks.Load(tradeNo); ok {
rcm = v.(*refCountedMutex)
} else {
rcm = &refCountedMutex{}
orderLocks.Store(tradeNo, rcm)
}
rcm.refCount++
createLock.Unlock()
rcm.mu.Lock()
}
// UnlockOrder 释放给定订单号的锁
func UnlockOrder(tradeNo string) {
v, ok := orderLocks.Load(tradeNo)
if !ok {
return
}
rcm := v.(*refCountedMutex)
rcm.mu.Unlock()
createLock.Lock()
rcm.refCount--
if rcm.refCount == 0 {
orderLocks.Delete(tradeNo)
}
createLock.Unlock()
}
func EpayNotify(c *gin.Context) {
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
log.Println("易支付回调POST解析失败:", err)
_, _ = c.Writer.Write([]byte("fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
// 兼容 JSON 回调体(部分 Yipay/Jeepay 部署会使用 application/json 推送)。
if len(params) == 0 {
bodyBytes, readErr := io.ReadAll(c.Request.Body)
if readErr == nil && len(bodyBytes) > 0 {
var payload map[string]any
if unmarshalErr := common.Unmarshal(bodyBytes, &payload); unmarshalErr == nil {
params = lo.Reduce(lo.Keys(payload), func(r map[string]string, t string, i int) map[string]string {
r[t] = fmt.Sprintf("%v", payload[t])
return r
}, map[string]string{})
}
}
}
} else {
// GET 请求:从 URL Query 解析参数
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.URL.Query().Get(t)
return r
}, map[string]string{})
}
if len(params) == 0 {
log.Printf("易支付回调参数为空method=%s, contentType=%s, remoteIP=%s, rawQuery=%s", c.Request.Method, c.ContentType(), c.ClientIP(), c.Request.URL.RawQuery)
_, _ = c.Writer.Write([]byte("fail"))
return
}
// 回调入站日志:用于确认回调是否到达以及携带了哪些参数。
log.Printf("支付回调已到达provider=%s, method=%s, contentType=%s, remoteIP=%s, keys=%v",
operation_setting.OnlinePayProvider, c.Request.Method, c.ContentType(), c.ClientIP(), sortedParamKeys(params))
// 先尝试按 Yipay 回调处理:使用 Yipay AppSecret 做 MD5 验签,并按 mchOrderNo/state 更新订单。
if operation_setting.OnlinePayProvider == "yipay" {
log.Printf("Yipay 回调关键参数mchOrderNo=%s, state=%s, payOrderId=%s",
params["mchOrderNo"], params["state"], params["payOrderId"])
if !verifyYipayNotify(params) {
log.Printf("Yipay 回调签名验证失败mchOrderNo=%s, state=%s", params["mchOrderNo"], params["state"])
_, _ = c.Writer.Write([]byte("fail"))
return
}
tradeNo := strings.TrimSpace(params["mchOrderNo"])
state := strings.TrimSpace(params["state"])
if tradeNo == "" {
log.Println("Yipay 回调缺少 mchOrderNo")
_, _ = c.Writer.Write([]byte("fail"))
return
}
if state != "2" {
log.Printf("Yipay 回调非成功状态mchOrderNo=%s, state=%s", tradeNo, state)
_, _ = c.Writer.Write([]byte("success"))
return
}
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
topUp := model.GetTopUpByTradeNo(tradeNo)
if topUp == nil {
log.Printf("Yipay 回调未找到订单: %s", tradeNo)
_, _ = c.Writer.Write([]byte("fail"))
return
}
if topUp.Status == "pending" {
topUp.Status = "success"
if err := topUp.Update(); err != nil {
log.Printf("Yipay 回调更新订单失败: %v", topUp)
_, _ = c.Writer.Write([]byte("fail"))
return
}
dAmount := decimal.NewFromInt(int64(topUp.Amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
if err := model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true); err != nil {
log.Printf("Yipay 回调更新用户失败: %v", topUp)
_, _ = c.Writer.Write([]byte("fail"))
return
}
log.Printf("Yipay 回调更新用户成功 %v", topUp)
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%f", logger.LogQuota(quotaToAdd), topUp.Money))
// 与易支付成功路径一致:到账后按邀请关系给邀请人分销提成
model.ApplyAffiliateTopupReward(topUp.UserId, quotaToAdd)
}
_, _ = c.Writer.Write([]byte("success"))
return
}
client := GetEpayClient()
if client == nil {
log.Println("易支付回调失败 未找到配置信息")
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
}
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
_, err := c.Writer.Write([]byte("success"))
if err != nil {
log.Println("易支付回调写入失败")
}
} else {
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
}
log.Println("易支付回调签名验证失败")
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
log.Println(verifyInfo)
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
if topUp == nil {
log.Printf("易支付回调未找到订单: %v", verifyInfo)
return
}
if topUp.Status == "pending" {
topUp.Status = "success"
err := topUp.Update()
if err != nil {
log.Printf("易支付回调更新订单失败: %v", topUp)
return
}
//user, _ := model.GetUserById(topUp.UserId, false)
//user.Quota += topUp.Amount * 500000
dAmount := decimal.NewFromInt(int64(topUp.Amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
if err != nil {
log.Printf("易支付回调更新用户失败: %v", topUp)
return
}
log.Printf("易支付回调更新用户成功 %v", topUp)
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%f", logger.LogQuota(quotaToAdd), topUp.Money))
// 支付回调成功且额度已入账后发放邀请人分销奖励(与 model.Recharge / Yipay 等路径一致)
model.ApplyAffiliateTopupReward(topUp.UserId, quotaToAdd)
}
} else {
log.Printf("易支付异常回调: %v", verifyInfo)
}
}
func RequestAmount(c *gin.Context) {
var req AmountRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < getMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
// parseTopUpListStatusFilter 解析充值列表的 status 查询参数,仅允许预定义状态;非法或 all 视为不按状态筛选。
func parseTopUpListStatusFilter(c *gin.Context) string {
s := strings.TrimSpace(strings.ToLower(c.Query("status")))
if s == "" || s == "all" {
return ""
}
switch s {
case common.TopUpStatusPending, common.TopUpStatusSuccess, common.TopUpStatusFailed, common.TopUpStatusExpired:
return s
default:
return ""
}
}
// parseTopUpTradeNoKeyword 解析订单号筛选:优先 trade_no其次兼容旧参数 keyword。
func parseTopUpTradeNoKeyword(c *gin.Context) string {
if v := strings.TrimSpace(c.Query("trade_no")); v != "" {
return v
}
return strings.TrimSpace(c.Query("keyword"))
}
func GetUserTopUps(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
tradeNoKeyword := parseTopUpTradeNoKeyword(c)
statusFilter := parseTopUpListStatusFilter(c)
var (
topups []*model.TopUp
total int64
err error
)
topups, total, err = model.GetUserTopUps(userId, pageInfo, statusFilter, tradeNoKeyword)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(topups)
common.ApiSuccess(c, pageInfo)
}
// GetAllTopUps 管理员获取全平台充值记录
func GetAllTopUps(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
tradeNoKeyword := parseTopUpTradeNoKeyword(c)
usernameKeyword := strings.TrimSpace(c.Query("username"))
statusFilter := parseTopUpListStatusFilter(c)
var (
topups []*model.TopUp
total int64
err error
)
topups, total, err = model.GetAllTopUps(pageInfo, statusFilter, tradeNoKeyword, usernameKeyword)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(topups)
common.ApiSuccess(c, pageInfo)
}
type AdminCompleteTopupRequest struct {
TradeNo string `json:"trade_no"`
}
// AdminCompleteTopUp 管理员补单接口
func AdminCompleteTopUp(c *gin.Context) {
var req AdminCompleteTopupRequest
if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
common.ApiErrorMsg(c, "参数错误")
return
}
// 订单级互斥,防止并发补单
LockOrder(req.TradeNo)
defer UnlockOrder(req.TradeNo)
// adminUsername 用于补单后写入使用日志详情,便于追溯具体操作者。
adminUsername := c.GetString("username")
if err := model.ManualCompleteTopUp(req.TradeNo, adminUsername); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}