Files
new-api/controller/subscription_payment_epay.go
t0ng7u 009910b960 feat: add subscription billing system with admin management and user purchase flow
Implement a new subscription-based billing model alongside existing metered/per-request billing:

Backend:
- Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.)
- Implement CRUD APIs for subscription plan management (admin only)
- Add user subscription queries with support for multiple active/expired subscriptions
- Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases
- Implement pre-consume and post-consume billing logic for subscription quota tracking
- Add billing preference settings (subscription_first, wallet_first, etc.)
- Enhance usage logs with subscription deduction details

Frontend - Admin:
- Add subscription management page with table view and drawer-based edit form
- Match UI/UX style with existing admin pages (redemption codes, users)
- Support enabling/disabling plans, configuring payment IDs, and model quotas
- Add user subscription binding modal in user management

Frontend - Wallet:
- Add subscription plans card with current subscription status display
- Show all subscriptions (active and expired) with remaining days/usage percentage
- Display purchasable plans with pricing cards following SaaS best practices
- Extract purchase modal to separate component matching payment confirm modal style
- Add skeleton loading states with active animation
- Implement billing preference selector in card header
- Handle payment gateway availability based on admin configuration

Frontend - Usage Logs:
- Display subscription deduction details in log entries
- Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining)
- Add subscription deduction tag for subscription-covered requests
2026-01-30 05:31:10 +08:00

127 lines
3.4 KiB
Go

package controller
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"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"
)
type SubscriptionEpayPayRequest struct {
PlanId int `json:"plan_id"`
PaymentMethod string `json:"payment_method"`
}
func SubscriptionRequestEpay(c *gin.Context) {
var req SubscriptionEpayPayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
plan, err := model.GetSubscriptionPlanById(req.PlanId)
if err != nil {
common.ApiError(c, err)
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
return
}
if plan.PriceAmount < 0.01 {
common.ApiErrorMsg(c, "套餐金额过低")
return
}
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
common.ApiErrorMsg(c, "支付方式不存在")
return
}
userId := c.GetInt("id")
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/topup")
notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)
client := GetEpayClient()
if client == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
Type: req.PaymentMethod,
ServiceTradeNo: tradeNo,
Name: fmt.Sprintf("SUB:%s", plan.Title),
Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64),
Device: epay.PC,
NotifyUrl: notifyUrl,
ReturnUrl: returnUrl,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
}
func SubscriptionEpayNotify(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.Writer.Write([]byte("fail"))
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
_, _ = c.Writer.Write([]byte("success"))
} else {
_, _ = c.Writer.Write([]byte("fail"))
return
}
if verifyInfo.TradeStatus != epay.StatusTradeSuccess {
return
}
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo)); err != nil {
// do not fail webhook response after signature verified
return
}
}