refactor(payment):重构支付功能

This commit is contained in:
qian committed 2026-05-21 23:06:01 +08:00
1 parent 0f76ed36b2
commit 555bddf5c3
45 files changed
+1297 -1458

No files matched your search

+1 -5
View File
@@ -146,11 +146,7 @@ type IAdminV1 interface {
AdminMemberEnable(ctx context.Context, req *v1.AdminMemberEnableReq) (res *v1.AdminMemberEnableRes, err error)
AdminMemberResetPassword(ctx context.Context, req *v1.AdminMemberResetPasswordReq) (res *v1.AdminMemberResetPasswordRes, err error)
PaymentChannelList(ctx context.Context, req *v1.PaymentChannelListReq) (res *v1.PaymentChannelListRes, err error)
PaymentChannelCreate(ctx context.Context, req *v1.PaymentChannelCreateReq) (res *v1.PaymentChannelCreateRes, err error)
PaymentChannelDetail(ctx context.Context, req *v1.PaymentChannelDetailReq) (res *v1.PaymentChannelDetailRes, err error)
PaymentChannelUpdate(ctx context.Context, req *v1.PaymentChannelUpdateReq) (res *v1.PaymentChannelUpdateRes, err error)
PaymentChannelDelete(ctx context.Context, req *v1.PaymentChannelDeleteReq) (res *v1.PaymentChannelDeleteRes, err error)
PaymentChannelToggle(ctx context.Context, req *v1.PaymentChannelToggleReq) (res *v1.PaymentChannelToggleRes, err error)
PaymentChannelSave(ctx context.Context, req *v1.PaymentChannelSaveReq) (res *v1.PaymentChannelSaveRes, err error)
PaymentSettingsGet(ctx context.Context, req *v1.PaymentSettingsGetReq) (res *v1.PaymentSettingsGetRes, err error)
PaymentSettingsUpdate(ctx context.Context, req *v1.PaymentSettingsUpdateReq) (res *v1.PaymentSettingsUpdateRes, err error)
AdminPermissionList(ctx context.Context, req *v1.AdminPermissionListReq) (res *v1.AdminPermissionListRes, err error)
+4
View File
@@ -277,6 +277,10 @@ type AdminWalletTransactionItem struct {
BalanceAfter float64 `json:"balance_after"`
FrozenAfter float64 `json:"frozen_after"`
Description string `json:"description"`
UserId int64 `json:"user_id"`
Username string `json:"username"`
RequestId string `json:"request_id"`
ModelName string `json:"model_name"`
CreatedAt string `json:"created_at"`
}
+9 -45
View File
@@ -41,61 +41,25 @@ type AdminMemberResetPasswordRes struct {
NewPassword string `json:"new_password"`
}
// === 支付渠道管理 ===
// === 支付渠道管理(单例配置模式:每种渠道类型只有一个配置) ===
// PaymentChannelListReq 获取所有渠道配置
type PaymentChannelListReq struct {
g.Meta `path:"/payment-channels" method:"get" mime:"json" tags:"管理后台-支付" summary:"支付渠道列表"`
g.Meta `path:"/payment-channels" method:"get" mime:"json" tags:"管理后台-支付" summary:"获取所有渠道配置"`
}
type PaymentChannelListRes struct {
List []map[string]any `json:"list"`
}
type PaymentChannelCreateReq struct {
g.Meta `path:"/payment-channels" method:"post" mime:"json" tags:"管理后台-支付" summary:"创建支付渠道"`
Channel string `json:"channel" v:"required#请输入渠道类型" dc:"渠道类型"`
Name string `json:"name" v:"required#请输入渠道名称" dc:"渠道名称"`
PaymentType string `json:"payment_type" dc:"支付类型"`
Config string `json:"config" dc:"配置(JSON)"`
SortOrder int `json:"sort_order" dc:"排序"`
// PaymentChannelSaveReq 保存指定渠道的配置(整体覆盖)
type PaymentChannelSaveReq struct {
g.Meta `path:"/payment-channels/{channel}" method:"put" mime:"json" tags:"管理后台-支付" summary:"保存渠道配置"`
Channel string `json:"channel" in:"path" v:"required|in:epay#请指定渠道类型|不支持的渠道类型" dc:"渠道类型"`
Config string `json:"config" v:"required#请提供配置" dc:"完整的 JSON 配置(含 is_enabled)"`
}
type PaymentChannelCreateRes struct {
ID int64 `json:"id"`
}
type PaymentChannelDetailReq struct {
g.Meta `path:"/payment-channels/{id}" method:"get" mime:"json" tags:"管理后台-支付" summary:"支付渠道详情"`
Id int64 `json:"id" in:"path" v:"required|min:1" dc:"渠道ID"`
}
type PaymentChannelDetailRes struct {
Data map[string]any `json:"data"`
}
type PaymentChannelUpdateReq struct {
g.Meta `path:"/payment-channels/{id}" method:"put" mime:"json" tags:"管理后台-支付" summary:"更新支付渠道"`
Id int64 `json:"id" in:"path" v:"required|min:1" dc:"渠道ID"`
Update map[string]interface{} `json:"update" dc:"更新字段"`
}
type PaymentChannelUpdateRes struct{}
type PaymentChannelDeleteReq struct {
g.Meta `path:"/payment-channels/{id}" method:"delete" mime:"json" tags:"管理后台-支付" summary:"删除支付渠道"`
Id int64 `json:"id" in:"path" v:"required|min:1" dc:"渠道ID"`
}
type PaymentChannelDeleteRes struct{}
type PaymentChannelToggleReq struct {
g.Meta `path:"/payment-channels/{id}/toggle" method:"put" mime:"json" tags:"管理后台-支付" summary:"切换支付渠道状态"`
Id int64 `json:"id" in:"path" v:"required|min:1" dc:"渠道ID"`
}
type PaymentChannelToggleRes struct {
IsEnabled bool `json:"is_enabled"`
}
type PaymentChannelSaveRes struct{}
// === 支付设置 ===
+1
View File
@@ -105,6 +105,7 @@ type ITenantV1 interface {
TenantOrderList(ctx context.Context, req *v1.TenantOrderListReq) (res *v1.TenantOrderListRes, err error)
TenantOrderDetail(ctx context.Context, req *v1.TenantOrderDetailReq) (res *v1.TenantOrderDetailRes, err error)
TenantOrderCancel(ctx context.Context, req *v1.TenantOrderCancelReq) (res *v1.TenantOrderCancelRes, err error)
TenantRechargeCreate(ctx context.Context, req *v1.TenantRechargeCreateReq) (res *v1.TenantRechargeCreateRes, err error)
TenantPaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq) (res *v1.TenantPaymentInfoRes, err error)
TenantOrderExport(ctx context.Context, req *v1.TenantOrderExportReq) (res *v1.TenantOrderExportRes, err error)
TenantOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (res *v1.TenantOrgInfoRes, err error)
+20 -5
View File
@@ -19,10 +19,10 @@ type TenantOrderCreateRes struct {
}
type TenantOrderPayReq struct {
g.Meta `path:"/orders/{id}/pay" method:"post" mime:"json" tags:"租户控制台-订单" summary:"支付订单"`
Id int64 `json:"id" in:"path" v:"required|min:1"`
PaymentChannelID int64 `json:"payment_channel_id"`
PaymentMethod string `json:"payment_method"`
g.Meta `path:"/orders/{id}/pay" method:"post" mime:"json" tags:"租户控制台-订单" summary:"支付订单"`
Id int64 `json:"id" in:"path" v:"required|min:1"`
PaymentChannel string `json:"payment_channel" dc:"支付渠道类型(epay/stripe/mock)"`
PaymentMethod string `json:"payment_method"`
}
type TenantOrderPayRes struct {
@@ -84,12 +84,27 @@ type TenantOrderCancelReq struct {
type TenantOrderCancelRes struct{}
type TenantRechargeCreateReq struct {
g.Meta `path:"/recharge/create" method:"post" mime:"json" tags:"租户控制台-订单" summary:"创建充值订单"`
Amount float64 `json:"amount" v:"required|min:0.01#请输入充值金额|充值金额不能小于 0.01"`
PaymentChannel string `json:"payment_channel" v:"required|in:epay#请选择支付渠道|不支持的支付渠道"`
PaymentMethod string `json:"payment_method" v:"required#请选择支付方式"`
}
type TenantRechargeCreateRes struct {
Data map[string]any `json:"data"`
}
type TenantPaymentInfoReq struct {
g.Meta `path:"/payment-info" method:"get" mime:"json" tags:"租户控制台-订单" summary:"支付信息"`
}
type TenantPaymentInfoRes struct {
Data map[string]any `json:"data"`
Channels []map[string]any `json:"channels"`
AmountOptions []int `json:"amount_options,omitempty"`
AmountDiscount map[int]float64 `json:"amount_discount,omitempty"`
MinTopUp int `json:"min_topup,omitempty"`
Currency string `json:"currency,omitempty"`
}
// TenantOrderExportReq 导出订单列表请求
+3 -4
View File
@@ -313,10 +313,9 @@ func printBanner() {
// registerPaymentCallbacks registers payment callback routes.
func registerPaymentCallbacks(group *ghttp.RouterGroup) {
group.Group("/payment", func(g *ghttp.RouterGroup) {
g.POST("/callback/{channel_id}", public.HandlePaymentCallback)
g.GET("/callback/{channel_id}", public.HandlePaymentCallback)
g.GET("/epay/return/{channel_id}", public.HandlePaymentEpayReturn)
g.POST("/stripe/webhook/{channel_id}", public.HandlePaymentStripeWebhook)
g.POST("/callback/{channel}", public.HandlePaymentCallback)
g.GET("/callback/{channel}", public.HandlePaymentCallback)
g.GET("/epay/return", public.HandlePaymentEpayReturn)
})
}
@@ -1,12 +0,0 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) PaymentChannelCreate(ctx context.Context, req *v1.PaymentChannelCreateReq) (res *v1.PaymentChannelCreateRes, err error) {
return service.Admin().CreatePaymentChannel(ctx, req)
}
@@ -1,12 +0,0 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) PaymentChannelDelete(ctx context.Context, req *v1.PaymentChannelDeleteReq) (res *v1.PaymentChannelDeleteRes, err error) {
return service.Admin().DeletePaymentChannel(ctx, req)
}
@@ -1,12 +0,0 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) PaymentChannelDetail(ctx context.Context, req *v1.PaymentChannelDetailReq) (res *v1.PaymentChannelDetailRes, err error) {
return service.Admin().GetPaymentChannel(ctx, req)
}
@@ -0,0 +1,12 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) PaymentChannelSave(ctx context.Context, req *v1.PaymentChannelSaveReq) (res *v1.PaymentChannelSaveRes, err error) {
return service.Admin().SavePaymentChannel(ctx, req)
}
@@ -1,12 +0,0 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) PaymentChannelToggle(ctx context.Context, req *v1.PaymentChannelToggleReq) (res *v1.PaymentChannelToggleRes, err error) {
return service.Admin().TogglePaymentChannel(ctx, req)
}
@@ -1,12 +0,0 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) PaymentChannelUpdate(ctx context.Context, req *v1.PaymentChannelUpdateReq) (res *v1.PaymentChannelUpdateRes, err error) {
return service.Admin().UpdatePaymentChannel(ctx, req)
}
@@ -0,0 +1,12 @@
package tenant
import (
"context"
"github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) TenantRechargeCreate(ctx context.Context, req *v1.TenantRechargeCreateReq) (res *v1.TenantRechargeCreateRes, err error) {
return service.Tenant().RechargeCreate(ctx, req)
}
+7 -1
View File
@@ -24,7 +24,7 @@ type BilTransactionsColumns struct {
Id string // 主键ID
TenantId string // 租户ID
WalletId string // 关联钱包ID
Type string // 类型:recharge(充值)/ pre_deduct(预扣)/ settle(结算)/ refund(退款)/ adjust(调整)/ freeze(冻结)/ unfreeze(解冻)
Type string // 类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)
Amount string // 变动金额(正数=收入,负数=支出)
BalanceAfter string // 变动后总余额
FrozenAfter string // 变动后冻结余额
@@ -33,6 +33,9 @@ type BilTransactionsColumns struct {
Description string // 交易描述
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
UserId string // 关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)
RequestId string // 关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)
ModelName string // 关联模型名(consume 类型为调用的模型名,其他类型为空)
}
// bilTransactionsColumns holds the columns for the table bil_transactions.
@@ -49,6 +52,9 @@ var bilTransactionsColumns = BilTransactionsColumns{
Description: "description",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
UserId: "user_id",
RequestId: "request_id",
ModelName: "model_name",
}
// NewBilTransactionsDao creates and returns a new DAO object for table data access.
@@ -1,99 +0,0 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// OrdPaymentChannelsDao is the data access object for the table ord_payment_channels.
type OrdPaymentChannelsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns OrdPaymentChannelsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// OrdPaymentChannelsColumns defines and stores column names for the table ord_payment_channels.
type OrdPaymentChannelsColumns struct {
Id string // 主键ID
Channel string // 渠道标识(alipay/wechat/stripe/mock)
Name string // 显示名称
Config string // 渠道配置(JSONB,含 API 密钥等敏感信息)
IsEnabled string // 是否启用
SortOrder string // 排序权重
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
PaymentType string // 子支付方式(alipay/wxpay 等,空表示该渠道支持所有方式)
CallbackUrl string // 支付回调地址覆盖(为空则使用系统默认)
ReturnUrl string // 支付完成后前端跳转地址覆盖
}
// ordPaymentChannelsColumns holds the columns for the table ord_payment_channels.
var ordPaymentChannelsColumns = OrdPaymentChannelsColumns{
Id: "id",
Channel: "channel",
Name: "name",
Config: "config",
IsEnabled: "is_enabled",
SortOrder: "sort_order",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
PaymentType: "payment_type",
CallbackUrl: "callback_url",
ReturnUrl: "return_url",
}
// NewOrdPaymentChannelsDao creates and returns a new DAO object for table data access.
func NewOrdPaymentChannelsDao(handlers ...gdb.ModelHandler) *OrdPaymentChannelsDao {
return &OrdPaymentChannelsDao{
group: "default",
table: "ord_payment_channels",
columns: ordPaymentChannelsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *OrdPaymentChannelsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *OrdPaymentChannelsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *OrdPaymentChannelsDao) Columns() OrdPaymentChannelsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *OrdPaymentChannelsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *OrdPaymentChannelsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *OrdPaymentChannelsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+2 -2
View File
@@ -23,7 +23,6 @@ type TskModelTasksDao struct {
type TskModelTasksColumns struct {
Id string //
PublicTaskId string //
RequestId string //
Platform string //
Action string //
Status string //
@@ -46,13 +45,13 @@ type TskModelTasksColumns struct {
FinishTime string //
CreatedAt string //
UpdatedAt string //
RequestId string // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
}
// tskModelTasksColumns holds the columns for the table tsk_model_tasks.
var tskModelTasksColumns = TskModelTasksColumns{
Id: "id",
PublicTaskId: "public_task_id",
RequestId: "request_id",
Platform: "platform",
Action: "action",
Status: "status",
@@ -75,6 +74,7 @@ var tskModelTasksColumns = TskModelTasksColumns{
FinishTime: "finish_time",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
RequestId: "request_id",
}
// NewTskModelTasksDao creates and returns a new DAO object for table data access.
-22
View File
@@ -1,22 +0,0 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// ordPaymentChannelsDao is the data access object for the table ord_payment_channels.
// You can define custom methods on it to extend its functionality as needed.
type ordPaymentChannelsDao struct {
*internal.OrdPaymentChannelsDao
}
var (
// OrdPaymentChannels is a globally accessible object for table ord_payment_channels operations.
OrdPaymentChannels = ordPaymentChannelsDao{internal.NewOrdPaymentChannelsDao()}
)
// Add your custom methods and functionality below.
+9 -33
View File
@@ -2,21 +2,21 @@ package public
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gconv"
"github.com/qianfree/team-api/internal/logic/payment"
)
// handlePaymentCallback 处理支付渠道的异步回调通知(Epay 等支持 GET/POST 两种方式)。
// HandlePaymentCallback 处理支付渠道的异步回调通知(支持 GET/POST)。
// channel 从 URL 路径获取(如 "epay")。
func HandlePaymentCallback(r *ghttp.Request) {
channelID := gconv.Int64(r.Get("channel_id").String())
if channelID <= 0 {
channel := r.Get("channel").String()
if channel == "" {
r.Response.WriteStatus(400)
r.Response.Write("fail")
return
}
err := payment.ProcessCallback(r.Context(), r.Request, channelID)
err := payment.ProcessCallback(r.Context(), r.Request, channel)
if err != nil {
r.Response.WriteStatus(200)
r.Response.Write("fail")
@@ -27,38 +27,14 @@ func HandlePaymentCallback(r *ghttp.Request) {
r.Response.Write("success")
}
// handlePaymentEpayReturn 处理 Epay 支付完成后的浏览器同步跳转。
// HandlePaymentEpayReturn 处理 Epay 支付完成后的浏览器同步跳转。
func HandlePaymentEpayReturn(r *ghttp.Request) {
channelID := gconv.Int64(r.Get("channel_id").String())
if channelID <= 0 {
r.Response.WriteStatus(302)
r.Response.Header().Set("Location", "/console/topup?pay=fail")
return
}
err := payment.ProcessCallback(r.Context(), r.Request, channelID)
err := payment.ProcessCallback(r.Context(), r.Request, "epay")
if err != nil {
r.Response.WriteStatus(302)
r.Response.Header().Set("Location", "/console/topup?pay=fail")
r.Response.Header().Set("Location", "/console/wallet?pay=fail")
return
}
r.Response.WriteStatus(302)
r.Response.Header().Set("Location", "/console/topup?pay=success")
}
// handlePaymentStripeWebhook 处理 Stripe Webhook 回调。
func HandlePaymentStripeWebhook(r *ghttp.Request) {
channelID := gconv.Int64(r.Get("channel_id").String())
if channelID <= 0 {
r.Response.WriteStatus(400)
return
}
err := payment.ProcessCallback(r.Context(), r.Request, channelID)
if err != nil {
r.Response.WriteStatus(400)
return
}
r.Response.WriteStatus(200)
r.Response.Header().Set("Location", "/console/wallet?pay=success")
}
+1 -1
View File
@@ -467,7 +467,7 @@ func (s *sAdmin) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletT
var total int
records := make([]*v1.AdminWalletTransactionItem, 0)
err = query.OrderDesc("created_at").
Fields("id, type, amount, balance_after, frozen_after, description, created_at").
Fields("id, type, amount, balance_after, frozen_after, description, user_id, request_id, model_name, created_at").
Page(page, pageSize).
ScanAndCount(&records, &total, false)
if err != nil {
+13 -90
View File
@@ -3,16 +3,15 @@ package admin
import (
"context"
"fmt"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/payment"
"github.com/qianfree/team-api/internal/model/entity"
"github.com/qianfree/team-api/internal/utility/export"
)
@@ -129,98 +128,22 @@ func (s *sAdmin) OrderComplete(ctx context.Context, req *v1.OrderCompleteReq) (*
return &v1.OrderCompleteRes{}, nil
}
// GetPaymentChannels 获取支付渠道配置
// GetPaymentChannels 获取所有渠道配置(单例模式,从 sys_options 读取)
func (s *sAdmin) GetPaymentChannels(ctx context.Context, _ *v1.PaymentChannelListReq) (*v1.PaymentChannelListRes, error) {
channels := make([]map[string]any, 0)
err := dao.OrdPaymentChannels.Ctx(ctx).
OrderAsc("sort_order").
Scan(&channels)
if err != nil {
return nil, err
}
return &v1.PaymentChannelListRes{List: channels}, nil
return &v1.PaymentChannelListRes{List: payment.ListAllChannels(ctx)}, nil
}
// CreatePaymentChannel 创建支付渠道(默认禁用)。
func (s *sAdmin) CreatePaymentChannel(ctx context.Context, req *v1.PaymentChannelCreateReq) (*v1.PaymentChannelCreateRes, error) {
result, err := dao.OrdPaymentChannels.Ctx(ctx).Insert(do.OrdPaymentChannels{
Channel: req.Channel,
Name: req.Name,
Config: req.Config,
IsEnabled: false,
SortOrder: req.SortOrder,
})
// SavePaymentChannel 保存指定渠道的配置(整体覆盖)
func (s *sAdmin) SavePaymentChannel(ctx context.Context, req *v1.PaymentChannelSaveReq) (*v1.PaymentChannelSaveRes, error) {
// 校验 config JSON 是否合法
_, err := payment.ParseChannelConfig(req.Channel, req.Config)
if err != nil {
return nil, common.NewBadRequestError("配置 JSON 格式无效: " + err.Error())
}
if err := payment.SaveChannelConfig(ctx, req.Channel, req.Config); err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return &v1.PaymentChannelCreateRes{ID: id}, nil
}
// GetPaymentChannel 获取单个支付渠道详情。
func (s *sAdmin) GetPaymentChannel(ctx context.Context, req *v1.PaymentChannelDetailReq) (*v1.PaymentChannelDetailRes, error) {
var ch entity.OrdPaymentChannels
err := dao.OrdPaymentChannels.Ctx(ctx).
Where("id", req.Id).Scan(&ch)
if err != nil {
return nil, err
}
if ch.Id == 0 {
return nil, common.NewNotFoundError("支付渠道")
}
return &v1.PaymentChannelDetailRes{Data: gconv.Map(ch)}, nil
}
// UpdatePaymentChannel 更新支付渠道配置
func (s *sAdmin) UpdatePaymentChannel(ctx context.Context, req *v1.PaymentChannelUpdateReq) (*v1.PaymentChannelUpdateRes, error) {
result, err := dao.OrdPaymentChannels.Ctx(ctx).
Where("id", req.Id).
Data(req.Update).
Update()
if err != nil {
return nil, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return nil, common.NewNotFoundError("支付渠道")
}
return &v1.PaymentChannelUpdateRes{}, nil
}
// DeletePaymentChannel 删除支付渠道。
func (s *sAdmin) DeletePaymentChannel(ctx context.Context, req *v1.PaymentChannelDeleteReq) (*v1.PaymentChannelDeleteRes, error) {
result, err := dao.OrdPaymentChannels.Ctx(ctx).
Where("id", req.Id).Delete()
if err != nil {
return nil, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return nil, common.NewNotFoundError("支付渠道")
}
return &v1.PaymentChannelDeleteRes{}, nil
}
// TogglePaymentChannel 切换支付渠道启用/禁用状态。
func (s *sAdmin) TogglePaymentChannel(ctx context.Context, req *v1.PaymentChannelToggleReq) (*v1.PaymentChannelToggleRes, error) {
var ch struct {
IsEnabled bool `json:"is_enabled"`
}
err := dao.OrdPaymentChannels.Ctx(ctx).
Where("id", req.Id).Scan(&ch)
if err != nil {
return nil, err
}
newState := !ch.IsEnabled
_, err = dao.OrdPaymentChannels.Ctx(ctx).
Where("id", req.Id).
Data(do.OrdPaymentChannels{
IsEnabled: newState,
}).Update()
if err != nil {
return nil, err
}
return &v1.PaymentChannelToggleRes{IsEnabled: newState}, nil
return &v1.PaymentChannelSaveRes{}, nil
}
// GetPaymentSettings 获取全局支付设置。
+4 -2
View File
@@ -97,7 +97,8 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
// 6. 记录消费流水(一条汇总)
recordTransaction(ctx, wallet.ID, tenantID, "consume", -actualCost,
fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.4f actual=%.4f", requestID, modelName, inputTokens, outputTokens, preDeductAmount, actualCost))
fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.4f actual=%.4f", requestID, modelName, inputTokens, outputTokens, preDeductAmount, actualCost),
userID, requestID, modelName)
return &SettlementResult{
PreDeductAmount: preDeductAmount,
@@ -201,7 +202,8 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
// 8. 记录消费流水(一条汇总)
recordTransaction(ctx, wallet.ID, tenantID, "consume", -actualCost,
fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.4f actual=%.4f", requestID, modelName, breakdown.InputTokens, breakdown.OutputTokens, preDeductAmount, actualCost))
fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.4f actual=%.4f", requestID, modelName, breakdown.InputTokens, breakdown.OutputTokens, preDeductAmount, actualCost),
userID, requestID, modelName)
return settlementResult, nil
}
+2 -1
View File
@@ -187,7 +187,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
// 6. 记录消费流水(一条汇总)
recordTransaction(ctx, wallet.ID, tenantID, "consume", -actualCost,
fmt.Sprintf("consume: %s model=%s pre_deduct=%.4f actual=%.4f", requestID, modelName, preDeductAmount, actualCost))
fmt.Sprintf("consume: %s model=%s pre_deduct=%.4f actual=%.4f", requestID, modelName, preDeductAmount, actualCost),
userID, requestID, modelName)
return result, nil
}
+4 -1
View File
@@ -317,7 +317,7 @@ func GetPreDeductAmount(ctx context.Context, requestID string) (float64, bool) {
}
// recordTransaction 记录钱包流水
func recordTransaction(ctx context.Context, walletID, tenantID int64, txnType string, amount float64, description string) {
func recordTransaction(ctx context.Context, walletID, tenantID int64, txnType string, amount float64, description string, userID int64, requestID string, modelName string) {
type walletRow struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
@@ -342,6 +342,9 @@ func recordTransaction(ctx context.Context, walletID, tenantID int64, txnType st
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
Description: description,
UserId: userID,
RequestId: requestID,
ModelName: modelName,
})
if err != nil {
g.Log().Errorf(ctx, "record transaction failed: %v", err)
+10 -24
View File
@@ -2,13 +2,14 @@ package payment
import (
"context"
do "github.com/qianfree/team-api/internal/model/do"
"net/http"
"sync"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
)
@@ -57,33 +58,18 @@ func UnlockOrder(orderNo string) {
}
// ProcessCallback 统一回调处理流程。
func ProcessCallback(ctx context.Context, r *http.Request, channelID int64) error {
// 1. 加载支付渠道配置
var channel *struct {
Channel string `json:"channel"`
Config string `json:"config"`
IsEnabled bool `json:"is_enabled"`
}
err := dao.OrdPaymentChannels.Ctx(ctx).
Where("id", channelID).Scan(&channel)
// channelType 为渠道类型字符串(如 "epay")。
func ProcessCallback(ctx context.Context, r *http.Request, channelType string) error {
// 1. 从 sys_options 加载渠道配置
cfg, err := GetChannelConfigAndProvider(ctx, channelType)
if err != nil {
return gerror.Wrapf(err, "加载支付渠道失败")
}
if channel == nil {
return common.NewNotFoundError("支付渠道")
}
if !channel.IsEnabled {
return common.NewBadRequestError("支付渠道已禁用")
return gerror.Wrapf(err, "加载支付渠道配置失败")
}
// 2. 解析配置并获取 Provider
cfg, err := ParseChannelConfig(channel.Channel, channel.Config)
if err != nil {
return gerror.Wrapf(err, "解析渠道配置失败")
}
provider := GetProvider(channel.Channel)
// 2. 获取 Provider
provider := GetProvider(channelType)
if provider == nil {
return gerror.Newf("不支持的支付渠道: %s", channel.Channel)
return gerror.Newf("不支持的支付渠道: %s", channelType)
}
// 3. 调用 Provider 验签并解析回调
+142 -41
View File
@@ -1,47 +1,150 @@
package payment
import "encoding/json"
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/frame/g"
lcommon "github.com/qianfree/team-api/internal/logic/common"
)
// 已注册的渠道类型列表。
var channelTypes = []string{"epay"}
// EpayConfig 易支付渠道配置。
type EpayConfig struct {
PayAddress string `json:"pay_address"` // 易支付网关地址
MerchantID string `json:"merchant_id"` // 商户 ID
MerchantKey string `json:"merchant_key"` // 商户密钥(用于签名)
PayMethods []EpayPayMethod `json:"pay_methods"` // 可用支付方式列表
IsEnabled bool `json:"is_enabled"`
PayAddress string `json:"pay_address"`
MerchantID string `json:"merchant_id"`
MerchantKey string `json:"merchant_key"`
PayMethods []EpayPayMethod `json:"pay_methods"`
}
// EpayPayMethod 易支付子支付方式。
type EpayPayMethod struct {
Name string `json:"name"` // 显示名称(如"支付宝"、"微信支付")
Type string `json:"type"` // 方式标识(alipay/wxpay/qqpay 等)
Color string `json:"color"` // UI 颜色提示
}
// StripeConfig Stripe 渠道配置。
type StripeConfig struct {
APISecret string `json:"api_secret"` // Stripe 密钥(sk_... / rk_...)
WebhookSecret string `json:"webhook_secret"` // Webhook 签名密钥(whsec_...)
PriceID string `json:"price_id"` // Stripe Price ID(用于 Checkout Session)
UnitPrice float64 `json:"unit_price"` // 每单位价格(USD),当 PriceID 为空时使用
MinTopup int `json:"min_topup"` // 最低充值数量
TestMode bool `json:"test_mode"` // 是否测试模式
}
// MockConfig Mock 支付渠道配置(开发环境)。
type MockConfig struct {
AutoFulfill bool `json:"auto_fulfill"` // 是否自动履约
Name string `json:"name"`
Type string `json:"type"`
Color string `json:"color"`
}
// GlobalPaymentSettings 全局支付设置。
type GlobalPaymentSettings struct {
AmountOptions []int `json:"amount_options"` // 预设充值金额选项
AmountDiscount map[int]float64 `json:"amount_discount"` // 金额折扣映射,如 {100: 0.9} 表示充 100 享 9 折
MinTopUp int `json:"min_topup"` // 最低充值金额
Currency string `json:"currency"` // 默认货币代码
CallbackBaseURL string `json:"callback_base_url"` // 回调基础 URL(为空则使用请求 Host)
AmountOptions []int `json:"amount_options"`
AmountDiscount map[int]float64 `json:"amount_discount"`
MinTopUp int `json:"min_topup"`
Currency string `json:"currency"`
CallbackBaseURL string `json:"callback_base_url"`
}
// ParseChannelConfig 按渠道类型将 JSONB 配置字符串解析为对应的类型化结构体。
// channelConfigKey 返回渠道在 sys_options 中的存储键。
func channelConfigKey(channelType string) string {
return "payment_channel_" + channelType
}
// LoadChannelConfig 从 sys_options 加载渠道配置,返回原始 JSON 字符串和解析后的配置。
func LoadChannelConfig(ctx context.Context, channelType string) (jsonStr string, cfg interface{}, err error) {
jsonStr = lcommon.Config().GetOption(ctx, channelConfigKey(channelType))
if jsonStr == "" {
cfg = defaultChannelConfig(channelType)
return
}
cfg, err = ParseChannelConfig(channelType, jsonStr)
return
}
// SaveChannelConfig 将渠道配置 JSON 保存到 sys_options。
func SaveChannelConfig(ctx context.Context, channelType string, configJSON string) error {
return lcommon.Config().SetOption(ctx, channelConfigKey(channelType), configJSON)
}
// ListAllChannels 返回所有已注册渠道的配置(含未配置的,返回默认值)。
func ListAllChannels(ctx context.Context) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(channelTypes))
for _, ct := range channelTypes {
_, cfg, _ := LoadChannelConfig(ctx, ct)
item := map[string]interface{}{
"channel": ct,
}
if c, ok := cfg.(*EpayConfig); ok {
item["is_enabled"] = c.IsEnabled
item["config"] = c
}
result = append(result, item)
}
return result
}
// GetEnabledChannels 返回所有已启用的渠道及其支付方式(供租户 PaymentInfo 使用)。
func GetEnabledChannels(ctx context.Context) []map[string]interface{} {
result := make([]map[string]interface{}, 0)
for _, ct := range channelTypes {
jsonStr, cfg, err := LoadChannelConfig(ctx, ct)
if err != nil {
g.Log().Warningf(ctx, "[Payment] channel %s config parse error: %v", ct, err)
continue
}
if cfg == nil {
g.Log().Debugf(ctx, "[Payment] channel %s config is nil, jsonStr=%q", ct, jsonStr)
continue
}
var isEnabled bool
var payMethods []map[string]interface{}
if c, ok := cfg.(*EpayConfig); ok {
isEnabled = c.IsEnabled
for _, m := range c.PayMethods {
payMethods = append(payMethods, map[string]interface{}{
"type": m.Type,
"name": m.Name,
"color": m.Color,
})
}
g.Log().Debugf(ctx, "[Payment] channel %s: enabled=%v, pay_methods_count=%d, json_len=%d",
ct, isEnabled, len(c.PayMethods), len(jsonStr))
} else {
g.Log().Warningf(ctx, "[Payment] channel %s config type mismatch: %T", ct, cfg)
}
if !isEnabled {
continue
}
item := map[string]interface{}{
"channel": ct,
}
if len(payMethods) > 0 {
item["pay_methods"] = payMethods
}
result = append(result, item)
}
g.Log().Debugf(ctx, "[Payment] GetEnabledChannels returned %d channels", len(result))
return result
}
// GetChannelConfigAndProvider 获取已启用渠道的配置和 Provider,未启用或不存在时返回错误。
func GetChannelConfigAndProvider(ctx context.Context, channelType string) (interface{}, error) {
_, cfg, err := LoadChannelConfig(ctx, channelType)
if err != nil {
return nil, fmt.Errorf("支付渠道配置无效")
}
var isEnabled bool
if c, ok := cfg.(*EpayConfig); ok {
isEnabled = c.IsEnabled
} else {
return nil, fmt.Errorf("不支持的支付渠道: %s", channelType)
}
if !isEnabled {
return nil, fmt.Errorf("支付渠道 %s 未启用", channelType)
}
return cfg, nil
}
// ParseChannelConfig 按渠道类型将 JSON 字符串解析为对应的类型化结构体。
func ParseChannelConfig(channelType string, configJSON string) (interface{}, error) {
switch channelType {
case "epay":
@@ -50,19 +153,17 @@ func ParseChannelConfig(channelType string, configJSON string) (interface{}, err
return nil, err
}
return cfg, nil
case "stripe":
cfg := &StripeConfig{}
if err := json.Unmarshal([]byte(configJSON), cfg); err != nil {
return nil, err
}
return cfg, nil
case "mock":
cfg := &MockConfig{}
if err := json.Unmarshal([]byte(configJSON), cfg); err != nil {
return nil, err
}
return cfg, nil
default:
return nil, nil
}
}
// defaultChannelConfig 返回渠道类型的默认配置。
func defaultChannelConfig(channelType string) interface{} {
switch channelType {
case "epay":
return &EpayConfig{PayMethods: []EpayPayMethod{}}
default:
return nil
}
}
-73
View File
@@ -1,73 +0,0 @@
package payment
import (
"context"
do "github.com/qianfree/team-api/internal/model/do"
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
)
// MockProvider Mock 支付渠道(开发环境)。
type MockProvider struct{}
func (p *MockProvider) CreatePayment(ctx context.Context, order *PaymentOrder, config interface{}) (*PaymentResult, error) {
return &PaymentResult{
PaymentURL: "",
PaymentNo: "MOCK_" + order.OrderNo,
Params: map[string]string{"mock": "true"},
IsRedirect: false,
}, nil
}
func (p *MockProvider) HandleCallback(ctx context.Context, r *http.Request, config interface{}) (*CallbackResult, error) {
return &CallbackResult{
OrderNo: r.URL.Query().Get("order_no"),
TradeNo: "MOCK_CALLBACK",
TradeStatus: "TRADE_SUCCESS",
Success: true,
}, nil
}
func (p *MockProvider) Refund(ctx context.Context, refund *RefundRequest, config interface{}) error {
return nil
}
func (p *MockProvider) QueryPaymentStatus(ctx context.Context, paymentNo string, config interface{}) (*PaymentStatus, error) {
return &PaymentStatus{
TradeNo: paymentNo,
Status: "paid",
}, nil
}
// MockPay 模拟支付(开发环境),向后兼容。
func MockPay(ctx context.Context, orderID int64) error {
var status string
err := dao.OrdOrders.Ctx(ctx).
Where("id", orderID).
Fields("status").
Scan(&status)
if err != nil {
return err
}
if status != "pending" {
return nil
}
_, err = dao.OrdOrders.Ctx(ctx).
Where("id", orderID).
Where("status", "pending").
Data(do.OrdOrders{
Status: "paid",
PaidAt: gtime.Now(),
PaymentNo: "MOCK_" + g.Cfg().MustGet(ctx, "server.name").String(),
}).Update()
if err != nil {
return err
}
return FulfillOrder(ctx, orderID)
}
+1 -5
View File
@@ -29,7 +29,7 @@ type PaymentOrder struct {
Currency string // 货币代码
OrderType string // 订单类型(new_plan/renew/upgrade/recharge)
Description string // 订单描述(用于支付页面展示)
PaymentMethod string // 子支付方式(alipay/wxpay/stripe 等)
PaymentMethod string // 子支付方式(alipay/wxpay 等)
NotifyURL string // 异步回调地址
ReturnURL string // 支付完成前端跳转地址
}
@@ -74,10 +74,6 @@ func GetProvider(channelType string) PaymentProvider {
switch channelType {
case "epay":
return &EpayProvider{}
case "stripe":
return &StripeProvider{}
case "mock":
return &MockProvider{}
default:
return nil
}
-233
View File
@@ -1,233 +0,0 @@
package payment
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/util/gconv"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
"github.com/stripe/stripe-go/v81/refund"
stripewebhook "github.com/stripe/stripe-go/v81/webhook"
"github.com/qianfree/team-api/internal/consts"
lcommon "github.com/qianfree/team-api/internal/logic/common"
)
// StripeProvider Stripe 支付渠道实现。
type StripeProvider struct{}
func (p *StripeProvider) CreatePayment(ctx context.Context, order *PaymentOrder, config interface{}) (*PaymentResult, error) {
cfg, ok := config.(*StripeConfig)
if !ok {
return nil, lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, consts.MsgPaymentInvalidConfig)
}
if cfg.APISecret == "" {
return nil, lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, "Stripe API 密钥未配置")
}
stripe.Key = cfg.APISecret
// 构建 Checkout Session 参数
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(order.OrderNo),
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
SuccessURL: stripe.String(order.ReturnURL + "?session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(order.ReturnURL + "?cancelled=true"),
Metadata: map[string]string{
"tenant_id": gconv.String(order.TenantID),
"order_id": gconv.String(order.OrderID),
"order_no": order.OrderNo,
"order_type": order.OrderType,
},
}
// 使用 PriceID 或自定义单价
if cfg.PriceID != "" {
params.LineItems = []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(cfg.PriceID),
Quantity: stripe.Int64(int64(order.Amount)),
},
}
} else {
unitPrice := cfg.UnitPrice
if unitPrice <= 0 {
unitPrice = 1.0
}
// Stripe 金额以分为单位
amountInCents := int64(order.Amount * unitPrice * 100)
params.LineItems = []*stripe.CheckoutSessionLineItemParams{
{
PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
Currency: stripe.String(strings.ToLower(order.Currency)),
ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
Name: stripe.String(order.Description),
},
UnitAmount: stripe.Int64(amountInCents),
},
Quantity: stripe.Int64(1),
},
}
}
s, err := session.New(params)
if err != nil {
return nil, gerror.Wrapf(err, "创建 Stripe Checkout Session 失败")
}
return &PaymentResult{
PaymentURL: s.URL,
PaymentNo: s.ID,
Params: map[string]string{
"session_id": s.ID,
"payment_intent": s.PaymentIntent.ID,
},
IsRedirect: true,
}, nil
}
func (p *StripeProvider) HandleCallback(ctx context.Context, r *http.Request, config interface{}) (*CallbackResult, error) {
cfg, ok := config.(*StripeConfig)
if !ok {
return nil, lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, consts.MsgPaymentInvalidConfig)
}
if cfg.WebhookSecret == "" {
return nil, lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, "Stripe Webhook 密钥未配置")
}
body, err := ReadBody(r)
if err != nil {
return nil, gerror.Wrapf(err, "读取请求体失败")
}
signature := r.Header.Get("Stripe-Signature")
event, err := stripewebhook.ConstructEventWithOptions(body, signature, cfg.WebhookSecret, stripewebhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
return nil, gerror.Wrapf(err, "Stripe Webhook 签名验证失败")
}
switch event.Type {
case stripe.EventTypeCheckoutSessionCompleted:
return p.handleSessionCompleted(event)
case stripe.EventTypeCheckoutSessionExpired:
return p.handleSessionExpired(event)
default:
return nil, lcommon.NewBusinessError(consts.CodePaymentCallbackFailed, fmt.Sprintf("不支持的 Stripe 事件类型: %s", event.Type))
}
}
func (p *StripeProvider) handleSessionCompleted(event stripe.Event) (*CallbackResult, error) {
var cs stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &cs); err != nil {
return nil, gerror.Wrapf(err, "解析 CheckoutSession 数据失败")
}
if cs.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
return &CallbackResult{
OrderNo: cs.ClientReferenceID,
TradeNo: cs.ID,
TradeStatus: string(cs.PaymentStatus),
Success: false,
RawData: string(event.Data.Raw),
}, nil
}
// 解析金额(Stripe 以分为单位)
var paidAmount float64
if cs.AmountTotal > 0 {
paidAmount = float64(cs.AmountTotal) / 100.0
}
return &CallbackResult{
OrderNo: cs.ClientReferenceID,
TradeNo: cs.ID,
TradeStatus: "TRADE_SUCCESS",
PaidAmount: paidAmount,
Success: true,
RawData: string(event.Data.Raw),
}, nil
}
func (p *StripeProvider) handleSessionExpired(event stripe.Event) (*CallbackResult, error) {
var cs stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &cs); err != nil {
return nil, gerror.Wrapf(err, "解析 CheckoutSession 数据失败")
}
return &CallbackResult{
OrderNo: cs.ClientReferenceID,
TradeNo: cs.ID,
TradeStatus: "expired",
Success: false,
RawData: string(event.Data.Raw),
}, nil
}
func (p *StripeProvider) Refund(ctx context.Context, refundReq *RefundRequest, config interface{}) error {
cfg, ok := config.(*StripeConfig)
if !ok {
return lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, consts.MsgPaymentInvalidConfig)
}
if cfg.APISecret == "" {
return lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, "Stripe API 密钥未配置")
}
stripe.Key = cfg.APISecret
// 通过 Checkout Session ID 获取 PaymentIntent,然后发起退款
params := &stripe.RefundParams{
PaymentIntent: stripe.String(refundReq.PaymentNo),
Amount: stripe.Int64(int64(refundReq.RefundAmount * 100)), // 转为分
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
Metadata: map[string]string{
"order_id": gconv.String(refundReq.OrderID),
"order_no": refundReq.OrderNo,
"refund_reason": refundReq.Reason,
},
}
_, err := refund.New(params)
if err != nil {
return gerror.Wrapf(err, "Stripe 退款失败")
}
return nil
}
func (p *StripeProvider) QueryPaymentStatus(ctx context.Context, paymentNo string, config interface{}) (*PaymentStatus, error) {
cfg, ok := config.(*StripeConfig)
if !ok {
return nil, lcommon.NewBusinessError(consts.CodePaymentInvalidConfig, consts.MsgPaymentInvalidConfig)
}
stripe.Key = cfg.APISecret
s, err := session.Get(paymentNo, nil)
if err != nil {
return nil, gerror.Wrapf(err, "查询 Stripe Session 失败")
}
status := "pending"
var paidAmount float64
if s.PaymentStatus == stripe.CheckoutSessionPaymentStatusPaid {
status = "paid"
if s.AmountTotal > 0 {
paidAmount = float64(s.AmountTotal) / 100.0
}
} else if s.Status == stripe.CheckoutSessionStatusExpired {
status = "expired"
}
return &PaymentStatus{
TradeNo: s.ID,
Status: status,
PaidAmount: paidAmount,
}, nil
}
+20 -3
View File
@@ -111,6 +111,13 @@ func handleTimedOutTasks(ctx context.Context) {
}
g.Log().Infof(ctx, "poll: task %s timed out", t.PublicTaskID)
// 查询渠道信息并记录用量日志
var ch *common.ChannelBasicInfo
if t.ChannelID > 0 {
ch, _ = DefaultAsyncProvider.GetChannelByID(ctx, t.ChannelID)
}
recordTaskUsage(t, ch, false, "task timed out", nil)
// 更新审计记录
recordTaskCompletionAudit(t, "TIMEOUT", "", nil)
}
@@ -323,7 +330,7 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
g.Log().Infof(ctx, "poll: task %s completed", task.PublicTaskID)
// 记录用量日志
recordTaskUsage(task, true, "", settleResult)
recordTaskUsage(task, channel, true, "", settleResult)
// 更新审计记录
recordTaskCompletionAudit(task, "SUCCESS", string(body), upstreamRespHeaders(resp))
@@ -343,7 +350,7 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
g.Log().Infof(ctx, "poll: task %s failed: %s", task.PublicTaskID, task.FailReason)
// 记录用量日志
recordTaskUsage(task, false, task.FailReason, nil)
recordTaskUsage(task, channel, false, task.FailReason, nil)
// 更新审计记录
recordTaskCompletionAudit(task, "FAILURE", string(body), upstreamRespHeaders(resp))
@@ -351,12 +358,20 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
}
// recordTaskUsage 异步记录视频/音乐等异步任务的用量日志
func recordTaskUsage(task *common.AsyncTask, success bool, errMsg string, settleResult *common.SettlementResult) {
func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, success bool, errMsg string, settleResult *common.SettlementResult) {
latencyMs := 0
if task.SubmitTime != nil && task.FinishTime != nil {
latencyMs = int(task.FinishTime.Sub(*task.SubmitTime).Milliseconds())
}
// 提取渠道名称和类型
var channelName string
var channelType int
if channel != nil {
channelName = channel.Name
channelType = channel.Type
}
status := "success"
if !success {
status = "error"
@@ -367,6 +382,8 @@ func recordTaskUsage(task *common.AsyncTask, success bool, errMsg string, settle
UserID: task.UserID,
ApiKeyID: task.ApiKeyID,
ChannelID: task.ChannelID,
ChannelName: channelName,
ChannelType: channelType,
ModelName: task.ModelName,
RelayMode: int(constant.RelayModeVideoGenerations),
RequestType: 3, // async
+15 -3
View File
@@ -91,14 +91,19 @@ func (s *sTenant) WalletTransactions(ctx context.Context, req *v1.TenantWalletTr
FrozenAfter float64 `json:"frozen_after"`
RelatedType string `json:"related_type"`
Description string `json:"description"`
UserId int64 `json:"user_id"`
Username string `json:"username"`
RequestId string `json:"request_id"`
ModelName string `json:"model_name"`
CreatedAt *gtime.Time `json:"created_at"`
}
var records []transactionRow
var err error
var total int
err = query.Fields("id, type, amount, balance_after, frozen_after, related_type, description, created_at").
OrderDesc("created_at").
err = query.Fields("bil_transactions.id, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.frozen_after, bil_transactions.related_type, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at").
LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id").
OrderDesc("bil_transactions.created_at").
Page(page, pageSize).
ScanAndCount(&records, &total, false)
if err != nil {
@@ -118,6 +123,10 @@ func (s *sTenant) WalletTransactions(ctx context.Context, req *v1.TenantWalletTr
"frozen_after": r.FrozenAfter,
"related_type": r.RelatedType,
"description": r.Description,
"user_id": r.UserId,
"username": r.Username,
"request_id": r.RequestId,
"model_name": r.ModelName,
"created_at": r.CreatedAt,
})
}
@@ -505,6 +514,9 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa
Type string `json:"type"`
Amount float64 `json:"amount"`
BalanceAfter float64 `json:"balance_after"`
UserId int64 `json:"user_id"`
RequestId string `json:"request_id"`
ModelName string `json:"model_name"`
Description string `json:"description"`
CreatedAt *gtime.Time `json:"created_at"`
}
@@ -512,7 +524,7 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa
var records []transactionRow
err := dao.BilTransactions.Ctx(ctx).
Where("tenant_id", tenantID).
Fields("id, type, amount, balance_after, description, created_at").
Fields("id, type, amount, balance_after, user_id, request_id, model_name, description, created_at").
OrderDesc("created_at").
Limit(1000).Offset(offset).
Scan(&records)
+129 -49
View File
@@ -3,9 +3,6 @@ package tenant
import (
"context"
"fmt"
"github.com/gogf/gf/v2/util/gconv"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"time"
v1 "github.com/qianfree/team-api/api/tenant/v1"
@@ -13,6 +10,9 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/payment"
"github.com/qianfree/team-api/internal/utility/export"
@@ -109,7 +109,6 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq)
amount = plan.MonthlyPrice * float64(months)
}
// 生成订单号
orderNo := fmt.Sprintf("ORD%s%04d", time.Now().Format("20060102150405"), time.Now().UnixNano()%10000)
result, err := dao.OrdOrders.Ctx(ctx).Insert(do.OrdOrders{
@@ -122,7 +121,7 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq)
DiscountAmount: 0,
FinalAmount: amount,
Currency: "CNY",
PaymentChannel: "mock",
PaymentChannel: "",
Status: "pending",
ExpiredAt: gtime.Now().Add(30 * time.Minute),
Description: fmt.Sprintf("Order for new_plan"),
@@ -174,56 +173,36 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.
tenantID := ctxTenantID(ctx)
orderID := req.Id
// channelID=0 走 mock 支付(向后兼容)
if req.PaymentChannelID == 0 {
if err := payment.MockPay(ctx, orderID); err != nil {
return nil, err
}
return &v1.TenantOrderPayRes{Data: g.Map{"status": "paid"}}, nil
// 支付渠道
if req.PaymentChannel == "" {
return nil, lcommon.NewBusinessError(422, "请选择支付渠道")
}
// 真实支付渠道
//
orderNo, finalAmount, currency, orderType, description, err := getOrderForPay(ctx, tenantID, orderID)
if err != nil {
return nil, err
}
var channel *struct {
Channel string `json:"channel"`
Config string `json:"config"`
IsEnabled bool `json:"is_enabled"`
}
err = dao.OrdPaymentChannels.Ctx(ctx).
Where("id", req.PaymentChannelID).Scan(&channel)
cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel)
if err != nil {
return nil, err
}
if channel == nil {
return nil, lcommon.NewBusinessError(422, "支付渠道不存在")
}
if !channel.IsEnabled {
return nil, lcommon.NewBusinessError(422, "支付渠道已禁用")
return nil, lcommon.NewBusinessError(422, err.Error())
}
updateOrderPaymentChannel(ctx, orderID, channel.Channel, req.PaymentMethod)
provider := payment.GetProvider(channel.Channel)
provider := payment.GetProvider(req.PaymentChannel)
if provider == nil {
return nil, lcommon.NewBusinessError(422, "不支持的支付渠道")
}
cfg, err := payment.ParseChannelConfig(channel.Channel, channel.Config)
if err != nil {
return nil, err
}
updateOrderPaymentChannel(ctx, orderID, req.PaymentChannel, req.PaymentMethod)
settings, _ := payment.GetGlobalPaymentSettings(ctx)
baseURL := settings.CallbackBaseURL
notifyURL := baseURL + "/api/payment/callback/" + gconv.String(req.PaymentChannelID)
if channel.Channel == "stripe" {
notifyURL = baseURL + "/api/payment/stripe/webhook/" + gconv.String(req.PaymentChannelID)
baseURL := ""
if settings != nil {
baseURL = settings.CallbackBaseURL
}
notifyURL := baseURL + "/api/payment/callback/" + req.PaymentChannel
returnURL := baseURL + "/api/payment/epay/return"
payOrder := &payment.PaymentOrder{
OrderID: orderID,
OrderNo: orderNo,
@@ -234,6 +213,7 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.
Description: description,
PaymentMethod: req.PaymentMethod,
NotifyURL: notifyURL,
ReturnURL: returnURL,
}
result, err := provider.CreatePayment(ctx, payOrder, cfg)
@@ -255,19 +235,20 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq)
if role != "owner" && role != "admin" {
return nil, lcommon.NewForbiddenError("需要 owner 或 admin 权限")
}
channels := make([]map[string]any, 0)
err := dao.OrdPaymentChannels.Ctx(ctx).
Where("is_enabled", true).
OrderAsc("sort_order").
Fields("id, channel, name, payment_type").
Scan(&channels)
if err != nil {
return nil, err
res := &v1.TenantPaymentInfoRes{
Channels: payment.GetEnabledChannels(ctx),
}
return &v1.TenantPaymentInfoRes{Data: g.Map{
"channels": channels,
}}, nil
settings, _ := payment.GetGlobalPaymentSettings(ctx)
if settings != nil {
res.AmountOptions = settings.AmountOptions
res.AmountDiscount = settings.AmountDiscount
res.MinTopUp = settings.MinTopUp
res.Currency = settings.Currency
}
return res, nil
}
// getOrderForPay 获取待支付订单信息(供 OrderPay 内部调用)
@@ -306,6 +287,105 @@ func updateOrderPaymentChannel(ctx context.Context, orderID int64, channel, paym
}).Update()
}
// RechargeCreate 创建充值订单并发起支付(一步完成)
func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCreateReq) (*v1.TenantRechargeCreateRes, error) {
role := ctxUserRole(ctx)
if role != "owner" && role != "admin" {
return nil, lcommon.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := ctxTenantID(ctx)
userID := ctxUserID(ctx)
// 1. 校验金额
settings, _ := payment.GetGlobalPaymentSettings(ctx)
minTopup := 1
if settings != nil && settings.MinTopUp > 0 {
minTopup = settings.MinTopUp
}
if req.Amount < float64(minTopup) {
return nil, lcommon.NewBusinessError(422, fmt.Sprintf("充值金额不能小于 %d", minTopup))
}
// 2. 从 sys_options 加载渠道配置
cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel)
if err != nil {
return nil, lcommon.NewBusinessError(422, err.Error())
}
provider := payment.GetProvider(req.PaymentChannel)
if provider == nil {
return nil, lcommon.NewBusinessError(422, "不支持的支付渠道")
}
// 3. 计算折扣后金额
finalAmount := req.Amount
if settings != nil {
if discount, ok := settings.AmountDiscount[int(req.Amount)]; ok && discount > 0 {
finalAmount = req.Amount * discount
}
}
// 4. 生成订单号并创建订单
orderNo := fmt.Sprintf("RCH%s%04d", time.Now().Format("20060102150405"), time.Now().UnixNano()%10000)
description := fmt.Sprintf("钱包充值 ¥%.2f", req.Amount)
result, err := dao.OrdOrders.Ctx(ctx).Insert(do.OrdOrders{
OrderNo: orderNo,
TenantId: tenantID,
UserId: userID,
OrderType: "recharge",
Amount: req.Amount,
DiscountAmount: req.Amount - finalAmount,
FinalAmount: finalAmount,
Currency: "CNY",
PaymentChannel: req.PaymentChannel,
PaymentMethod: req.PaymentMethod,
Status: "pending",
ExpiredAt: gtime.Now().Add(30 * time.Minute),
Description: description,
})
if err != nil {
return nil, err
}
orderID, _ := result.LastInsertId()
// 5. 构建回调 URL 和 ReturnURL
baseURL := ""
if settings != nil {
baseURL = settings.CallbackBaseURL
}
notifyURL := baseURL + "/api/payment/callback/" + req.PaymentChannel
returnURL := baseURL + "/api/payment/epay/return"
// 6. 调用 Provider 生成支付链接
payResult, err := provider.CreatePayment(ctx, &payment.PaymentOrder{
OrderID: orderID,
OrderNo: orderNo,
TenantID: tenantID,
Amount: finalAmount,
Currency: "CNY",
OrderType: "recharge",
Description: description,
PaymentMethod: req.PaymentMethod,
NotifyURL: notifyURL,
ReturnURL: returnURL,
}, cfg)
if err != nil {
dao.OrdOrders.Ctx(ctx).Where("id", orderID).
Data(do.OrdOrders{Status: "cancelled"}).Update()
return nil, err
}
return &v1.TenantRechargeCreateRes{Data: g.Map{
"order_id": orderID,
"order_no": orderNo,
"payment_url": payResult.PaymentURL,
"payment_no": payResult.PaymentNo,
"params": payResult.Params,
"is_redirect": payResult.IsRedirect,
"final_amount": finalAmount,
}}, nil
}
// ExportOrders exports the tenant order list as CSV or Excel.
func (s *sTenant) ExportOrders(ctx context.Context, req *v1.TenantOrderExportReq) (*v1.TenantOrderExportRes, error) {
role := ctxUserRole(ctx)
+4 -1
View File
@@ -15,7 +15,7 @@ type BilTransactions struct {
Id any // 主键ID
TenantId any // 租户ID
WalletId any // 关联钱包ID
Type any // 类型:recharge(充值)/ pre_deduct(预扣)/ settle(结算)/ refund(退款)/ adjust(调整)/ freeze(冻结)/ unfreeze(解冻)
Type any // 类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)
Amount any // 变动金额(正数=收入,负数=支出)
BalanceAfter any // 变动后总余额
FrozenAfter any // 变动后冻结余额
@@ -24,4 +24,7 @@ type BilTransactions struct {
Description any // 交易描述
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
UserId any // 关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)
RequestId any // 关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)
ModelName any // 关联模型名(consume 类型为调用的模型名,其他类型为空)
}
-26
View File
@@ -1,26 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// OrdPaymentChannels is the golang structure of table ord_payment_channels for DAO operations like Where/Data.
type OrdPaymentChannels struct {
g.Meta `orm:"table:ord_payment_channels, do:true"`
Id any // 主键ID
Channel any // 渠道标识(alipay/wechat/stripe/mock)
Name any // 显示名称
Config any // 渠道配置(JSONB,含 API 密钥等敏感信息)
IsEnabled any // 是否启用
SortOrder any // 排序权重
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
PaymentType any // 子支付方式(alipay/wxpay 等,空表示该渠道支持所有方式)
CallbackUrl any // 支付回调地址覆盖(为空则使用系统默认)
ReturnUrl any // 支付完成后前端跳转地址覆盖
}
+1 -1
View File
@@ -14,7 +14,6 @@ type TskModelTasks struct {
g.Meta `orm:"table:tsk_model_tasks, do:true"`
Id any //
PublicTaskId any //
RequestId any // 任务提交时的原始请求 ID
Platform any //
Action any //
Status any //
@@ -37,4 +36,5 @@ type TskModelTasks struct {
FinishTime *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
RequestId any // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
}
+15 -12
View File
@@ -10,16 +10,19 @@ import (
// BilTransactions is the golang structure for table bil_transactions.
type BilTransactions struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
WalletId int64 `json:"wallet_id" orm:"wallet_id" description:"关联钱包ID"` // 关联钱包ID
Type string `json:"type" orm:"type" description:"类型:recharge(充值)/ pre_deduct(预扣)/ settle(结算)/ refund(退款)/ adjust(调整)/ freeze(冻结)/ unfreeze(解冻)"` // 类型:recharge(充值)/ pre_deduct(预扣)/ settle(结算)/ refund(退款)/ adjust(调整)/ freeze(冻结)/ unfreeze(解冻)
Amount float64 `json:"amount" orm:"amount" description:"变动金额(正数=收入,负数=支出)"` // 变动金额(正数=收入,负数=支出)
BalanceAfter float64 `json:"balance_after" orm:"balance_after" description:"变动后总余额"` // 变动后总余额
FrozenAfter float64 `json:"frozen_after" orm:"frozen_after" description:"变动后冻结余额"` // 变动后冻结余额
RelatedId int64 `json:"related_id" orm:"related_id" description:"关联业务ID(如计费记录ID、订单ID等)"` // 关联业务ID(如计费记录ID、订单ID等)
RelatedType string `json:"related_type" orm:"related_type" description:"关联业务类型:billing_record / order / refund / adjustment / redemption"` // 关联业务类型:billing_record / order / refund / adjustment / redemption
Description string `json:"description" orm:"description" description:"交易描述"` // 交易描述
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
WalletId int64 `json:"wallet_id" orm:"wallet_id" description:"关联钱包ID"` // 关联钱包ID
Type string `json:"type" orm:"type" description:"类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)"` // 类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)
Amount float64 `json:"amount" orm:"amount" description:"变动金额(正数=收入,负数=支出)"` // 变动金额(正数=收入,负数=支出)
BalanceAfter float64 `json:"balance_after" orm:"balance_after" description:"变动后总余额"` // 变动后总余额
FrozenAfter float64 `json:"frozen_after" orm:"frozen_after" description:"变动后冻结余额"` // 变动后冻结余额
RelatedId int64 `json:"related_id" orm:"related_id" description:"关联业务ID(如计费记录ID、订单ID等)"` // 关联业务ID(如计费记录ID、订单ID等)
RelatedType string `json:"related_type" orm:"related_type" description:"关联业务类型:billing_record / order / refund / adjustment / redemption"` // 关联业务类型:billing_record / order / refund / adjustment / redemption
Description string `json:"description" orm:"description" description:"交易描述"` // 交易描述
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
UserId int64 `json:"user_id" orm:"user_id" description:"关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)"` // 关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)
RequestId string `json:"request_id" orm:"request_id" description:"关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)"` // 关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)
ModelName string `json:"model_name" orm:"model_name" description:"关联模型名(consume 类型为调用的模型名,其他类型为空)"` // 关联模型名(consume 类型为调用的模型名,其他类型为空)
}
@@ -1,24 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// OrdPaymentChannels is the golang structure for table ord_payment_channels.
type OrdPaymentChannels struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
Channel string `json:"channel" orm:"channel" description:"渠道标识(alipay/wechat/stripe/mock)"` // 渠道标识(alipay/wechat/stripe/mock)
Name string `json:"name" orm:"name" description:"显示名称"` // 显示名称
Config string `json:"config" orm:"config" description:"渠道配置(JSONB,含 API 密钥等敏感信息)"` // 渠道配置(JSONB,含 API 密钥等敏感信息)
IsEnabled bool `json:"is_enabled" orm:"is_enabled" description:"是否启用"` // 是否启用
SortOrder int `json:"sort_order" orm:"sort_order" description:"排序权重"` // 排序权重
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
PaymentType string `json:"payment_type" orm:"payment_type" description:"子支付方式(alipay/wxpay 等,空表示该渠道支持所有方式)"` // 子支付方式(alipay/wxpay 等,空表示该渠道支持所有方式)
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"支付回调地址覆盖(为空则使用系统默认)"` // 支付回调地址覆盖(为空则使用系统默认)
ReturnUrl string `json:"return_url" orm:"return_url" description:"支付完成后前端跳转地址覆盖"` // 支付完成后前端跳转地址覆盖
}
+25 -25
View File
@@ -10,29 +10,29 @@ import (
// TskModelTasks is the golang structure for table tsk_model_tasks.
type TskModelTasks struct {
Id int64 `json:"id" orm:"id" description:""` //
PublicTaskId string `json:"public_task_id" orm:"public_task_id" description:""` //
RequestId string `json:"request_id" orm:"request_id" description:"任务提交时的原始请求 ID"` //
Platform string `json:"platform" orm:"platform" description:""` //
Action string `json:"action" orm:"action" description:""` //
Status string `json:"status" orm:"status" description:""` //
Progress string `json:"progress" orm:"progress" description:""` //
FailReason string `json:"fail_reason" orm:"fail_reason" description:""` //
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
UserId int64 `json:"user_id" orm:"user_id" description:""` //
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:""` //
ChannelId int64 `json:"channel_id" orm:"channel_id" description:""` //
ModelName string `json:"model_name" orm:"model_name" description:""` //
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:""` //
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:""` //
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:""` //
BillingSettled bool `json:"billing_settled" orm:"billing_settled" description:""` //
ResultUrl string `json:"result_url" orm:"result_url" description:""` //
Data string `json:"data" orm:"data" description:""` //
PrivateData string `json:"private_data" orm:"private_data" description:""` //
SubmitTime *gtime.Time `json:"submit_time" orm:"submit_time" description:""` //
StartTime *gtime.Time `json:"start_time" orm:"start_time" description:""` //
FinishTime *gtime.Time `json:"finish_time" orm:"finish_time" description:""` //
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
Id int64 `json:"id" orm:"id" description:""` //
PublicTaskId string `json:"public_task_id" orm:"public_task_id" description:""` //
Platform string `json:"platform" orm:"platform" description:""` //
Action string `json:"action" orm:"action" description:""` //
Status string `json:"status" orm:"status" description:""` //
Progress string `json:"progress" orm:"progress" description:""` //
FailReason string `json:"fail_reason" orm:"fail_reason" description:""` //
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
UserId int64 `json:"user_id" orm:"user_id" description:""` //
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:""` //
ChannelId int64 `json:"channel_id" orm:"channel_id" description:""` //
ModelName string `json:"model_name" orm:"model_name" description:""` //
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:""` //
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:""` //
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:""` //
BillingSettled bool `json:"billing_settled" orm:"billing_settled" description:""` //
ResultUrl string `json:"result_url" orm:"result_url" description:""` //
Data string `json:"data" orm:"data" description:""` //
PrivateData string `json:"private_data" orm:"private_data" description:""` //
SubmitTime *gtime.Time `json:"submit_time" orm:"submit_time" description:""` //
StartTime *gtime.Time `json:"start_time" orm:"start_time" description:""` //
FinishTime *gtime.Time `json:"finish_time" orm:"finish_time" description:""` //
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
RequestId string `json:"request_id" orm:"request_id" description:"任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id"` // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
}
+3 -11
View File
@@ -248,18 +248,10 @@ type (
RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.OrderRefundRes, error)
// OrderComplete 手动完成订单
OrderComplete(ctx context.Context, req *v1.OrderCompleteReq) (*v1.OrderCompleteRes, error)
// GetPaymentChannels 获取支付渠道配置
// GetPaymentChannels 获取所有渠道配置(单例模式,从 sys_options 读取)
GetPaymentChannels(ctx context.Context, _ *v1.PaymentChannelListReq) (*v1.PaymentChannelListRes, error)
// CreatePaymentChannel 创建支付渠道(默认禁用)。
CreatePaymentChannel(ctx context.Context, req *v1.PaymentChannelCreateReq) (*v1.PaymentChannelCreateRes, error)
// GetPaymentChannel 获取单个支付渠道详情。
GetPaymentChannel(ctx context.Context, req *v1.PaymentChannelDetailReq) (*v1.PaymentChannelDetailRes, error)
// UpdatePaymentChannel 更新支付渠道配置
UpdatePaymentChannel(ctx context.Context, req *v1.PaymentChannelUpdateReq) (*v1.PaymentChannelUpdateRes, error)
// DeletePaymentChannel 删除支付渠道。
DeletePaymentChannel(ctx context.Context, req *v1.PaymentChannelDeleteReq) (*v1.PaymentChannelDeleteRes, error)
// TogglePaymentChannel 切换支付渠道启用/禁用状态。
TogglePaymentChannel(ctx context.Context, req *v1.PaymentChannelToggleReq) (*v1.PaymentChannelToggleRes, error)
// SavePaymentChannel 保存指定渠道的配置(整体覆盖)
SavePaymentChannel(ctx context.Context, req *v1.PaymentChannelSaveReq) (*v1.PaymentChannelSaveRes, error)
// GetPaymentSettings 获取全局支付设置。
GetPaymentSettings(ctx context.Context, _ *v1.PaymentSettingsGetReq) (*v1.PaymentSettingsGetRes, error)
// UpdatePaymentSettings 更新全局支付设置。
+2
View File
@@ -203,6 +203,8 @@ type (
OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.TenantOrderPayRes, error)
// PaymentInfo 获取租户可用的支付信息(渠道列表、金额选项、折扣)
PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq) (*v1.TenantPaymentInfoRes, error)
// RechargeCreate 创建充值订单并发起支付(一步完成)
RechargeCreate(ctx context.Context, req *v1.TenantRechargeCreateReq) (*v1.TenantRechargeCreateRes, error)
// ExportOrders exports the tenant order list as CSV or Excel.
ExportOrders(ctx context.Context, req *v1.TenantOrderExportReq) (*v1.TenantOrderExportRes, error)
// GetOrgInfo returns tenant organization info.
@@ -0,0 +1,22 @@
-- +goose Up
-- bil_transactions 增加关联字段,支持按用户、模型、请求维度查询和导出交易记录
ALTER TABLE bil_transactions ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE bil_transactions ADD COLUMN IF NOT EXISTS request_id VARCHAR(64);
ALTER TABLE bil_transactions ADD COLUMN IF NOT EXISTS model_name VARCHAR(100);
COMMENT ON COLUMN bil_transactions.user_id IS '关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)';
COMMENT ON COLUMN bil_transactions.request_id IS '关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)';
COMMENT ON COLUMN bil_transactions.model_name IS '关联模型名(consume 类型为调用的模型名,其他类型为空)';
-- 按用户查询交易记录
CREATE INDEX IF NOT EXISTS idx_bil_transactions_user_created ON bil_transactions (tenant_id, user_id, created_at DESC);
-- 按模型查询消费记录
CREATE INDEX IF NOT EXISTS idx_bil_transactions_model_created ON bil_transactions (tenant_id, model_name, created_at DESC) WHERE model_name IS NOT NULL;
-- +goose Down
DROP INDEX IF EXISTS idx_bil_transactions_model_created;
DROP INDEX IF EXISTS idx_bil_transactions_user_created;
ALTER TABLE bil_transactions DROP COLUMN IF EXISTS model_name;
ALTER TABLE bil_transactions DROP COLUMN IF EXISTS request_id;
ALTER TABLE bil_transactions DROP COLUMN IF EXISTS user_id;
@@ -0,0 +1,30 @@
-- +goose Up
DROP TABLE IF EXISTS ord_payment_channels;
-- +goose Down
CREATE TABLE ord_payment_channels (
id BIGSERIAL PRIMARY KEY,
channel VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
config JSONB DEFAULT '{}' NOT NULL,
is_enabled BOOLEAN DEFAULT false NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
payment_type VARCHAR(20) DEFAULT '' NOT NULL,
callback_url VARCHAR(500) DEFAULT '' NOT NULL,
return_url VARCHAR(500) DEFAULT '' NOT NULL
);
CREATE INDEX idx_ord_payment_channels_channel ON ord_payment_channels USING btree (channel);
COMMENT ON TABLE ord_payment_channels IS '支付渠道配置';
COMMENT ON COLUMN ord_payment_channels.id IS '主键ID';
COMMENT ON COLUMN ord_payment_channels.channel IS '渠道标识(alipay/wechat/stripe/mock)';
COMMENT ON COLUMN ord_payment_channels.name IS '显示名称';
COMMENT ON COLUMN ord_payment_channels.config IS '渠道配置(JSONB,含 API 密钥等敏感信息)';
COMMENT ON COLUMN ord_payment_channels.is_enabled IS '是否启用';
COMMENT ON COLUMN ord_payment_channels.sort_order IS '排序权重';
COMMENT ON COLUMN ord_payment_channels.created_at IS '创建时间';
COMMENT ON COLUMN ord_payment_channels.updated_at IS '更新时间';
COMMENT ON COLUMN ord_payment_channels.payment_type IS '子支付方式(alipay/wxpay 等,空表示该渠道支持所有方式)';
COMMENT ON COLUMN ord_payment_channels.callback_url IS '支付回调地址覆盖(为空则使用系统默认)';
COMMENT ON COLUMN ord_payment_channels.return_url IS '支付完成后前端跳转地址覆盖';
@@ -1,302 +1,90 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { Message, Modal, Tag, Button, Space } from '@arco-design/web-vue'
import type { TableColumnData, FormInstance } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
// === Tabs ===
const activeTab = ref('channels')
// ========== 支付渠道 ==========
// ========== 支付渠道配置(单例模式) ==========
const loading = ref(false)
const channels = ref<any[]>([])
const channelsLoading = ref(false)
const channels = ref<Record<string, any>>({})
const channelTypeMap: Record<string, string> = {
epay: 'Epay',
stripe: 'Stripe',
mock: 'Mock',
// Epay 表单
const epayForm = reactive({
is_enabled: false,
pay_address: '',
merchant_id: '',
merchant_key: '',
pay_methods: '',
})
const epaySaving = ref(false)
const channelNames: Record<string, string> = {
epay: 'Epay(易支付)',
}
const channelTypeOptions = [
{ value: 'epay', label: 'Epay(易支付)' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mock', label: 'Mock(测试)' },
]
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '名称', dataIndex: 'name', width: 160 },
{
title: '类型',
dataIndex: 'channel',
width: 120,
render({ record }) {
return h(Tag, { color: 'arcoblue' }, () => channelTypeMap[record.channel] || record.channel)
},
},
{ title: '子支付方式', dataIndex: 'payment_type', width: 120 },
{
title: '排序',
dataIndex: 'sort_order',
width: 80,
},
{
title: '状态',
dataIndex: 'is_enabled',
width: 100,
render({ record }) {
return h(Tag, { color: record.is_enabled ? 'green' : undefined, size: 'small' }, () =>
record.is_enabled ? '已启用' : '已禁用',
)
},
},
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{
title: '操作',
dataIndex: 'actions',
width: 220,
fixed: 'right',
render({ record }) {
return h(Space, { size: 4 }, () => [
h(
Button,
{ type: 'text', size: 'mini', onClick: () => toggleChannel(record) },
() => (record.is_enabled ? '禁用' : '启用'),
),
h(
Button,
{ type: 'text', size: 'mini', onClick: () => openEdit(record) },
() => '编辑',
),
h(
Button,
{
type: 'text',
size: 'mini',
status: 'danger',
onClick: () => handleDelete(record),
},
() => '删除',
),
])
},
},
]
async function fetchChannels() {
loading.value = true
channelsLoading.value = true
try {
const res = await request.get('/admin/payment-channels')
const raw = res.data?.data
channels.value = Array.isArray(raw) ? raw : (raw?.data || [])
const list = res.data?.data?.list || []
const map: Record<string, any> = {}
for (const ch of list) {
map[ch.channel] = ch.config || {}
}
channels.value = map
// Epay
const epay = map.epay || {}
epayForm.is_enabled = !!epay.is_enabled
epayForm.pay_address = epay.pay_address || ''
epayForm.merchant_id = epay.merchant_id || ''
epayForm.merchant_key = epay.merchant_key || ''
epayForm.pay_methods = epay.pay_methods
? typeof epay.pay_methods === 'string'
? epay.pay_methods
: JSON.stringify(epay.pay_methods)
: ''
} catch {
/* interceptor handles error toast */
// interceptor handles error
} finally {
channelsLoading.value = false
}
}
async function saveChannel(channel: string, config: Record<string, any>, loading: { value: boolean }) {
loading.value = true
try {
await request.put(`/admin/payment-channels/${channel}`, {
config: JSON.stringify(config),
})
Message.success(`${channelNames[channel]} 配置已保存`)
} catch {
// interceptor handles error
} finally {
loading.value = false
}
}
async function toggleChannel(ch: any) {
try {
const res = await request.put(`/admin/payment-channels/${ch.id}/toggle`)
const newState = res.data?.data?.is_enabled
ch.is_enabled = newState !== undefined ? newState : !ch.is_enabled
Message.success(ch.is_enabled ? '已启用' : '已禁用')
} catch {
/* interceptor handles error toast */
}
}
function handleDelete(ch: any) {
Modal.warning({
title: '确认删除',
content: `确定要删除支付渠道「${ch.name}」吗?此操作不可恢复。`,
hideCancel: false,
onOk: async () => {
try {
await request.delete(`/admin/payment-channels/${ch.id}`)
Message.success('已删除')
fetchChannels()
} catch {
/* interceptor handles error toast */
}
},
})
}
// === 渠道编辑弹窗 ===
const showChannelModal = ref(false)
const modalTitle = ref('添加支付渠道')
const editingId = ref<number | null>(null)
const channelFormRef = ref<FormInstance | null>(null)
const channelSaving = ref(false)
const channelForm = reactive({
channel: 'epay',
name: '',
payment_type: '',
sort_order: 0,
// Epay 配置
epay_pay_address: '',
epay_merchant_id: '',
epay_merchant_key: '',
epay_pay_methods: '',
// Stripe 配置
stripe_api_secret: '',
stripe_webhook_secret: '',
stripe_price_id: '',
stripe_unit_price: 0,
stripe_min_topup: 1,
stripe_test_mode: false,
// Mock 配置
mock_auto_fulfill: true,
})
const currentChannelType = computed(() => channelForm.channel)
function resetChannelForm() {
channelForm.channel = 'epay'
channelForm.name = ''
channelForm.payment_type = ''
channelForm.sort_order = 0
channelForm.epay_pay_address = ''
channelForm.epay_merchant_id = ''
channelForm.epay_merchant_key = ''
channelForm.epay_pay_methods = ''
channelForm.stripe_api_secret = ''
channelForm.stripe_webhook_secret = ''
channelForm.stripe_price_id = ''
channelForm.stripe_unit_price = 0
channelForm.stripe_min_topup = 1
channelForm.stripe_test_mode = false
channelForm.mock_auto_fulfill = true
editingId.value = null
modalTitle.value = '添加支付渠道'
}
function openCreate() {
resetChannelForm()
showChannelModal.value = true
}
function openEdit(ch: any) {
resetChannelForm()
editingId.value = ch.id
modalTitle.value = '编辑支付渠道'
channelForm.channel = ch.channel || 'epay'
channelForm.name = ch.name || ''
channelForm.payment_type = ch.payment_type || ''
channelForm.sort_order = ch.sort_order || 0
// 解析 config JSONB
let config: any = {}
if (ch.config) {
try {
config = typeof ch.config === 'string' ? JSON.parse(ch.config) : ch.config
} catch {
config = {}
}
}
if (channelForm.channel === 'epay') {
channelForm.epay_pay_address = config.pay_address || ''
channelForm.epay_merchant_id = config.merchant_id || ''
channelForm.epay_merchant_key = config.merchant_key || ''
channelForm.epay_pay_methods = config.pay_methods
? typeof config.pay_methods === 'string'
? config.pay_methods
: JSON.stringify(config.pay_methods)
: ''
} else if (channelForm.channel === 'stripe') {
channelForm.stripe_api_secret = config.api_secret || ''
channelForm.stripe_webhook_secret = config.webhook_secret || ''
channelForm.stripe_price_id = config.price_id || ''
channelForm.stripe_unit_price = config.unit_price || 0
channelForm.stripe_min_topup = config.min_topup || 1
channelForm.stripe_test_mode = config.test_mode || false
} else if (channelForm.channel === 'mock') {
channelForm.mock_auto_fulfill = config.auto_fulfill !== false
}
showChannelModal.value = true
}
function buildConfigJSON(): string {
const type = channelForm.channel
if (type === 'epay') {
let payMethods = channelForm.epay_pay_methods
try {
payMethods = JSON.parse(payMethods)
} catch {
// keep as string
}
return JSON.stringify({
pay_address: channelForm.epay_pay_address,
merchant_id: channelForm.epay_merchant_id,
merchant_key: channelForm.epay_merchant_key,
pay_methods: payMethods || undefined,
})
} else if (type === 'stripe') {
return JSON.stringify({
api_secret: channelForm.stripe_api_secret,
webhook_secret: channelForm.stripe_webhook_secret,
price_id: channelForm.stripe_price_id,
unit_price: channelForm.stripe_unit_price,
min_topup: channelForm.stripe_min_topup,
test_mode: channelForm.stripe_test_mode,
})
} else {
return JSON.stringify({
auto_fulfill: channelForm.mock_auto_fulfill,
})
}
}
async function handleChannelSave() {
try {
const errors = await channelFormRef.value?.validate()
if (errors) return
} catch {
return
}
channelSaving.value = true
try {
const payload: Record<string, any> = {
channel: channelForm.channel,
name: channelForm.name,
payment_type: channelForm.payment_type,
sort_order: channelForm.sort_order,
config: buildConfigJSON(),
}
if (editingId.value) {
await request.put(`/admin/payment-channels/${editingId.value}`, payload)
Message.success('更新成功')
} else {
await request.post('/admin/payment-channels', payload)
Message.success('创建成功')
}
showChannelModal.value = false
fetchChannels()
} catch {
/* interceptor handles error toast */
} finally {
channelSaving.value = false
}
function handleEpaySave() {
let payMethods = epayForm.pay_methods
try { payMethods = JSON.parse(payMethods) } catch { /* keep as string */ }
saveChannel('epay', {
is_enabled: epayForm.is_enabled,
pay_address: epayForm.pay_address,
merchant_id: epayForm.merchant_id,
merchant_key: epayForm.merchant_key,
pay_methods: payMethods || undefined,
}, epaySaving)
}
// ========== 全局支付设置 ==========
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const settingsFormRef = ref<FormInstance | null>(null)
const settingsForm = reactive({
amount_options: '10,20,50,100,200,500',
@@ -317,20 +105,21 @@ async function fetchSettings() {
const res = await request.get('/admin/payment-settings')
const data = res.data?.data
if (!data) return
if (data.amount_options) {
const opts = data.amount_options
settingsForm.amount_options = Array.isArray(opts) ? opts.join(',') : String(opts)
settingsForm.amount_options = Array.isArray(data.amount_options)
? data.amount_options.join(',')
: String(data.amount_options)
}
if (data.amount_discount) {
const disc = data.amount_discount
settingsForm.amount_discount = typeof disc === 'string' ? disc : JSON.stringify(disc)
settingsForm.amount_discount = typeof data.amount_discount === 'string'
? data.amount_discount
: JSON.stringify(data.amount_discount)
}
if (data.min_topup !== undefined) settingsForm.min_topup = Number(data.min_topup)
if (data.currency) settingsForm.currency = data.currency
if (data.callback_base_url !== undefined) settingsForm.callback_base_url = data.callback_base_url
} catch {
/* interceptor handles error toast */
// interceptor handles error
} finally {
settingsLoading.value = false
}
@@ -339,7 +128,6 @@ async function fetchSettings() {
async function handleSettingsSave() {
settingsSaving.value = true
try {
// 解析金额选项
let amountOptions: number[] = []
try {
amountOptions = settingsForm.amount_options
@@ -350,16 +138,13 @@ async function handleSettingsSave() {
Message.error('金额选项格式错误')
return
}
// 解析折扣映射
let amountDiscount: Record<string, number> = {}
try {
amountDiscount = JSON.parse(settingsForm.amount_discount || '{}')
} catch {
Message.error('折扣映射格式错误,请使用 JSON 格式')
Message.error('折扣映射格式错误')
return
}
await request.put('/admin/payment-settings', {
amount_options: amountOptions,
amount_discount: amountDiscount,
@@ -369,14 +154,12 @@ async function handleSettingsSave() {
})
Message.success('保存成功')
} catch {
/* interceptor handles error toast */
// interceptor handles error
} finally {
settingsSaving.value = false
}
}
// === Init ===
onMounted(() => {
fetchChannels()
fetchSettings()
@@ -392,31 +175,53 @@ onMounted(() => {
<ATabPane key="settings" title="全局设置" />
</ATabs>
<!-- 支付渠道管理 -->
<div v-if="activeTab === 'channels'">
<div class="mb-4 flex justify-end">
<AButton type="primary" @click="openCreate">
<template #icon><IconPlus /></template>
添加渠道
</AButton>
</div>
<ACard :bordered="false">
<ATable
:columns="columns"
:data="channels"
:loading="loading"
:pagination="false"
row-key="id"
stripe
/>
<!-- 支付渠道配置 -->
<div v-if="activeTab === 'channels'" class="space-y-4">
<!-- Epay -->
<ACard :bordered="false" :loading="channelsLoading">
<template #title>
<div class="flex items-center gap-2">
<span>Epay(易支付)</span>
<ATag :color="epayForm.is_enabled ? 'green' : undefined" size="small">
{{ epayForm.is_enabled ? '已启用' : '未启用' }}
</ATag>
</div>
</template>
<AForm :model="epayForm" :auto-label-width="true">
<AFormItem label="启用">
<ASwitch v-model="epayForm.is_enabled" />
</AFormItem>
<AFormItem label="支付网关地址">
<AInput v-model="epayForm.pay_address" placeholder="https://pay.example.com" />
</AFormItem>
<AFormItem label="商户ID">
<AInput v-model="epayForm.merchant_id" placeholder="1001" />
</AFormItem>
<AFormItem label="商户密钥">
<AInput v-model="epayForm.merchant_key" placeholder="商户密钥" />
</AFormItem>
<AFormItem
label="支付方式"
extra='JSON 数组。如 [{"name":"支付宝","type":"alipay","color":"#1677ff"},{"name":"微信支付","type":"wxpay","color":"#07c160"}]'
>
<AInput
v-model="epayForm.pay_methods"
type="textarea"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder='[{"name":"支付宝","type":"alipay","color":"#1677ff"}]'
/>
</AFormItem>
</AForm>
<template #actions>
<AButton type="primary" :loading="epaySaving" @click="handleEpaySave">保存 Epay 配置</AButton>
</template>
</ACard>
</div>
<!-- 全局支付设置 -->
<div v-if="activeTab === 'settings'">
<ACard :bordered="false" :loading="settingsLoading" title="充值与金额">
<AForm ref="settingsFormRef" :model="settingsForm" :auto-label-width="true">
<AForm :model="settingsForm" :auto-label-width="true">
<AFormItem label="金额选项" extra="预设充值金额,用逗号分隔">
<AInput v-model="settingsForm.amount_options" placeholder="10,20,50,100,200,500" />
</AFormItem>
@@ -457,125 +262,6 @@ onMounted(() => {
</AButton>
</div>
</div>
<!-- 渠道编辑弹窗 -->
<AModal
v-model:visible="showChannelModal"
:title="modalTitle"
:width="560"
:mask-closable="false"
@cancel="showChannelModal = false"
@ok="handleChannelSave"
:ok-loading="channelSaving"
>
<AForm ref="channelFormRef" :model="channelForm" :auto-label-width="true" layout="vertical">
<AFormItem
field="name"
label="渠道名称"
:rules="[{ required: true, message: '请输入渠道名称' }]"
>
<AInput v-model="channelForm.name" placeholder="如:Epay 主商户" />
</AFormItem>
<AFormItem
v-if="!editingId"
field="channel"
label="渠道类型"
:rules="[{ required: true, message: '请选择渠道类型' }]"
>
<ASelect v-model="channelForm.channel" :options="channelTypeOptions" :disabled="!!editingId" />
</AFormItem>
<AFormItem v-else label="渠道类型">
<AInput :model-value="channelTypeMap[channelForm.channel] || channelForm.channel" disabled />
</AFormItem>
<AFormItem field="payment_type" label="子支付方式" extra="如 alipay、wxpay,留空表示支持所有">
<AInput v-model="channelForm.payment_type" placeholder="alipay" />
</AFormItem>
<AFormItem field="sort_order" label="排序权重">
<AInputNumber v-model="channelForm.sort_order" :min="0" />
</AFormItem>
<!-- Epay 配置 -->
<template v-if="currentChannelType === 'epay'">
<ADivider>Epay 配置</ADivider>
<AFormItem
field="epay_pay_address"
label="支付网关地址"
:rules="[{ required: true, message: '请输入支付网关地址' }]"
>
<AInput v-model="channelForm.epay_pay_address" placeholder="https://pay.example.com" />
</AFormItem>
<AFormItem
field="epay_merchant_id"
label="商户ID"
:rules="[{ required: true, message: '请输入商户ID' }]"
>
<AInput v-model="channelForm.epay_merchant_id" placeholder="1001" />
</AFormItem>
<AFormItem
field="epay_merchant_key"
label="商户密钥"
:rules="[{ required: true, message: '请输入商户密钥' }]"
>
<AInput v-model="channelForm.epay_merchant_key" placeholder="商户密钥" />
</AFormItem>
<AFormItem
field="epay_pay_methods"
label="支付方式"
extra='JSON 数组或留空。如 [{"name":"支付宝","type":"alipay","color":"#1677ff"}]'
>
<AInput
v-model="channelForm.epay_pay_methods"
type="textarea"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder='[{"name":"支付宝","type":"alipay","color":"#1677ff"}]'
/>
</AFormItem>
</template>
<!-- Stripe 配置 -->
<template v-if="currentChannelType === 'stripe'">
<ADivider>Stripe 配置</ADivider>
<AFormItem
field="stripe_api_secret"
label="API Secret Key"
:rules="[{ required: true, message: '请输入 API Secret' }]"
>
<AInput v-model="channelForm.stripe_api_secret" placeholder="sk_live_..." />
</AFormItem>
<AFormItem
field="stripe_webhook_secret"
label="Webhook Signing Secret"
:rules="[{ required: true, message: '请输入 Webhook Secret' }]"
>
<AInput v-model="channelForm.stripe_webhook_secret" placeholder="whsec_..." />
</AFormItem>
<AFormItem field="stripe_price_id" label="Price ID" extra="Stripe 中创建的 Price ID,留空则使用自定义单价">
<AInput v-model="channelForm.stripe_price_id" placeholder="price_..." />
</AFormItem>
<AFormItem field="stripe_unit_price" label="自定义单价(分)" extra="未设置 Price ID 时使用此价格,单位为最小货币单位">
<AInputNumber v-model="channelForm.stripe_unit_price" :min="0" :step="100" />
</AFormItem>
<AFormItem field="stripe_min_topup" label="最低充值金额">
<AInputNumber v-model="channelForm.stripe_min_topup" :min="0.5" :step="1" :precision="2" />
</AFormItem>
<AFormItem field="stripe_test_mode" label="测试模式">
<ASwitch v-model="channelForm.stripe_test_mode" />
</AFormItem>
</template>
<!-- Mock 配置 -->
<template v-if="currentChannelType === 'mock'">
<ADivider>Mock 配置</ADivider>
<AFormItem field="mock_auto_fulfill" label="自动履约">
<ASwitch v-model="channelForm.mock_auto_fulfill" />
<template #extra>开启后 Mock 支付会自动完成订单履约(加余额/激活套餐)</template>
</AFormItem>
</template>
</AForm>
</AModal>
</div>
</template>
@@ -341,6 +341,9 @@ const txColumns: TableColumnData[] = [
title: '变动后余额', dataIndex: 'balance_after', width: 120,
render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(2)}` },
},
{ title: '用户', dataIndex: 'username', width: 120 },
{ title: '请求ID', dataIndex: 'request_id', width: 140, ellipsis: true },
{ title: '模型', dataIndex: 'model_name', width: 130 },
{ title: '描述', dataIndex: 'description', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', width: 170 },
]
@@ -115,8 +115,11 @@ onMounted(fetchTransactions)
<th>类型</th>
<th>金额</th>
<th>余额</th>
<th>描述</th>
<th>用户</th>
<th>请求ID</th>
<th>模型</th>
<th>时间</th>
<th>描述</th>
</tr>
</thead>
<tbody>
@@ -130,8 +133,11 @@ onMounted(fetchTransactions)
{{ formatAmount(tx.amount) }}
</td>
<td class="text-gray-700">${{ tx.balance_after?.toFixed(2) ?? '--' }}</td>
<td class="text-gray-500 text-sm">{{ tx.description || '--' }}</td>
<td class="text-gray-700 text-sm">{{ tx.username || '--' }}</td>
<td class="text-gray-500 text-xs font-mono">{{ tx.request_id || '--' }}</td>
<td class="text-gray-700 text-sm">{{ tx.model_name || '--' }}</td>
<td class="text-gray-500 text-xs">{{ (tx.created_at || '').replace('T', ' ').substring(0, 16) }}</td>
<td class="text-gray-500 text-sm">{{ tx.description || '--' }}</td>
</tr>
</tbody>
</table>
+651 -128
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
const route = useRoute()
const loading = ref(false)
const wallet = ref<any>(null)
@@ -10,7 +12,12 @@ const wallet = ref<any>(null)
const rechargeAmount = ref<number | null>(null)
const customAmount = ref('')
const rechargeLoading = ref(false)
const presetAmounts = [10, 50, 100, 500]
const selectedChannel = ref('')
const selectedPaymentMethod = ref('')
const paymentInfo = ref<any>(null)
// Pay result notification
const payResult = ref<'success' | 'fail' | ''>('')
// Plans
const plans = ref<any[]>([])
@@ -35,6 +42,45 @@ const monthsOptions = [
const currentPlanId = computed(() => currentPlan.value?.plan_id)
// Computed: preset amounts from payment settings
const presetAmounts = computed(() => {
if (paymentInfo.value?.amount_options?.length) {
return paymentInfo.value.amount_options
}
return [10, 50, 100, 500]
})
// Computed: flattened pay methods from all channels
const payMethods = computed(() => {
if (!paymentInfo.value?.channels) return []
const methods: { channel: string; type: string; name: string; color: string }[] = []
for (const ch of paymentInfo.value.channels) {
if (ch.pay_methods?.length) {
for (const m of ch.pay_methods) {
methods.push({
channel: ch.channel,
type: m.type,
name: m.name,
color: m.color || '',
})
}
}
}
return methods
})
// Current plan details (matched from plans list)
const currentPlanDetail = computed(() => {
if (!currentPlan.value || !plans.value.length) return null
return plans.value.find((p: any) => p.id === currentPlan.value.plan_id) || null
})
// Balance warning
const isLowBalance = computed(() => {
if (!wallet.value) return false
return wallet.value.available_balance <= (wallet.value.warning_threshold || 0)
})
async function fetchWallet() {
try {
const res: any = await request.get('/tenant/wallet')
@@ -44,6 +90,15 @@ async function fetchWallet() {
}
}
async function fetchPaymentInfo() {
try {
const res: any = await request.get('/tenant/payment-info')
paymentInfo.value = res.data?.data
} catch {
paymentInfo.value = null
}
}
async function fetchPlans() {
loading.value = true
try {
@@ -71,21 +126,27 @@ function onCustomInput() {
rechargeAmount.value = isNaN(val) || val <= 0 ? null : val
}
function selectPayMethod(method: { channel: string; type: string }) {
selectedChannel.value = method.channel
selectedPaymentMethod.value = method.type
}
async function handleRecharge() {
if (!rechargeAmount.value || rechargeAmount.value <= 0) return
if (!selectedChannel.value || !selectedPaymentMethod.value) return
rechargeLoading.value = true
try {
const res = await request.post('/tenant/orders/create', {
order_type: 'recharge',
const res: any = await request.post('/tenant/recharge/create', {
amount: rechargeAmount.value,
payment_channel: selectedChannel.value,
payment_method: selectedPaymentMethod.value,
})
const order = res.data?.data
if (order?.id) {
await request.post(`/tenant/orders/${order.id}/pay`)
const data = res.data?.data
if (data?.payment_url) {
window.location.href = data.payment_url
return
}
rechargeAmount.value = null
customAmount.value = ''
fetchWallet()
} catch {
// interceptor handles error toast
} finally {
@@ -171,6 +232,17 @@ function formatRemaining(seconds: number): string {
onMounted(() => {
fetchWallet()
fetchPlans()
fetchPaymentInfo()
// Handle pay result from return URL
const pay = route.query.pay as string
if (pay === 'success' || pay === 'fail') {
payResult.value = pay
setTimeout(() => { payResult.value = '' }, 5000)
if (pay === 'success') {
fetchWallet()
}
}
})
onBeforeUnmount(() => {
@@ -181,153 +253,354 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="space-y-6">
<div class="wallet-page space-y-8">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">钱包</h1>
<p class="page-description">管理余额、充值和套餐方案</p>
</div>
<!-- Wallet Summary -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div class="card p-5">
<p class="stat-label">可用余额</p>
<p class="text-2xl font-bold text-gray-900">${{ wallet?.available_balance?.toFixed(2) ?? '0.00' }}</p>
<p class="text-xs text-gray-400 mt-1">{{ wallet?.currency || 'USD' }}</p>
<!-- Pay Result Banner -->
<transition name="fade">
<div v-if="payResult === 'success'" class="pay-banner pay-banner-success">
<div class="pay-banner-icon">
<Icon name="checkCircle" size="sm" />
</div>
<div class="flex-1">
<p class="text-sm font-semibold">充值成功</p>
<p class="text-xs opacity-80 mt-0.5">余额已更新,请查看钱包余额</p>
</div>
<button class="opacity-60 hover:opacity-100 transition-opacity p-1" @click="payResult = ''">
<Icon name="x" size="sm" />
</button>
</div>
<div
class="card p-5 cursor-pointer hover:-translate-y-0.5 transition-all duration-200 group"
@click="openFrozenModal"
>
<p class="stat-label">冻结余额</p>
<p class="text-2xl font-bold text-amber-600 group-hover:text-amber-700 transition-colors">
${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}
</p>
<p v-if="wallet?.frozen_balance > 0" class="text-xs text-amber-500 mt-1">点击查看明细</p>
<div v-else-if="payResult === 'fail'" class="pay-banner pay-banner-fail">
<div class="pay-banner-icon">
<Icon name="xCircle" size="sm" />
</div>
<div class="flex-1">
<p class="text-sm font-semibold">支付未完成</p>
<p class="text-xs opacity-80 mt-0.5">如果已扣款请联系客服处理</p>
</div>
<button class="opacity-60 hover:opacity-100 transition-opacity p-1" @click="payResult = ''">
<Icon name="x" size="sm" />
</button>
</div>
<div class="card p-5">
<p class="stat-label">总余额</p>
<p class="text-2xl font-bold text-gray-900">${{ wallet?.balance?.toFixed(2) ?? '0.00' }}</p>
</transition>
<!-- ============================================ -->
<!-- Hero Balance Card -->
<!-- ============================================ -->
<div class="balance-hero">
<!-- Background decorations -->
<div class="balance-hero-bg">
<div class="balance-hero-orb balance-hero-orb-1"></div>
<div class="balance-hero-orb balance-hero-orb-2"></div>
<div class="balance-hero-orb balance-hero-orb-3"></div>
<div class="balance-hero-grid"></div>
</div>
<div class="relative z-10 p-6 md:p-8 lg:p-10">
<!-- Top row -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="balance-hero-icon">
<Icon name="wallet" size="md" />
</div>
<div>
<p class="text-sm font-medium text-white/60">可用余额</p>
</div>
</div>
<div class="balance-hero-currency">
{{ wallet?.currency || 'USD' }}
</div>
</div>
<!-- Balance number -->
<div class="balance-hero-amount">
<span class="balance-hero-dollar">$</span>
<span class="balance-hero-value">{{ wallet?.available_balance?.toFixed(2) ?? '0.00' }}</span>
</div>
<!-- Low balance warning -->
<div v-if="isLowBalance && wallet" class="balance-warning">
<Icon name="exclamationTriangle" size="xs" />
<span>余额低于预警线 ${{ wallet.warning_threshold?.toFixed(2) }}</span>
</div>
<!-- Secondary balances -->
<div class="flex items-center gap-3 mt-5 flex-wrap">
<button class="balance-chip group" @click="openFrozenModal">
<span class="balance-chip-dot bg-amber-400"></span>
<span class="text-white/50">冻结</span>
<span class="text-white font-semibold">${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}</span>
<Icon v-if="wallet?.frozen_balance > 0" name="chevronRight" size="xs"
class="text-white/30 group-hover:text-white/50 transition-colors" />
</button>
<div class="w-px h-4 bg-white/10"></div>
<div class="balance-chip-static">
<span class="balance-chip-dot bg-white/30"></span>
<span class="text-white/50">总余额</span>
<span class="text-white font-semibold">${{ wallet?.balance?.toFixed(2) ?? '0.00' }}</span>
</div>
</div>
</div>
</div>
<!-- Recharge -->
<div class="card">
<div class="card-header">
<h2 class="font-semibold text-gray-900">充值</h2>
</div>
<div class="card-body">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<button
v-for="amt in presetAmounts"
:key="amt"
class="rounded-xl px-4 py-3 text-center border-2 font-semibold text-gray-900 transition-all hover:shadow-sm"
:class="rechargeAmount === amt
? 'border-primary-500 bg-primary-50 shadow-glow'
: 'border-gray-200 hover:border-gray-300'"
@click="selectPresetAmount(amt)"
>
¥{{ amt }}
</button>
<!-- ============================================ -->
<!-- Two-column: Recharge + Current Plan -->
<!-- ============================================ -->
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
<!-- Recharge Card -->
<div class="lg:col-span-3 card overflow-hidden">
<div class="card-header">
<div class="flex items-center gap-2.5">
<div class="h-8 w-8 rounded-lg bg-gradient-to-br from-primary-100 to-primary-50 flex items-center justify-center">
<Icon name="plus" size="sm" class="text-primary-600" />
</div>
<h2 class="font-semibold text-gray-900">充值</h2>
</div>
</div>
<div class="flex items-center gap-3">
<input
v-model="customAmount"
type="number"
class="input flex-1"
placeholder="自定义金额"
min="1"
step="0.01"
@input="onCustomInput"
/>
<div class="card-body space-y-6">
<!-- Amount Selection -->
<div>
<label class="input-label">充值金额(元)</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2.5 mt-2">
<button
v-for="amt in presetAmounts"
:key="amt"
class="amount-pill"
:class="rechargeAmount === amt ? 'amount-pill-active' : ''"
@click="selectPresetAmount(amt)"
>
<span class="amount-pill-symbol">¥</span>{{ amt }}
</button>
</div>
<div class="mt-3">
<input
v-model="customAmount"
type="number"
class="input"
placeholder="自定义金额"
min="1"
step="0.01"
@input="onCustomInput"
/>
</div>
</div>
<!-- Payment Method Selection -->
<div v-if="payMethods.length > 0">
<label class="input-label">支付方式</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2.5 mt-2">
<button
v-for="method in payMethods"
:key="method.channel + '-' + method.type"
class="pay-method-card"
:class="selectedChannel === method.channel && selectedPaymentMethod === method.type
? 'pay-method-card-active'
: ''"
@click="selectPayMethod(method)"
>
<span
class="pay-method-icon"
:class="{
'pay-method-icon-alipay': method.type === 'alipay',
'pay-method-icon-wxpay': method.type === 'wxpay',
'pay-method-icon-default': method.type !== 'alipay' && method.type !== 'wxpay',
}"
>
<template v-if="method.type === 'alipay'">支</template>
<template v-else-if="method.type === 'wxpay'">微</template>
<template v-else>{{ method.name.substring(0, 1) }}</template>
</span>
<span class="text-sm font-medium text-gray-700">{{ method.name }}</span>
</button>
</div>
</div>
<div v-else class="py-6 text-center">
<p class="text-sm text-gray-400">暂无可用的支付渠道,请联系管理员配置</p>
</div>
<!-- Submit -->
<button
class="btn btn-primary"
:disabled="!rechargeAmount || rechargeLoading"
class="btn btn-primary btn-lg w-full"
:disabled="!rechargeAmount || !selectedChannel || !selectedPaymentMethod || rechargeLoading"
@click="handleRecharge"
>
{{ rechargeLoading ? '处理中...' : '立即充值' }}
<template v-if="rechargeLoading">
<span class="spinner"></span>
处理中...
</template>
<template v-else>
充值 ¥{{ rechargeAmount || '—' }}
</template>
</button>
</div>
</div>
<!-- Current Plan Card -->
<div class="lg:col-span-2">
<div v-if="currentPlanDetail" class="card h-full flex flex-col">
<div class="card-header">
<div class="flex items-center gap-2.5">
<div class="h-8 w-8 rounded-lg bg-gradient-to-br from-emerald-100 to-emerald-50 flex items-center justify-center">
<Icon name="shield" size="sm" class="text-emerald-600" />
</div>
<h2 class="font-semibold text-gray-900">当前套餐</h2>
</div>
<span class="badge badge-success">使用中</span>
</div>
<div class="card-body flex-1 flex flex-col">
<h3 class="text-lg font-semibold text-gray-900 mb-1">{{ currentPlanDetail.name }}</h3>
<p class="text-xs text-gray-400 mb-5">{{ currentPlanDetail.description || '标准套餐方案' }}</p>
<div class="space-y-3 flex-1">
<div class="plan-detail-row">
<span class="text-gray-500">月费</span>
<span class="font-semibold text-gray-900">¥{{ Number(currentPlanDetail.monthly_price).toFixed(0) }}<span class="text-gray-400 font-normal text-xs">/月</span></span>
</div>
<div class="plan-detail-row">
<span class="text-gray-500">Token 额度</span>
<span v-if="currentPlanDetail.monthly_quota_tokens > 0" class="font-medium text-gray-700">
{{ currentPlanDetail.monthly_quota_tokens.toLocaleString() }}/月
</span>
<span v-else class="font-medium text-primary-600">不限</span>
</div>
<div v-if="currentPlan.value?.expires_at" class="plan-detail-row">
<span class="text-gray-500">到期时间</span>
<span class="font-medium text-gray-700">
{{ new Date(currentPlan.value.expires_at * 1000).toLocaleDateString('zh-CN') }}
</span>
</div>
</div>
<div class="mt-5 pt-4 border-t border-gray-100">
<a href="#plans" class="text-sm text-primary-600 font-medium hover:text-primary-700 transition-colors inline-flex items-center gap-1">
更换套餐
<Icon name="chevronRight" size="xs" />
</a>
</div>
</div>
</div>
<!-- No current plan -->
<div v-else class="card h-full">
<div class="card-header">
<div class="flex items-center gap-2.5">
<div class="h-8 w-8 rounded-lg bg-gray-50 flex items-center justify-center">
<Icon name="exclamationCircle" size="sm" class="text-gray-400" />
</div>
<h2 class="font-semibold text-gray-900">当前套餐</h2>
</div>
</div>
<div class="card-body flex flex-col items-center justify-center py-12">
<div class="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-4">
<Icon name="creditCard" size="lg" class="text-gray-300" />
</div>
<p class="text-sm text-gray-500 mb-1">暂无订阅套餐</p>
<a href="#plans" class="text-sm text-primary-600 font-medium hover:text-primary-700 transition-colors">
查看可用套餐 &rarr;
</a>
</div>
</div>
</div>
</div>
<!-- ============================================ -->
<!-- Subscription Plans -->
<div class="card">
<!-- ============================================ -->
<div id="plans" class="card">
<div class="card-header">
<h2 class="font-semibold text-gray-900">套餐方案</h2>
<p class="text-sm text-gray-500 mt-0.5">选择适合您团队的方案</p>
<div class="flex items-center gap-2.5">
<div class="h-8 w-8 rounded-lg bg-gradient-to-br from-violet-100 to-violet-50 flex items-center justify-center">
<Icon name="chart" size="sm" class="text-violet-600" />
</div>
<div>
<h2 class="font-semibold text-gray-900">套餐方案</h2>
<p class="text-xs text-gray-400 mt-0.5">选择适合您团队的方案</p>
</div>
</div>
</div>
<div class="card-body">
<!-- Loading -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i" class="p-4 border border-gray-200 rounded-xl animate-pulse">
<div class="h-5 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="h-7 bg-gray-200 rounded w-3/4 mb-4"></div>
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div v-for="i in 3" :key="i" class="h-52 rounded-2xl border border-gray-100 p-5 animate-pulse">
<div class="h-4 bg-gray-100 rounded w-1/3 mb-3"></div>
<div class="h-8 bg-gray-100 rounded w-2/3 mb-4"></div>
<div class="h-3 bg-gray-100 rounded w-full mb-2"></div>
<div class="h-3 bg-gray-100 rounded w-3/4"></div>
</div>
</div>
<!-- Empty -->
<div v-else-if="plans.length === 0" class="py-8 text-center">
<Icon name="exclamationCircle" size="xl" class="text-gray-300 mx-auto mb-3" />
<div v-else-if="plans.length === 0" class="py-12 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-50 mb-4">
<Icon name="exclamationCircle" size="lg" class="text-gray-300" />
</div>
<p class="text-gray-500 text-sm">暂无可用套餐,请联系管理员配置</p>
</div>
<!-- Plan Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div
v-for="plan in plans"
:key="plan.id"
class="relative flex flex-col p-4 border rounded-xl transition-all hover:shadow-sm"
:class="plan.is_recommended
? 'border-primary-500 bg-primary-50/30 ring-1 ring-primary-500/20'
: 'border-gray-200 hover:border-gray-300'"
class="plan-card group"
:class="{
'plan-card-recommended': plan.is_recommended,
'plan-card-current': isCurrentPlan(plan),
}"
>
<!-- Recommended Badge -->
<div
v-if="plan.is_recommended"
class="absolute -top-2.5 left-1/2 -translate-x-1/2"
>
<span class="badge badge-primary text-xs">推荐</span>
</div>
<!-- Recommended accent bar -->
<div v-if="plan.is_recommended" class="plan-card-accent"></div>
<!-- Current Plan Badge -->
<div
v-if="isCurrentPlan(plan)"
class="absolute top-3 right-3"
>
<span class="badge badge-success text-xs">当前</span>
</div>
<!-- Plan Info -->
<h3 class="text-base font-semibold text-gray-900 mb-1">{{ plan.name }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ plan.description || '' }}</p>
<!-- Price -->
<div class="mb-4">
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold text-gray-900">¥{{ Number(plan.monthly_price).toFixed(0) }}</span>
<span class="text-xs text-gray-500">/月</span>
<div class="p-5">
<!-- Header row -->
<div class="flex items-start justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">{{ plan.name }}</h3>
<div class="flex items-center gap-1.5">
<span v-if="plan.is_recommended && !isCurrentPlan(plan)" class="badge badge-primary text-xs">推荐</span>
<span v-if="isCurrentPlan(plan)" class="badge badge-success text-xs">当前</span>
</div>
</div>
<p v-if="Number(plan.yearly_price) > 0" class="text-xs text-gray-400 mt-1">
年付 ¥{{ Number(plan.yearly_price).toFixed(0) }}(省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }})
</p>
</div>
<!-- Features -->
<div class="space-y-1.5 text-sm text-gray-600 flex-1">
<div v-if="plan.monthly_quota_tokens > 0" class="flex items-center gap-1.5">
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
<span class="text-xs">{{ plan.monthly_quota_tokens.toLocaleString() }} Tokens/月</span>
<!-- Price -->
<div class="mb-5">
<div class="flex items-baseline gap-1">
<span class="text-xs text-gray-400">¥</span>
<span class="text-3xl font-bold text-gray-900 tracking-tight">
{{ Number(plan.monthly_price) === 0 ? '免费' : Number(plan.monthly_price).toFixed(0) }}
</span>
<span v-if="Number(plan.monthly_price) > 0" class="text-sm text-gray-400">/月</span>
</div>
<p v-if="Number(plan.yearly_price) > 0" class="text-xs text-primary-600 mt-1.5 font-medium">
年付 ¥{{ Number(plan.yearly_price).toFixed(0) }},省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }}
</p>
</div>
<div v-else class="flex items-center gap-1.5">
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
<span class="text-xs">不限 Token 用量</span>
</div>
</div>
<!-- Action -->
<div class="mt-4 pt-3 border-t border-gray-100">
<!-- Features -->
<div class="space-y-2.5 mb-5">
<div class="flex items-center gap-2.5">
<div class="plan-card-check">
<Icon name="check" size="xs" />
</div>
<span class="text-sm text-gray-600">
{{ plan.monthly_quota_tokens > 0 ? plan.monthly_quota_tokens.toLocaleString() + ' Tokens/月' : '不限 Token 用量' }}
</span>
</div>
<div v-if="plan.description" class="flex items-center gap-2.5">
<div class="plan-card-check">
<Icon name="check" size="xs" />
</div>
<span class="text-sm text-gray-600 line-clamp-1">{{ plan.description }}</span>
</div>
</div>
<!-- Action -->
<button
v-if="isCurrentPlan(plan)"
class="btn btn-secondary w-full btn-sm"
@@ -337,7 +610,8 @@ onBeforeUnmount(() => {
</button>
<button
v-else
class="btn btn-primary w-full btn-sm"
class="btn w-full btn-sm"
:class="plan.is_recommended ? 'btn-primary' : 'btn-secondary hover:!border-primary-300 hover:!text-primary-600'"
@click="openConfirm(plan)"
>
{{ Number(plan.monthly_price) === 0 ? '免费开通' : '立即购买' }}
@@ -348,7 +622,9 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Confirm Modal -->
<!-- ============================================ -->
<!-- Confirm Subscribe Modal -->
<!-- ============================================ -->
<Teleport to="body">
<transition name="modal">
<div v-if="showConfirm" class="modal-overlay" @click.self="showConfirm = false">
@@ -360,19 +636,24 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="modal-body">
<div v-if="selectedPlan" class="space-y-4">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<span class="font-medium text-gray-900">{{ selectedPlan.name }}</span>
<div v-if="selectedPlan" class="space-y-5">
<!-- Selected plan summary -->
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div>
<p class="font-medium text-gray-900">{{ selectedPlan.name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ selectedPlan.identifier }}</p>
</div>
<span class="badge badge-primary">{{ selectedPlan.identifier }}</span>
</div>
<!-- Duration selection -->
<div>
<label class="input-label">订阅时长</label>
<div class="grid grid-cols-2 gap-2 mt-1.5">
<div class="grid grid-cols-2 gap-2 mt-2">
<button
v-for="opt in monthsOptions"
:key="opt.value"
class="rounded-xl px-3 py-2 text-sm font-medium border transition-all"
class="rounded-xl px-3 py-2.5 text-sm font-medium border-2 transition-all"
:class="selectedMonths === opt.value
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
@@ -383,7 +664,8 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-xl">
<!-- Price summary -->
<div class="flex items-center justify-between p-4 rounded-xl bg-gradient-to-r from-primary-50 to-primary-100/50 border border-primary-200/50">
<span class="text-sm text-gray-600">应付金额</span>
<span class="text-xl font-bold text-primary-600">
¥{{ calcPrice(selectedPlan, selectedMonths).toFixed(2) }}
@@ -398,7 +680,11 @@ onBeforeUnmount(() => {
:disabled="confirmLoading"
@click="handleSubscribe"
>
{{ confirmLoading ? '处理中...' : '确认订阅' }}
<template v-if="confirmLoading">
<span class="spinner"></span>
处理中...
</template>
<template v-else>确认订阅</template>
</button>
</div>
</div>
@@ -406,7 +692,9 @@ onBeforeUnmount(() => {
</transition>
</Teleport>
<!-- ============================================ -->
<!-- Frozen Items Modal -->
<!-- ============================================ -->
<Teleport to="body">
<transition name="modal">
<div v-if="showFrozenModal" class="modal-overlay" @click.self="closeFrozenModal">
@@ -420,17 +708,19 @@ onBeforeUnmount(() => {
<div class="modal-body">
<!-- Loading -->
<div v-if="frozenLoading && frozenItems.length === 0" class="space-y-3">
<div v-for="i in 3" :key="i" class="h-12 bg-gray-100 rounded-xl animate-pulse"></div>
<div v-for="i in 3" :key="i" class="h-14 bg-gray-100 rounded-xl animate-pulse"></div>
</div>
<!-- Empty -->
<div v-else-if="frozenItems.length === 0" class="py-8 text-center">
<Icon name="exclamationCircle" size="xl" class="text-gray-300 mx-auto mb-3" />
<div v-else-if="frozenItems.length === 0" class="py-10 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-50 mb-3">
<Icon name="checkCircle" size="lg" class="text-gray-300" />
</div>
<p class="text-gray-500 text-sm">当前没有冻结的资金</p>
</div>
<!-- Items list -->
<div v-else class="space-y-2">
<div v-for="item in frozenItems" :key="item.request_id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
class="flex items-center justify-between p-3.5 bg-gray-50/80 rounded-xl hover:bg-gray-100/80 transition-colors">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ item.model_name || '未知模型' }}</p>
<p class="text-xs text-gray-400 truncate mt-0.5">
@@ -446,7 +736,10 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="modal-footer">
<p class="text-xs text-gray-400 flex-1">自动刷新中 · 每 10 秒更新</p>
<p class="text-xs text-gray-400 flex-1 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 animate-pulse"></span>
自动刷新中 · 每 10 秒更新
</p>
<button @click="closeFrozenModal" class="btn btn-secondary btn-sm">关闭</button>
</div>
</div>
@@ -455,3 +748,233 @@ onBeforeUnmount(() => {
</Teleport>
</div>
</template>
<style scoped>
/* ==========================================
Pay Result Banners
========================================== */
.pay-banner {
border-radius: 1rem;
padding: 0.875rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.pay-banner-success {
background-color: #ecfdf5;
border: 1px solid rgba(167, 243, 208, 0.6);
color: #065f46;
}
.pay-banner-success .pay-banner-icon {
height: 2rem; width: 2rem; border-radius: 9999px;
background-color: #d1fae5;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: #059669;
}
.pay-banner-fail {
background-color: #fef2f2;
border: 1px solid rgba(254, 202, 202, 0.6);
color: #991b1b;
}
.pay-banner-fail .pay-banner-icon {
height: 2rem; width: 2rem; border-radius: 9999px;
background-color: #fee2e2;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: #dc2626;
}
/* ==========================================
Hero Balance Card
========================================== */
.balance-hero {
position: relative; overflow: hidden;
border-radius: 1rem;
background: linear-gradient(135deg, #0f766e 0%, #115e59 40%, #042f2e 100%);
}
.balance-hero-bg {
position: absolute; inset: 0;
pointer-events: none; overflow: hidden;
}
.balance-hero-orb { position: absolute; border-radius: 9999px; }
.balance-hero-orb-1 {
top: -5rem; right: -4rem; height: 18rem; width: 18rem;
background: radial-gradient(circle, rgba(45, 212, 191, 0.15) 0%, transparent 70%);
}
.balance-hero-orb-2 {
bottom: -3rem; left: -3rem; height: 14rem; width: 14rem;
background: radial-gradient(circle, rgba(94, 234, 212, 0.1) 0%, transparent 70%);
}
.balance-hero-orb-3 {
top: 33%; right: 25%; height: 8rem; width: 8rem;
background: radial-gradient(circle, rgba(20, 184, 166, 0.08) 0%, transparent 70%);
}
.balance-hero-grid {
position: absolute; inset: 0;
background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.04) 1px, transparent 0);
background-size: 24px 24px;
}
.balance-hero-icon {
height: 2.5rem; width: 2.5rem; border-radius: 0.75rem;
display: flex; align-items: center; justify-content: center;
color: white;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.balance-hero-currency {
border-radius: 9999px; padding: 0.25rem 0.75rem;
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.025em;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.balance-hero-amount { display: flex; align-items: baseline; }
.balance-hero-dollar {
font-size: 1.5rem; font-weight: 600; padding-right: 0.25rem;
color: rgba(255, 255, 255, 0.4);
}
.balance-hero-value {
font-size: 2.25rem; font-weight: 700; color: white;
letter-spacing: -0.025em; font-variant-numeric: tabular-nums;
}
@media (min-width: 768px) {
.balance-hero-value { font-size: 3rem; }
}
.balance-warning {
margin-top: 0.75rem;
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.75rem; border-radius: 0.5rem;
font-size: 0.75rem; font-weight: 500; color: #fcd34d;
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.2);
}
.balance-chip {
display: inline-flex; align-items: center; gap: 0.5rem;
border-radius: 0.75rem; padding: 0.5rem 0.875rem;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.balance-chip:hover { background: rgba(255, 255, 255, 0.1); }
.balance-chip-static {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0 0.25rem;
}
.balance-chip-dot {
height: 0.375rem; width: 0.375rem;
border-radius: 9999px; flex-shrink: 0;
}
/* ==========================================
Amount Pills
========================================== */
.amount-pill {
position: relative; border-radius: 0.75rem;
padding: 0.75rem 1rem; text-align: center;
font-weight: 600; font-size: 0.875rem;
transition: all 0.2s;
border: 2px solid #e5e7eb; color: #374151;
}
.amount-pill:hover { border-color: #d1d5db; color: #111827; }
.amount-pill-active {
border-color: #14b8a6; background-color: #f0fdfa; color: #0f766e;
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.1), 0 0 16px rgba(20, 184, 166, 0.12);
}
.amount-pill-symbol {
font-size: 0.75rem; font-weight: 500;
margin-right: 0.125rem; opacity: 0.5;
}
/* ==========================================
Payment Method Cards
========================================== */
.pay-method-card {
display: flex; align-items: center; gap: 0.75rem;
border-radius: 0.75rem; padding: 0.75rem 1rem;
border: 2px solid #e5e7eb; transition: all 0.2s;
}
.pay-method-card:hover { border-color: #d1d5db; }
.pay-method-card-active {
border-color: #14b8a6;
background-color: rgba(240, 253, 250, 0.5);
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.1), 0 0 16px rgba(20, 184, 166, 0.08);
}
.pay-method-icon {
height: 2.25rem; width: 2.25rem; border-radius: 0.5rem;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 700; color: white; flex-shrink: 0;
}
.pay-method-icon-alipay { background: linear-gradient(135deg, #1677ff, #0958d9); }
.pay-method-icon-wxpay { background: linear-gradient(135deg, #07c160, #06ad56); }
.pay-method-icon-default { background: linear-gradient(135deg, #6b7280, #4b5563); }
/* ==========================================
Plan Detail Rows
========================================== */
.plan-detail-row {
display: flex; align-items: center;
justify-content: space-between;
font-size: 0.875rem; padding: 0.25rem 0;
}
/* ==========================================
Plan Cards
========================================== */
.plan-card {
position: relative; border-radius: 1rem;
border: 1px solid #e5e7eb; background: white;
overflow: hidden; transition: all 0.3s;
}
.plan-card:hover {
border-color: #d1d5db;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08);
}
.plan-card-recommended {
border-color: #99f6e4;
background: linear-gradient(180deg, rgba(240, 253, 250, 0.5) 0%, white 30%);
}
.plan-card-recommended:hover { border-color: #5eead4; }
.plan-card-current {
border-color: #a7f3d0;
background-color: rgba(236, 253, 245, 0.3);
}
.plan-card-current:hover { border-color: #6ee7b7; }
.plan-card-accent {
height: 0.25rem; width: 100%;
background: linear-gradient(90deg, #2dd4bf, #14b8a6, #0d9488);
}
.plan-card-check {
height: 1.25rem; width: 1.25rem; border-radius: 0.375rem;
background-color: #f0fdfa;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: #14b8a6;
}
/* ==========================================
Transitions
========================================== */
.fade-enter-active { transition: all 0.3s ease-out; }
.fade-leave-active { transition: all 0.2s ease-in; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-4px); }
/* ==========================================
Page entrance animation
========================================== */
.wallet-page > * {
animation: fade-in 0.4s ease-out both;
}
.wallet-page > *:nth-child(1) { animation-delay: 0ms; }
.wallet-page > *:nth-child(2) { animation-delay: 60ms; }
.wallet-page > *:nth-child(3) { animation-delay: 120ms; }
.wallet-page > *:nth-child(4) { animation-delay: 180ms; }
.wallet-page > *:nth-child(5) { animation-delay: 240ms; }
.wallet-page > *:nth-child(6) { animation-delay: 300ms; }
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.wallet-page > * { animation: none !important; }
}
</style>