refactor(payment):重构支付功能
This commit is contained in:
1 parent
0f76ed36b2
commit
555bddf5c3
45 files changed
+1297
-1458
No files matched your search
+1
-5
@@ -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)
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
// === 支付设置 ===
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 获取全局支付设置。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 验签并解析回调
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 类型为调用的模型名,其他类型为空)
|
||||
}
|
||||
@@ -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 // 支付完成后前端跳转地址覆盖
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"支付完成后前端跳转地址覆盖"` // 支付完成后前端跳转地址覆盖
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 更新全局支付设置。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
查看可用套餐 →
|
||||
</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>
|
||||
Reference in new issue
Block a user