mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-06-07 22:09:57 +00:00
✨ fix(subscription): finalize payments, log billing, and clean up dead code
Complete subscription orders by creating a matching top-up record and writing billing logs Add Epay return handler to verify and finalize browser callbacks Require Stripe/Creem webhook configuration before starting subscription payments Show subscription purchases in topup history with clearer labels/methods Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields Simplify subscription self API payload to active/all lists only
This commit is contained in:
@@ -68,17 +68,10 @@ func GetSubscriptionSelf(c *gin.Context) {
|
|||||||
activeSubscriptions = []model.SubscriptionSummary{}
|
activeSubscriptions = []model.SubscriptionSummary{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For backward compatibility, also return the first active subscription as "subscription"
|
|
||||||
var summary *model.SubscriptionSummary
|
|
||||||
if len(activeSubscriptions) > 0 {
|
|
||||||
summary = &activeSubscriptions[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
common.ApiSuccess(c, gin.H{
|
common.ApiSuccess(c, gin.H{
|
||||||
"billing_preference": pref,
|
"billing_preference": pref,
|
||||||
"subscription": summary, // backward compatibility (first active)
|
"subscriptions": activeSubscriptions, // all active subscriptions
|
||||||
"subscriptions": activeSubscriptions, // all active subscriptions
|
"all_subscriptions": allSubscriptions, // all subscriptions including expired
|
||||||
"all_subscriptions": allSubscriptions, // all subscriptions including expired
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/thanhpk/randstr"
|
"github.com/thanhpk/randstr"
|
||||||
)
|
)
|
||||||
@@ -46,6 +47,10 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
|
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if setting.CreemWebhookSecret == "" && !setting.CreemTestMode {
|
||||||
|
common.ApiErrorMsg(c, "Creem Webhook 未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(userId, false)
|
user, _ := model.GetUserById(userId, false)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
callBackAddress := service.GetCallbackAddress()
|
callBackAddress := service.GetCallbackAddress()
|
||||||
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/topup")
|
returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
||||||
notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
||||||
|
|
||||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||||
@@ -124,3 +124,31 @@ func SubscriptionEpayNotify(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscriptionEpayReturn handles browser return after payment.
|
||||||
|
// It verifies the payload and completes the order, then redirects to console.
|
||||||
|
func SubscriptionEpayReturn(c *gin.Context) {
|
||||||
|
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{})
|
||||||
|
|
||||||
|
client := GetEpayClient()
|
||||||
|
if client == nil {
|
||||||
|
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifyInfo, err := client.Verify(params)
|
||||||
|
if err != nil || !verifyInfo.VerifyStatus {
|
||||||
|
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||||
|
LockOrder(verifyInfo.ServiceTradeNo)
|
||||||
|
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||||
|
_ = model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo))
|
||||||
|
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
|
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if setting.StripeWebhookSecret == "" {
|
||||||
|
common.ApiErrorMsg(c, "Stripe Webhook 未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(userId, false)
|
user, _ := model.GetUserById(userId, false)
|
||||||
|
|||||||
@@ -65,12 +65,10 @@ func GetTopUpInfo(c *gin.Context) {
|
|||||||
type EpayRequest struct {
|
type EpayRequest struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
PaymentMethod string `json:"payment_method"`
|
PaymentMethod string `json:"payment_method"`
|
||||||
TopUpCode string `json:"top_up_code"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AmountRequest struct {
|
type AmountRequest struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
TopUpCode string `json:"top_up_code"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetEpayClient() *epay.Client {
|
func GetEpayClient() *epay.Client {
|
||||||
|
|||||||
@@ -227,16 +227,6 @@ type CreemWebhookEvent struct {
|
|||||||
} `json:"object"`
|
} `json:"object"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留旧的结构体作为兼容
|
|
||||||
type CreemWebhookData struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Data struct {
|
|
||||||
RequestId string `json:"request_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Metadata map[string]string `json:"metadata"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreemWebhook(c *gin.Context) {
|
func CreemWebhook(c *gin.Context) {
|
||||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||||
|
|||||||
@@ -255,7 +255,11 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
|||||||
if common.UsingPostgreSQL {
|
if common.UsingPostgreSQL {
|
||||||
refCol = `"trade_no"`
|
refCol = `"trade_no"`
|
||||||
}
|
}
|
||||||
return DB.Transaction(func(tx *gorm.DB) error {
|
var logUserId int
|
||||||
|
var logPlanTitle string
|
||||||
|
var logMoney float64
|
||||||
|
var logPaymentMethod string
|
||||||
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
var order SubscriptionOrder
|
var order SubscriptionOrder
|
||||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
||||||
return errors.New("subscription order not found")
|
return errors.New("subscription order not found")
|
||||||
@@ -277,13 +281,65 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := upsertSubscriptionTopUpTx(tx, &order); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
order.Status = common.TopUpStatusSuccess
|
order.Status = common.TopUpStatusSuccess
|
||||||
order.CompleteTime = common.GetTimestamp()
|
order.CompleteTime = common.GetTimestamp()
|
||||||
if providerPayload != "" {
|
if providerPayload != "" {
|
||||||
order.ProviderPayload = providerPayload
|
order.ProviderPayload = providerPayload
|
||||||
}
|
}
|
||||||
return tx.Save(&order).Error
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logUserId = order.UserId
|
||||||
|
logPlanTitle = plan.Title
|
||||||
|
logMoney = order.Money
|
||||||
|
logPaymentMethod = order.PaymentMethod
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if logUserId > 0 {
|
||||||
|
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
|
||||||
|
RecordLog(logUserId, LogTypeTopup, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
|
||||||
|
if tx == nil || order == nil {
|
||||||
|
return errors.New("invalid subscription order")
|
||||||
|
}
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
var topup TopUp
|
||||||
|
if err := tx.Where("trade_no = ?", order.TradeNo).First(&topup).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
topup = TopUp{
|
||||||
|
UserId: order.UserId,
|
||||||
|
Amount: 0,
|
||||||
|
Money: order.Money,
|
||||||
|
TradeNo: order.TradeNo,
|
||||||
|
PaymentMethod: order.PaymentMethod,
|
||||||
|
CreateTime: order.CreateTime,
|
||||||
|
CompleteTime: now,
|
||||||
|
Status: common.TopUpStatusSuccess,
|
||||||
|
}
|
||||||
|
return tx.Create(&topup).Error
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
topup.Money = order.Money
|
||||||
|
if topup.PaymentMethod == "" {
|
||||||
|
topup.PaymentMethod = order.PaymentMethod
|
||||||
|
}
|
||||||
|
if topup.CreateTime == 0 {
|
||||||
|
topup.CreateTime = order.CreateTime
|
||||||
|
}
|
||||||
|
topup.CompleteTime = now
|
||||||
|
topup.Status = common.TopUpStatusSuccess
|
||||||
|
return tx.Save(&topup).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExpireSubscriptionOrder(tradeNo string) error {
|
func ExpireSubscriptionOrder(tradeNo string) error {
|
||||||
@@ -323,26 +379,6 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current active subscription (best-effort: latest end_time)
|
|
||||||
func GetActiveUserSubscription(userId int) (*SubscriptionSummary, error) {
|
|
||||||
if userId <= 0 {
|
|
||||||
return nil, errors.New("invalid userId")
|
|
||||||
}
|
|
||||||
now := common.GetTimestamp()
|
|
||||||
var sub UserSubscription
|
|
||||||
err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
|
|
||||||
Order("end_time desc, id desc").
|
|
||||||
First(&sub).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var items []UserSubscriptionItem
|
|
||||||
if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &SubscriptionSummary{Subscription: &sub, Items: items}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
||||||
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||||
if userId <= 0 {
|
if userId <= 0 {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
|
|
||||||
// Subscription payment callbacks (no auth)
|
// Subscription payment callbacks (no auth)
|
||||||
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||||
|
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
|
||||||
optionRoute := apiRouter.Group("/option")
|
optionRoute := apiRouter.Group("/option")
|
||||||
optionRoute.Use(middleware.RootAuth())
|
optionRoute.Use(middleware.RootAuth())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Empty,
|
Empty,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
Tag,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IllustrationNoResult,
|
IllustrationNoResult,
|
||||||
@@ -49,6 +50,7 @@ const STATUS_CONFIG = {
|
|||||||
// 支付方式映射
|
// 支付方式映射
|
||||||
const PAYMENT_METHOD_MAP = {
|
const PAYMENT_METHOD_MAP = {
|
||||||
stripe: 'Stripe',
|
stripe: 'Stripe',
|
||||||
|
creem: 'Creem',
|
||||||
alipay: '支付宝',
|
alipay: '支付宝',
|
||||||
wxpay: '微信',
|
wxpay: '微信',
|
||||||
};
|
};
|
||||||
@@ -150,6 +152,11 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
|||||||
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSubscriptionTopup = (record) => {
|
||||||
|
const tradeNo = (record?.trade_no || '').toLowerCase();
|
||||||
|
return Number(record?.amount || 0) === 0 && tradeNo.startsWith('sub');
|
||||||
|
};
|
||||||
|
|
||||||
// 检查是否为管理员
|
// 检查是否为管理员
|
||||||
const userIsAdmin = useMemo(() => isAdmin(), []);
|
const userIsAdmin = useMemo(() => isAdmin(), []);
|
||||||
|
|
||||||
@@ -171,12 +178,21 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
|||||||
title: t('充值额度'),
|
title: t('充值额度'),
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
render: (amount) => (
|
render: (amount, record) => {
|
||||||
<span className='flex items-center gap-1'>
|
if (isSubscriptionTopup(record)) {
|
||||||
<Coins size={16} />
|
return (
|
||||||
<Text>{amount}</Text>
|
<Tag color='purple' shape='circle' size='small'>
|
||||||
</span>
|
{t('订阅套餐')}
|
||||||
),
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className='flex items-center gap-1'>
|
||||||
|
<Coins size={16} />
|
||||||
|
<Text>{amount}</Text>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('支付金额'),
|
title: t('支付金额'),
|
||||||
|
|||||||
Reference in New Issue
Block a user