refactor(plan):移除套餐相关的所有修改
This commit is contained in:
1 parent
8480d06d21
commit
5d48dbcdc2
73 files changed
+958
-2306
No files matched your search
@@ -144,7 +144,6 @@ type IAdminV1 interface {
|
||||
OrderList(ctx context.Context, req *v1.OrderListReq) (res *v1.OrderListRes, err error)
|
||||
OrderDetail(ctx context.Context, req *v1.OrderDetailReq) (res *v1.OrderDetailRes, err error)
|
||||
OrderRefund(ctx context.Context, req *v1.OrderRefundReq) (res *v1.OrderRefundRes, err error)
|
||||
OrderRefundPreview(ctx context.Context, req *v1.OrderRefundPreviewReq) (res *v1.OrderRefundPreviewRes, err error)
|
||||
OrderComplete(ctx context.Context, req *v1.OrderCompleteReq) (res *v1.OrderCompleteRes, err error)
|
||||
OrderExport(ctx context.Context, req *v1.OrderExportReq) (res *v1.OrderExportRes, err error)
|
||||
AdminMemberCreate(ctx context.Context, req *v1.AdminMemberCreateReq) (res *v1.AdminMemberCreateRes, err error)
|
||||
|
||||
@@ -62,23 +62,7 @@ type OrderRefundReq struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type OrderRefundRes struct {
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
}
|
||||
|
||||
// OrderRefundPreviewReq 退款预览(计算可退金额)
|
||||
type OrderRefundPreviewReq struct {
|
||||
g.Meta `path:"/orders/{id}/refund-preview" method:"get" mime:"json" tags:"管理后台-订单" summary:"退款预览"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
}
|
||||
|
||||
type OrderRefundPreviewRes struct {
|
||||
CanRefund bool `json:"can_refund"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
OrderType string `json:"order_type"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
type OrderRefundRes struct{}
|
||||
|
||||
type OrderCompleteReq struct {
|
||||
g.Meta `path:"/orders/{id}/complete" method:"post" mime:"json" tags:"管理后台-订单" summary:"手动完成订单"`
|
||||
|
||||
+23
-32
@@ -15,24 +15,19 @@ type PlanListReq struct {
|
||||
}
|
||||
|
||||
type PlanItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"`
|
||||
CreditAmount float64 `json:"credit_amount"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
PurchaseLimit int `json:"purchase_limit"`
|
||||
PurchaseLimitPeriod string `json:"purchase_limit_period"`
|
||||
Stock int `json:"stock"`
|
||||
TotalPurchased int `json:"total_purchased"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Description string `json:"description"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
Status string `json:"status"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PlanListRes struct {
|
||||
@@ -43,20 +38,16 @@ type PlanListRes struct {
|
||||
}
|
||||
|
||||
type PlanCreateReq struct {
|
||||
g.Meta `path:"/plans" method:"post" mime:"json" tags:"管理后台-套餐" summary:"创建套餐"`
|
||||
Name string `json:"name" v:"required#请输入名称"`
|
||||
Identifier string `json:"identifier" v:"required#请输入标识符"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price" v:"min:0.01#价格必须大于0.01"`
|
||||
CreditAmount float64 `json:"credit_amount" v:"min:0.01#额度必须大于0.01"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days" v:"min:1#有效天数至少为1"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
PurchaseLimit int `json:"purchase_limit"`
|
||||
PurchaseLimitPeriod string `json:"purchase_limit_period" v:"in:lifetime,monthly,yearly#限购周期无效"`
|
||||
Stock int `json:"stock"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
g.Meta `path:"/plans" method:"post" mime:"json" tags:"管理后台-套餐" summary:"创建套餐"`
|
||||
Name string `json:"name" v:"required#请输入名称"`
|
||||
Identifier string `json:"identifier" v:"required#请输入标识符"`
|
||||
Description string `json:"description"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type PlanCreateRes struct {
|
||||
|
||||
@@ -119,8 +119,8 @@ type ITenantV1 interface {
|
||||
PersonalModelDist(ctx context.Context, req *v1.PersonalModelDistReq) (res *v1.PersonalModelDistRes, err error)
|
||||
PersonalApiKeyUsage(ctx context.Context, req *v1.PersonalApiKeyUsageReq) (res *v1.PersonalApiKeyUsageRes, err error)
|
||||
TenantPlanList(ctx context.Context, req *v1.TenantPlanListReq) (res *v1.TenantPlanListRes, err error)
|
||||
TenantPlanMine(ctx context.Context, req *v1.TenantPlanMineReq) (res *v1.TenantPlanMineRes, err error)
|
||||
TenantPlanOrderCreate(ctx context.Context, req *v1.TenantPlanOrderCreateReq) (res *v1.TenantPlanOrderCreateRes, err error)
|
||||
TenantPlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (res *v1.TenantPlanCurrentRes, err error)
|
||||
TenantPlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (res *v1.TenantPlanCancelAutoRenewRes, err error)
|
||||
PlaygroundChat(ctx context.Context, req *v1.PlaygroundChatReq) (res *v1.PlaygroundChatRes, err error)
|
||||
PlaygroundImage(ctx context.Context, req *v1.PlaygroundImageReq) (res *v1.PlaygroundImageRes, err error)
|
||||
PlaygroundAudioTTS(ctx context.Context, req *v1.PlaygroundAudioTTSReq) (res *v1.PlaygroundAudioTTSRes, err error)
|
||||
|
||||
@@ -92,13 +92,7 @@ type TenantRechargeCreateReq struct {
|
||||
}
|
||||
|
||||
type TenantRechargeCreateRes struct {
|
||||
OrderId int64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
PaymentUrl string `json:"payment_url"`
|
||||
PaymentNo string `json:"payment_no"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
IsRedirect bool `json:"is_redirect"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
type TenantPaymentInfoReq struct {
|
||||
@@ -109,7 +103,7 @@ type TenantPaymentInfoRes struct {
|
||||
Channels []map[string]any `json:"channels"`
|
||||
AmountOptions []int `json:"amount_options,omitempty"`
|
||||
AmountDiscount map[int]float64 `json:"amount_discount,omitempty"`
|
||||
MinTopUp float64 `json:"min_topup,omitempty"`
|
||||
MinTopUp int `json:"min_topup,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -8,61 +8,50 @@ import (
|
||||
// === 租户套餐 ===
|
||||
|
||||
type TenantPlanItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
CreditAmount float64 `json:"credit_amount"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Description string `json:"description"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type TenantPlanListReq struct {
|
||||
g.Meta `path:"/plans" method:"get" mime:"json" tags:"租户控制台-套餐" summary:"可购买套餐列表"`
|
||||
g.Meta `path:"/plans" method:"get" mime:"json" tags:"租户控制台-套餐" summary:"套餐列表"`
|
||||
}
|
||||
|
||||
type TenantPlanListRes struct {
|
||||
List []*TenantPlanItem `json:"list"`
|
||||
}
|
||||
|
||||
// TenantPlanMineReq 我的套餐
|
||||
type TenantPlanMineReq struct {
|
||||
g.Meta `path:"/plans/mine" method:"get" mime:"json" tags:"租户控制台-套餐" summary:"我的套餐"`
|
||||
type TenantPlanCurrentReq struct {
|
||||
g.Meta `path:"/plan/current" method:"get" mime:"json" tags:"租户控制台-套餐" summary:"当前套餐"`
|
||||
}
|
||||
|
||||
type TenantPlanMineItem struct {
|
||||
Id int64 `json:"id"`
|
||||
PlanId int64 `json:"plan_id"`
|
||||
PlanName string `json:"plan_name"`
|
||||
Status string `json:"status"`
|
||||
TotalCredits float64 `json:"total_credits"`
|
||||
RemainingCredits float64 `json:"remaining_credits"`
|
||||
StartAt *gtime.Time `json:"start_at"`
|
||||
EndAt *gtime.Time `json:"end_at"`
|
||||
ActivatedAt *gtime.Time `json:"activated_at"`
|
||||
ExpiresAt *gtime.Time `json:"expires_at"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
type TenantPlanCurrentRes struct {
|
||||
Id int64 `json:"id"`
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
PlanId int64 `json:"plan_id"`
|
||||
Status string `json:"status"`
|
||||
StartAt *gtime.Time `json:"start_at"`
|
||||
EndAt *gtime.Time `json:"end_at"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
UsedTokens int64 `json:"used_tokens"`
|
||||
LastResetAt *gtime.Time `json:"last_reset_at"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Description string `json:"description"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
}
|
||||
|
||||
type TenantPlanMineRes struct {
|
||||
List []*TenantPlanMineItem `json:"list"`
|
||||
TotalRemaining float64 `json:"total_remaining"`
|
||||
ActiveCount int `json:"active_count"`
|
||||
type TenantPlanCancelAutoRenewReq struct {
|
||||
g.Meta `path:"/plan/cancel-auto-renew" method:"put" mime:"json" tags:"租户控制台-套餐" summary:"取消自动续费"`
|
||||
}
|
||||
|
||||
// TenantPlanOrderCreateReq 购买套餐创建订单
|
||||
type TenantPlanOrderCreateReq struct {
|
||||
g.Meta `path:"/plans/orders" method:"post" mime:"json" tags:"租户控制台-套餐" summary:"购买套餐"`
|
||||
PlanId int64 `json:"plan_id" v:"required|min:1#请选择套餐"`
|
||||
PaymentChannel string `json:"payment_channel" v:"required#请选择支付渠道"`
|
||||
PaymentMethod string `json:"payment_method" v:"required#请选择支付方式"`
|
||||
}
|
||||
|
||||
type TenantPlanOrderCreateRes struct {
|
||||
OrderId int64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
PaymentUrl string `json:"payment_url"`
|
||||
}
|
||||
type TenantPlanCancelAutoRenewRes struct{}
|
||||
@@ -164,9 +164,6 @@ var (
|
||||
billing.CleanExpiredPreDeducts(ctx)
|
||||
return nil
|
||||
})
|
||||
cs.Register("plan_expiration_check", "0 * * * *", func(ctx context.Context) error {
|
||||
return task.CheckPlanExpirations(ctx)
|
||||
})
|
||||
cs.StartBackground(ctx)
|
||||
|
||||
// Initialize plugin system (must be after CronScheduler init)
|
||||
|
||||
@@ -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) OrderRefundPreview(ctx context.Context, req *v1.OrderRefundPreviewReq) (res *v1.OrderRefundPreviewRes, err error) {
|
||||
return service.Admin().OrderRefundPreview(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) TenantPlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (res *v1.TenantPlanCancelAutoRenewRes, err error) {
|
||||
return service.Tenant().PlanCancelAutoRenew(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) TenantPlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (res *v1.TenantPlanCurrentRes, err error) {
|
||||
return service.Tenant().PlanCurrent(ctx, req)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) TenantPlanMine(ctx context.Context, req *v1.TenantPlanMineReq) (res *v1.TenantPlanMineRes, err error) {
|
||||
return service.Tenant().PlanMine(ctx, req)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) TenantPlanOrderCreate(ctx context.Context, req *v1.TenantPlanOrderCreateReq) (res *v1.TenantPlanOrderCreateRes, err error) {
|
||||
return service.Tenant().PlanOrderCreate(ctx, req)
|
||||
}
|
||||
@@ -54,9 +54,6 @@ type BilRecordsColumns struct {
|
||||
BaseInputPrice string // 基础模型输入单价(快照,应用倍率前)
|
||||
BaseOutputPrice string // 基础模型输出单价(快照,应用倍率前)
|
||||
BillingSnapshot string // 完整计费计算过程快照(JSONB)
|
||||
TenantPlanId string //
|
||||
PlanDeduction string //
|
||||
WalletDeduction string //
|
||||
}
|
||||
|
||||
// bilRecordsColumns holds the columns for the table bil_records.
|
||||
@@ -94,9 +91,6 @@ var bilRecordsColumns = BilRecordsColumns{
|
||||
BaseInputPrice: "base_input_price",
|
||||
BaseOutputPrice: "base_output_price",
|
||||
BillingSnapshot: "billing_snapshot",
|
||||
TenantPlanId: "tenant_plan_id",
|
||||
PlanDeduction: "plan_deduction",
|
||||
WalletDeduction: "wallet_deduction",
|
||||
}
|
||||
|
||||
// NewBilRecordsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -39,7 +39,6 @@ type BilTransactionsColumns struct {
|
||||
ProjectId string // 关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)
|
||||
ApiKeyId string // 关联API密钥ID(consume 类型为发起请求的密钥)
|
||||
TaskId string // 关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)
|
||||
TenantPlanId string //
|
||||
}
|
||||
|
||||
// bilTransactionsColumns holds the columns for the table bil_transactions.
|
||||
@@ -62,7 +61,6 @@ var bilTransactionsColumns = BilTransactionsColumns{
|
||||
ProjectId: "project_id",
|
||||
ApiKeyId: "api_key_id",
|
||||
TaskId: "task_id",
|
||||
TenantPlanId: "tenant_plan_id",
|
||||
}
|
||||
|
||||
// NewBilTransactionsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -78,9 +78,6 @@ type BilUsageLogsColumns struct {
|
||||
BillingSnapshot string // 完整计费计算过程快照(JSONB)
|
||||
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
TenantPlanId string //
|
||||
PlanDeduction string //
|
||||
WalletDeduction string //
|
||||
}
|
||||
|
||||
// bilUsageLogsColumns holds the columns for the table bil_usage_logs.
|
||||
@@ -142,9 +139,6 @@ var bilUsageLogsColumns = BilUsageLogsColumns{
|
||||
BillingSnapshot: "billing_snapshot",
|
||||
ProjectId: "project_id",
|
||||
TaskId: "task_id",
|
||||
TenantPlanId: "tenant_plan_id",
|
||||
PlanDeduction: "plan_deduction",
|
||||
WalletDeduction: "wallet_deduction",
|
||||
}
|
||||
|
||||
// NewBilUsageLogsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -1,85 +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"
|
||||
)
|
||||
|
||||
// MdlModelGroupItemsDao is the data access object for the table mdl_model_group_items.
|
||||
type MdlModelGroupItemsDao 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 MdlModelGroupItemsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// MdlModelGroupItemsColumns defines and stores column names for the table mdl_model_group_items.
|
||||
type MdlModelGroupItemsColumns struct {
|
||||
Id string //
|
||||
GroupId string //
|
||||
ModelName string //
|
||||
CreatedAt string //
|
||||
}
|
||||
|
||||
// mdlModelGroupItemsColumns holds the columns for the table mdl_model_group_items.
|
||||
var mdlModelGroupItemsColumns = MdlModelGroupItemsColumns{
|
||||
Id: "id",
|
||||
GroupId: "group_id",
|
||||
ModelName: "model_name",
|
||||
CreatedAt: "created_at",
|
||||
}
|
||||
|
||||
// NewMdlModelGroupItemsDao creates and returns a new DAO object for table data access.
|
||||
func NewMdlModelGroupItemsDao(handlers ...gdb.ModelHandler) *MdlModelGroupItemsDao {
|
||||
return &MdlModelGroupItemsDao{
|
||||
group: "default",
|
||||
table: "mdl_model_group_items",
|
||||
columns: mdlModelGroupItemsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *MdlModelGroupItemsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *MdlModelGroupItemsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *MdlModelGroupItemsDao) Columns() MdlModelGroupItemsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *MdlModelGroupItemsDao) 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 *MdlModelGroupItemsDao) 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 *MdlModelGroupItemsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -1,91 +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"
|
||||
)
|
||||
|
||||
// MdlModelGroupsDao is the data access object for the table mdl_model_groups.
|
||||
type MdlModelGroupsDao 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 MdlModelGroupsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// MdlModelGroupsColumns defines and stores column names for the table mdl_model_groups.
|
||||
type MdlModelGroupsColumns struct {
|
||||
Id string //
|
||||
Name string //
|
||||
Description string //
|
||||
SortOrder string //
|
||||
Status string //
|
||||
CreatedAt string //
|
||||
UpdatedAt string //
|
||||
}
|
||||
|
||||
// mdlModelGroupsColumns holds the columns for the table mdl_model_groups.
|
||||
var mdlModelGroupsColumns = MdlModelGroupsColumns{
|
||||
Id: "id",
|
||||
Name: "name",
|
||||
Description: "description",
|
||||
SortOrder: "sort_order",
|
||||
Status: "status",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewMdlModelGroupsDao creates and returns a new DAO object for table data access.
|
||||
func NewMdlModelGroupsDao(handlers ...gdb.ModelHandler) *MdlModelGroupsDao {
|
||||
return &MdlModelGroupsDao{
|
||||
group: "default",
|
||||
table: "mdl_model_groups",
|
||||
columns: mdlModelGroupsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *MdlModelGroupsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *MdlModelGroupsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *MdlModelGroupsDao) Columns() MdlModelGroupsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *MdlModelGroupsDao) 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 *MdlModelGroupsDao) 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 *MdlModelGroupsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// ==========================================================================
|
||||
// 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"
|
||||
)
|
||||
|
||||
// PlnFeatureFlagsDao is the data access object for the table pln_feature_flags.
|
||||
type PlnFeatureFlagsDao 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 PlnFeatureFlagsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// PlnFeatureFlagsColumns defines and stores column names for the table pln_feature_flags.
|
||||
type PlnFeatureFlagsColumns struct {
|
||||
Id string // 主键ID
|
||||
FeatureKey string // 功能标识(如 api_docs, export_csv)
|
||||
Description string // 功能描述
|
||||
DefaultEnabled string // 默认是否启用
|
||||
Enabled string // 当前是否启用(计算后的最终值)
|
||||
Source string // 来源:plan(套餐)/ tenant(租户覆盖)/ manual(手动)
|
||||
SourceId string // 来源ID(plan_id 或 tenant_id)
|
||||
TenantId string // 关联租户ID(租户级覆盖时使用)
|
||||
PlanId string // 关联套餐ID(套餐级配置时使用)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// plnFeatureFlagsColumns holds the columns for the table pln_feature_flags.
|
||||
var plnFeatureFlagsColumns = PlnFeatureFlagsColumns{
|
||||
Id: "id",
|
||||
FeatureKey: "feature_key",
|
||||
Description: "description",
|
||||
DefaultEnabled: "default_enabled",
|
||||
Enabled: "enabled",
|
||||
Source: "source",
|
||||
SourceId: "source_id",
|
||||
TenantId: "tenant_id",
|
||||
PlanId: "plan_id",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewPlnFeatureFlagsDao creates and returns a new DAO object for table data access.
|
||||
func NewPlnFeatureFlagsDao(handlers ...gdb.ModelHandler) *PlnFeatureFlagsDao {
|
||||
return &PlnFeatureFlagsDao{
|
||||
group: "default",
|
||||
table: "pln_feature_flags",
|
||||
columns: plnFeatureFlagsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *PlnFeatureFlagsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *PlnFeatureFlagsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *PlnFeatureFlagsDao) Columns() PlnFeatureFlagsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *PlnFeatureFlagsDao) 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 *PlnFeatureFlagsDao) 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 *PlnFeatureFlagsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -21,46 +21,36 @@ type PlnPlansDao struct {
|
||||
|
||||
// PlnPlansColumns defines and stores column names for the table pln_plans.
|
||||
type PlnPlansColumns struct {
|
||||
Id string // 主键ID
|
||||
Name string // 套餐显示名称
|
||||
Identifier string // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description string // 套餐描述(面向用户的营销文案)
|
||||
Price string // 套餐价格(CNY)
|
||||
Status string // 状态:active(上架)/ archived(下架)
|
||||
IsRecommended string // 是否推荐
|
||||
SortOrder string // 排序权重(数字越小越靠前)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
CreditAmount string // 套餐包含的额度(USD)
|
||||
BonusAmount string // 赠送额度(USD)
|
||||
ValidityDays string // 有效天数,从激活时起算
|
||||
PurchaseLimit string // 限购数量,0=不限购
|
||||
PurchaseLimitPeriod string // 限购周期:lifetime/monthly/yearly
|
||||
Stock string // 库存数量,NULL=不限
|
||||
TotalPurchased string // 累计购买次数
|
||||
AllowedModels string // 允许使用的模型列表,空数组=全部模型
|
||||
Id string // 主键ID
|
||||
Name string // 套餐显示名称
|
||||
Identifier string // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description string // 套餐描述(面向用户的营销文案)
|
||||
MonthlyPrice string // 月度价格(CNY)
|
||||
YearlyPrice string // 年度价格(CNY,通常为月价×10)
|
||||
Status string // 状态:active(上架)/ archived(下架)
|
||||
MonthlyQuotaTokens string // 每月 Token 配额(0=不限)
|
||||
AllowedModels string // 允许使用的模型列表(NULL=全部,空数组=无)
|
||||
IsRecommended string // 是否推荐
|
||||
SortOrder string // 排序权重(数字越小越靠前)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// plnPlansColumns holds the columns for the table pln_plans.
|
||||
var plnPlansColumns = PlnPlansColumns{
|
||||
Id: "id",
|
||||
Name: "name",
|
||||
Identifier: "identifier",
|
||||
Description: "description",
|
||||
Price: "price",
|
||||
Status: "status",
|
||||
IsRecommended: "is_recommended",
|
||||
SortOrder: "sort_order",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
CreditAmount: "credit_amount",
|
||||
BonusAmount: "bonus_amount",
|
||||
ValidityDays: "validity_days",
|
||||
PurchaseLimit: "purchase_limit",
|
||||
PurchaseLimitPeriod: "purchase_limit_period",
|
||||
Stock: "stock",
|
||||
TotalPurchased: "total_purchased",
|
||||
AllowedModels: "allowed_models",
|
||||
Id: "id",
|
||||
Name: "name",
|
||||
Identifier: "identifier",
|
||||
Description: "description",
|
||||
MonthlyPrice: "monthly_price",
|
||||
YearlyPrice: "yearly_price",
|
||||
Status: "status",
|
||||
MonthlyQuotaTokens: "monthly_quota_tokens",
|
||||
AllowedModels: "allowed_models",
|
||||
IsRecommended: "is_recommended",
|
||||
SortOrder: "sort_order",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewPlnPlansDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -21,36 +21,36 @@ type PlnTenantPlansDao struct {
|
||||
|
||||
// PlnTenantPlansColumns defines and stores column names for the table pln_tenant_plans.
|
||||
type PlnTenantPlansColumns struct {
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
PlanId string // 套餐ID
|
||||
Status string // 状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)
|
||||
StartAt string // 生效起始时间
|
||||
EndAt string // 到期时间
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
TotalCredits string // 总额度(USD)= credit_amount + bonus_amount
|
||||
RemainingCredits string // 剩余额度(USD)
|
||||
PaidCny string // 实付金额(CNY)
|
||||
RefundedAt string // 退款时间
|
||||
OrderId string // 关联订单ID
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
PlanId string // 套餐ID
|
||||
Status string // 状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)
|
||||
StartAt string // 生效起始时间
|
||||
EndAt string // 到期时间
|
||||
AutoRenew string // 是否自动续费
|
||||
MonthlyQuotaTokens string // 月度 Token 配额快照
|
||||
UsedTokens string // 本月已使用 Token
|
||||
LastResetAt string // 上次配额重置时间
|
||||
CancelledAt string // 取消时间
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// plnTenantPlansColumns holds the columns for the table pln_tenant_plans.
|
||||
var plnTenantPlansColumns = PlnTenantPlansColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
PlanId: "plan_id",
|
||||
Status: "status",
|
||||
StartAt: "start_at",
|
||||
EndAt: "end_at",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
TotalCredits: "total_credits",
|
||||
RemainingCredits: "remaining_credits",
|
||||
PaidCny: "paid_cny",
|
||||
RefundedAt: "refunded_at",
|
||||
OrderId: "order_id",
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
PlanId: "plan_id",
|
||||
Status: "status",
|
||||
StartAt: "start_at",
|
||||
EndAt: "end_at",
|
||||
AutoRenew: "auto_renew",
|
||||
MonthlyQuotaTokens: "monthly_quota_tokens",
|
||||
UsedTokens: "used_tokens",
|
||||
LastResetAt: "last_reset_at",
|
||||
CancelledAt: "cancelled_at",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewPlnTenantPlansDao 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"
|
||||
)
|
||||
|
||||
// mdlModelGroupItemsDao is the data access object for the table mdl_model_group_items.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type mdlModelGroupItemsDao struct {
|
||||
*internal.MdlModelGroupItemsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// MdlModelGroupItems is a globally accessible object for table mdl_model_group_items operations.
|
||||
MdlModelGroupItems = mdlModelGroupItemsDao{internal.NewMdlModelGroupItemsDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -8,15 +8,15 @@ import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// mdlModelGroupsDao is the data access object for the table mdl_model_groups.
|
||||
// plnFeatureFlagsDao is the data access object for the table pln_feature_flags.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type mdlModelGroupsDao struct {
|
||||
*internal.MdlModelGroupsDao
|
||||
type plnFeatureFlagsDao struct {
|
||||
*internal.PlnFeatureFlagsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// MdlModelGroups is a globally accessible object for table mdl_model_groups operations.
|
||||
MdlModelGroups = mdlModelGroupsDao{internal.NewMdlModelGroupsDao()}
|
||||
// PlnFeatureFlags is a globally accessible object for table pln_feature_flags operations.
|
||||
PlnFeatureFlags = plnFeatureFlagsDao{internal.NewPlnFeatureFlagsDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -27,20 +27,14 @@ func HandlePaymentCallback(r *ghttp.Request) {
|
||||
r.Response.Write("success")
|
||||
}
|
||||
|
||||
// HandlePaymentEpayReturn 处理 EPay 支付完成后的浏览器同步跳转(支持 GET/POST)。
|
||||
// 验签成功后尝试完成订单,然后重定向到前端页面。
|
||||
// HandlePaymentEpayReturn 处理 Epay 支付完成后的浏览器同步跳转。
|
||||
func HandlePaymentEpayReturn(r *ghttp.Request) {
|
||||
// 先验签,确保跳转来源可信
|
||||
err := payment.VerifyEpayReturn(r.Context(), r.Request)
|
||||
err := payment.ProcessCallback(r.Context(), r.Request, "epay")
|
||||
if err != nil {
|
||||
r.Response.WriteStatus(302)
|
||||
r.Response.Header().Set("Location", "/console/wallet?pay=fail")
|
||||
return
|
||||
}
|
||||
|
||||
// 验签通过,尝试完成订单(幂等,已处理过的不会重复处理)
|
||||
_ = payment.ProcessCallback(r.Context(), r.Request, "epay")
|
||||
|
||||
r.Response.WriteStatus(302)
|
||||
r.Response.Header().Set("Location", "/console/wallet?pay=success")
|
||||
}
|
||||
@@ -64,8 +64,10 @@ func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.O
|
||||
adminUserID := ctxUserID(ctx)
|
||||
|
||||
var order struct {
|
||||
OrderType string `json:"order_type"`
|
||||
Status string `json:"status"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
Status string `json:"status"`
|
||||
PaymentChannel string `json:"payment_channel"`
|
||||
}
|
||||
err := dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
@@ -77,107 +79,30 @@ func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.O
|
||||
return nil, common.NewBadRequestError("订单状态不支持退款")
|
||||
}
|
||||
|
||||
var refundAmount float64
|
||||
|
||||
switch order.OrderType {
|
||||
case "new_plan":
|
||||
err = payment.RefundPlanOrder(ctx, req.Id, adminUserID, req.Reason)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refundAmount, _, _ = payment.CalculateRefundAmount(ctx, req.Id)
|
||||
|
||||
default:
|
||||
var orderFull struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
PaymentChannel string `json:"payment_channel"`
|
||||
}
|
||||
err := dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Scan(&orderFull)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Data(do.OrdOrders{Status: "refunding"}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dao.OrdRefunds.Ctx(ctx).Insert(do.OrdRefunds{
|
||||
OrderId: req.Id,
|
||||
TenantId: orderFull.TenantID,
|
||||
Amount: orderFull.FinalAmount,
|
||||
Reason: req.Reason,
|
||||
Status: "approved",
|
||||
PaymentChannel: orderFull.PaymentChannel,
|
||||
ApprovedBy: adminUserID,
|
||||
ApprovedAt: gtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refundAmount = orderFull.FinalAmount
|
||||
}
|
||||
|
||||
return &v1.OrderRefundRes{RefundAmount: refundAmount}, nil
|
||||
}
|
||||
|
||||
// OrderRefundPreview 退款预览
|
||||
func (s *sAdmin) OrderRefundPreview(ctx context.Context, req *v1.OrderRefundPreviewReq) (*v1.OrderRefundPreviewRes, error) {
|
||||
var order struct {
|
||||
OrderType string `json:"order_type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.OrdOrders.Ctx(ctx).
|
||||
_, err = dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Scan(&order)
|
||||
Data(do.OrdOrders{
|
||||
Status: "refunding",
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.Status == "" {
|
||||
return nil, common.NewNotFoundError("order")
|
||||
|
||||
_, err = dao.OrdRefunds.Ctx(ctx).Insert(do.OrdRefunds{
|
||||
OrderId: req.Id,
|
||||
TenantId: order.TenantID,
|
||||
Amount: order.FinalAmount,
|
||||
Reason: req.Reason,
|
||||
Status: "approved",
|
||||
PaymentChannel: order.PaymentChannel,
|
||||
ApprovedBy: adminUserID,
|
||||
ApprovedAt: gtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &v1.OrderRefundPreviewRes{
|
||||
OrderType: order.OrderType,
|
||||
OrderStatus: order.Status,
|
||||
}
|
||||
|
||||
if order.Status != "paid" && order.Status != "fulfilled" {
|
||||
res.Message = "订单状态不支持退款"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
switch order.OrderType {
|
||||
case "new_plan":
|
||||
refundCNY, canRefund, err := payment.CalculateRefundAmount(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.CanRefund = canRefund
|
||||
res.RefundAmount = refundCNY
|
||||
if !canRefund {
|
||||
res.Message = "未找到有效的套餐"
|
||||
}
|
||||
|
||||
case "recharge":
|
||||
var orderFull struct {
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
}
|
||||
dao.OrdOrders.Ctx(ctx).Where("id", req.Id).Scan(&orderFull)
|
||||
res.CanRefund = true
|
||||
res.RefundAmount = orderFull.FinalAmount
|
||||
|
||||
default:
|
||||
res.Message = "该订单类型暂不支持在线退款"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
return &v1.OrderRefundRes{}, nil
|
||||
}
|
||||
|
||||
// OrderComplete 手动完成订单
|
||||
|
||||
@@ -2,14 +2,14 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/utility/export"
|
||||
)
|
||||
|
||||
@@ -31,12 +31,6 @@ func (s *sAdmin) ListPlans(ctx context.Context, req *v1.PlanListReq) (*v1.PlanLi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range plans {
|
||||
if item.AllowedModels == nil {
|
||||
item.AllowedModels = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
return &v1.PlanListRes{
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -57,31 +51,22 @@ func (s *sAdmin) GetPlan(ctx context.Context, req *v1.PlanDetailReq) (*v1.PlanDe
|
||||
if plan.Id == 0 {
|
||||
return nil, common.NewNotFoundError("plan")
|
||||
}
|
||||
|
||||
if plan.AllowedModels == nil {
|
||||
plan.AllowedModels = []string{}
|
||||
}
|
||||
|
||||
return &v1.PlanDetailRes{Data: &plan}, nil
|
||||
}
|
||||
|
||||
// CreatePlan 创建套餐
|
||||
func (s *sAdmin) CreatePlan(ctx context.Context, req *v1.PlanCreateReq) (*v1.PlanCreateRes, error) {
|
||||
result, err := dao.PlnPlans.Ctx(ctx).Data(g.Map{
|
||||
"name": req.Name,
|
||||
"identifier": req.Identifier,
|
||||
"description": req.Description,
|
||||
"price": req.Price,
|
||||
"credit_amount": req.CreditAmount,
|
||||
"bonus_amount": req.BonusAmount,
|
||||
"validity_days": req.ValidityDays,
|
||||
"allowed_models": req.AllowedModels,
|
||||
"purchase_limit": req.PurchaseLimit,
|
||||
"purchase_limit_period": req.PurchaseLimitPeriod,
|
||||
"stock": req.Stock,
|
||||
"is_recommended": req.IsRecommended,
|
||||
"sort_order": req.SortOrder,
|
||||
}).Insert()
|
||||
result, err := dao.PlnPlans.Ctx(ctx).Insert(do.PlnPlans{
|
||||
Name: req.Name,
|
||||
Identifier: req.Identifier,
|
||||
Description: req.Description,
|
||||
MonthlyPrice: req.MonthlyPrice,
|
||||
YearlyPrice: req.YearlyPrice,
|
||||
MonthlyQuotaTokens: req.MonthlyQuotaTokens,
|
||||
AllowedModels: req.AllowedModels,
|
||||
IsRecommended: req.IsRecommended,
|
||||
SortOrder: req.SortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -128,13 +113,12 @@ func (s *sAdmin) ArchivePlan(ctx context.Context, req *v1.PlanArchiveReq) (*v1.P
|
||||
|
||||
// ToggleRecommend 切换推荐标记
|
||||
func (s *sAdmin) ToggleRecommend(ctx context.Context, req *v1.PlanToggleRecommendReq) (*v1.PlanToggleRecommendRes, error) {
|
||||
var row struct {
|
||||
IsRecommended bool `orm:"is_recommended"`
|
||||
}
|
||||
// 先查当前值
|
||||
var isRecommended bool
|
||||
err := dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Fields("is_recommended").
|
||||
Scan(&row)
|
||||
Scan(&isRecommended)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +126,7 @@ func (s *sAdmin) ToggleRecommend(ctx context.Context, req *v1.PlanToggleRecommen
|
||||
_, err = dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Data(do.PlnPlans{
|
||||
IsRecommended: !row.IsRecommended,
|
||||
IsRecommended: !isRecommended,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -165,11 +149,10 @@ func (s *sAdmin) ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.Pl
|
||||
{Field: "id", Header: "ID"},
|
||||
{Field: "name", Header: "名称"},
|
||||
{Field: "identifier", Header: "标识"},
|
||||
{Field: "price", Header: "价格(CNY)"},
|
||||
{Field: "monthly_price", Header: "月价"},
|
||||
{Field: "yearly_price", Header: "年价"},
|
||||
{Field: "status", Header: "状态"},
|
||||
{Field: "credit_amount", Header: "额度"},
|
||||
{Field: "bonus_amount", Header: "赠送额度"},
|
||||
{Field: "validity_days", Header: "有效天数"},
|
||||
{Field: "monthly_quota_tokens", Header: "月配额Token"},
|
||||
{Field: "is_recommended", Header: "推荐标记"},
|
||||
{Field: "created_at", Header: "创建时间"},
|
||||
}
|
||||
@@ -180,7 +163,7 @@ func (s *sAdmin) ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.Pl
|
||||
Columns: columns,
|
||||
}
|
||||
|
||||
planFields := "id, name, identifier, price, status, credit_amount, bonus_amount, validity_days, is_recommended, created_at"
|
||||
planFields := "id, name, identifier, monthly_price, yearly_price, status, monthly_quota_tokens, is_recommended, created_at"
|
||||
|
||||
if format == "xlsx" {
|
||||
query := dao.PlnPlans.Ctx(ctx)
|
||||
@@ -188,16 +171,15 @@ func (s *sAdmin) ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.Pl
|
||||
query = query.Where("status", req.Status)
|
||||
}
|
||||
var plans []struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"`
|
||||
CreditAmount float64 `json:"credit_amount"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
Status string `json:"status"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
if err := query.Fields(planFields).OrderAsc("sort_order").Scan(&plans); err != nil {
|
||||
return nil, err
|
||||
@@ -209,16 +191,15 @@ func (s *sAdmin) ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.Pl
|
||||
isRec = "是"
|
||||
}
|
||||
data[i] = map[string]any{
|
||||
"id": p.Id,
|
||||
"name": p.Name,
|
||||
"identifier": p.Identifier,
|
||||
"price": p.Price,
|
||||
"status": p.Status,
|
||||
"credit_amount": p.CreditAmount,
|
||||
"bonus_amount": p.BonusAmount,
|
||||
"validity_days": p.ValidityDays,
|
||||
"is_recommended": isRec,
|
||||
"created_at": p.CreatedAt.String(),
|
||||
"id": p.Id,
|
||||
"name": p.Name,
|
||||
"identifier": p.Identifier,
|
||||
"monthly_price": p.MonthlyPrice,
|
||||
"yearly_price": p.YearlyPrice,
|
||||
"status": p.Status,
|
||||
"monthly_quota_tokens": p.MonthlyQuotaTokens,
|
||||
"is_recommended": isRec,
|
||||
"created_at": p.CreatedAt.String(),
|
||||
}
|
||||
}
|
||||
return nil, export.WriteExcel(r, config, data)
|
||||
@@ -232,16 +213,15 @@ func (s *sAdmin) ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.Pl
|
||||
query = query.Where("status", req.Status)
|
||||
}
|
||||
var batch []struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"`
|
||||
CreditAmount float64 `json:"credit_amount"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
Status string `json:"status"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
if err := query.Fields(planFields).OrderAsc("sort_order").Limit(1000).Offset(offset).Scan(&batch); err != nil {
|
||||
return
|
||||
@@ -252,16 +232,15 @@ func (s *sAdmin) ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.Pl
|
||||
isRec = "是"
|
||||
}
|
||||
if !yield(map[string]any{
|
||||
"id": p.Id,
|
||||
"name": p.Name,
|
||||
"identifier": p.Identifier,
|
||||
"price": p.Price,
|
||||
"status": p.Status,
|
||||
"credit_amount": p.CreditAmount,
|
||||
"bonus_amount": p.BonusAmount,
|
||||
"validity_days": p.ValidityDays,
|
||||
"is_recommended": isRec,
|
||||
"created_at": p.CreatedAt.String(),
|
||||
"id": p.Id,
|
||||
"name": p.Name,
|
||||
"identifier": p.Identifier,
|
||||
"monthly_price": p.MonthlyPrice,
|
||||
"yearly_price": p.YearlyPrice,
|
||||
"status": p.Status,
|
||||
"monthly_quota_tokens": p.MonthlyQuotaTokens,
|
||||
"is_recommended": isRec,
|
||||
"created_at": p.CreatedAt.String(),
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// planCacheTTL 套餐缓存 TTL
|
||||
planCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
// planCache 套餐缓存
|
||||
var planCache = lcommon.NewCache("plan_deduction", planCacheTTL)
|
||||
|
||||
// ActivePlan 租户持有的活跃套餐
|
||||
type ActivePlan struct {
|
||||
ID int64 `json:"id"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
RemainingCredits float64 `json:"remaining_credits"`
|
||||
TotalCredits float64 `json:"total_credits"`
|
||||
EndAt string `json:"end_at"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
}
|
||||
|
||||
// GetActivePlansForModel 查询租户对指定模型可用的活跃套餐(按 end_at ASC,优先消费快过期的)
|
||||
func GetActivePlansForModel(ctx context.Context, tenantID int64, modelName string) ([]*ActivePlan, error) {
|
||||
cacheKey := fmt.Sprintf("%d:%s", tenantID, modelName)
|
||||
var cached []*ActivePlan
|
||||
if planCache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// 查询活跃套餐(allowed_models 为空数组表示全部模型,否则模型必须在列表中)
|
||||
query := g.DB().Model("pln_tenant_plans tp").
|
||||
Ctx(ctx).
|
||||
LeftJoin("pln_plans p", "p.id = tp.plan_id").
|
||||
Where("tp.tenant_id", tenantID).
|
||||
Where("tp.status", "active").
|
||||
Where("tp.remaining_credits > 0").
|
||||
Where("tp.end_at > NOW()")
|
||||
|
||||
if modelName != "" {
|
||||
// allowed_models 为空数组(全部模型)OR 模型在 allowed_models 内
|
||||
query = query.Where("(p.allowed_models = '{}' OR ? = ANY(p.allowed_models))", modelName)
|
||||
}
|
||||
|
||||
var plans []*ActivePlan
|
||||
err := query.Fields("tp.id, tp.plan_id, tp.remaining_credits, tp.total_credits, tp.end_at::text, COALESCE(p.allowed_models, '{}') as allowed_models").
|
||||
Order("tp.end_at ASC").
|
||||
Scan(&plans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plans == nil {
|
||||
plans = []*ActivePlan{}
|
||||
}
|
||||
|
||||
planCache.Set(ctx, cacheKey, plans)
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// TryPreDeductFromPlan 尝试从套餐预扣费用
|
||||
// 返回 (planDeducted 从套餐扣了多少, walletNeeded 还需要钱包扣多少, planID 使用的套餐ID, err)
|
||||
func TryPreDeductFromPlan(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) (planDeducted, walletNeeded float64, planID int64, err error) {
|
||||
if amount <= 0 {
|
||||
return 0, amount, 0, nil
|
||||
}
|
||||
|
||||
plans, err := GetActivePlansForModel(ctx, tenantID, modelName)
|
||||
if err != nil || len(plans) == 0 {
|
||||
return 0, amount, 0, nil
|
||||
}
|
||||
|
||||
// FIFO: 优先扣快过期的(已按 end_at ASC 排序)
|
||||
for _, plan := range plans {
|
||||
if plan.RemainingCredits <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试扣减:使用原子 UPDATE 确保不超扣
|
||||
if plan.RemainingCredits >= amount {
|
||||
// 全额扣套餐
|
||||
result, dbErr := g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE pln_tenant_plans SET remaining_credits = remaining_credits - ?, updated_at = NOW() WHERE id = ? AND remaining_credits >= ? AND status = 'active'",
|
||||
amount, plan.ID, amount)
|
||||
if dbErr != nil {
|
||||
g.Log().Warningf(ctx, "[PLAN] pre-deduct failed for plan %d: %v", plan.ID, dbErr)
|
||||
continue
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
continue // 并发冲突,尝试下一个套餐
|
||||
}
|
||||
|
||||
storePlanDeductToRedis(ctx, requestID, amount, 0, plan.ID)
|
||||
return amount, 0, plan.ID, nil
|
||||
}
|
||||
|
||||
// 部分套餐扣 + 剩余走钱包
|
||||
deduct := plan.RemainingCredits
|
||||
result, dbErr := g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE pln_tenant_plans SET remaining_credits = remaining_credits - ?, updated_at = NOW() WHERE id = ? AND remaining_credits >= ? AND status = 'active'",
|
||||
deduct, plan.ID, deduct)
|
||||
if dbErr != nil {
|
||||
g.Log().Warningf(ctx, "[PLAN] partial pre-deduct failed for plan %d: %v", plan.ID, dbErr)
|
||||
continue
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
remaining := amount - deduct
|
||||
storePlanDeductToRedis(ctx, requestID, deduct, remaining, plan.ID)
|
||||
return deduct, remaining, plan.ID, nil
|
||||
}
|
||||
|
||||
// 所有套餐都不够,全额走钱包
|
||||
return 0, amount, 0, nil
|
||||
}
|
||||
|
||||
// RollbackPlanPreDeduct 回滚套餐预扣(钱包预扣失败时调用)
|
||||
func RollbackPlanPreDeduct(ctx context.Context, tenantID int64, planID int64, amount float64) {
|
||||
if planID <= 0 || amount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE pln_tenant_plans SET remaining_credits = remaining_credits + ?, updated_at = NOW() WHERE id = ? AND status = 'active'",
|
||||
amount, planID)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[PLAN] rollback pre-deduct failed for plan %d: %v", planID, err)
|
||||
}
|
||||
|
||||
clearPlanDeductFromRedis(ctx, "rollback_"+fmt.Sprintf("%d", planID))
|
||||
}
|
||||
|
||||
// SettlePlanDeduction 在结算事务中处理套餐扣费
|
||||
// 返回套餐实际扣费金额(可能小于预扣,差额退还到 remaining_credits)
|
||||
func SettlePlanDeduction(ctx context.Context, tx gdb.TX, planID int64, planPreDeducted, actualCost float64) (planActual float64) {
|
||||
if planID <= 0 || planPreDeducted <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 套餐实际扣费 = min(actualCost, planPreDeducted)
|
||||
planActual = actualCost
|
||||
if planActual > planPreDeducted {
|
||||
planActual = planPreDeducted
|
||||
}
|
||||
|
||||
// 退还差额到 remaining_credits
|
||||
refund := planPreDeducted - planActual
|
||||
if refund > 0 {
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE pln_tenant_plans SET remaining_credits = remaining_credits + ?, updated_at = NOW() WHERE id = ?",
|
||||
refund, planID)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[PLAN] settle refund to plan %d failed: %v", planID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return planActual
|
||||
}
|
||||
|
||||
// GetPreDeductSplit 读取预扣时的套餐/钱包拆分
|
||||
func GetPreDeductSplit(ctx context.Context, requestID string) (planAmount, walletAmount float64, planID int64) {
|
||||
predeductKey := PreDeductRedisKeyPrefix + requestID
|
||||
|
||||
planVal, err := g.Redis().Do(ctx, "HGET", predeductKey, "plan_amount")
|
||||
if err == nil && !planVal.IsNil() {
|
||||
planAmount = planVal.Float64()
|
||||
}
|
||||
|
||||
walletVal, err := g.Redis().Do(ctx, "HGET", predeductKey, "wallet_amount")
|
||||
if err == nil && !walletVal.IsNil() {
|
||||
walletAmount = walletVal.Float64()
|
||||
}
|
||||
|
||||
planIDVal, err := g.Redis().Do(ctx, "HGET", predeductKey, "plan_id")
|
||||
if err == nil && !planIDVal.IsNil() {
|
||||
planID = planIDVal.Int64()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// storePlanDeductToRedis 将套餐扣费拆分存入 Redis prededuct hash
|
||||
func storePlanDeductToRedis(ctx context.Context, requestID string, planAmount, walletAmount float64, planID int64) {
|
||||
predeductKey := PreDeductRedisKeyPrefix + requestID
|
||||
g.Redis().Do(ctx, "HSET", predeductKey, "plan_amount", planAmount)
|
||||
g.Redis().Do(ctx, "HSET", predeductKey, "wallet_amount", walletAmount)
|
||||
g.Redis().Do(ctx, "HSET", predeductKey, "plan_id", planID)
|
||||
}
|
||||
|
||||
// clearPlanDeductFromRedis 清除套餐扣费拆分(失败回滚时)
|
||||
func clearPlanDeductFromRedis(ctx context.Context, requestID string) {
|
||||
// 不删除整个 key,只清除套餐相关字段
|
||||
predeductKey := PreDeductRedisKeyPrefix + requestID
|
||||
g.Redis().Do(ctx, "HDEL", predeductKey, "plan_amount")
|
||||
g.Redis().Do(ctx, "HDEL", predeductKey, "wallet_amount")
|
||||
g.Redis().Do(ctx, "HDEL", predeductKey, "plan_id")
|
||||
}
|
||||
|
||||
// InvalidatePlanCache 失效租户套餐缓存
|
||||
func InvalidatePlanCache(ctx context.Context, tenantID int64) {
|
||||
// 删除该租户所有模型相关的套餐缓存
|
||||
planCache.DeleteByPattern(ctx, fmt.Sprintf("%d:*", tenantID))
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package billing
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
@@ -22,28 +20,9 @@ func (b *BillingProviderImpl) PreDeduct(ctx context.Context, tenantID int64, mod
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 尝试从套餐预扣(FIFO,优先快过期的)
|
||||
planDeducted, walletNeeded, planID, planErr := TryPreDeductFromPlan(ctx, tenantID, amount, requestID, modelName)
|
||||
if planErr != nil {
|
||||
g.Log().Warningf(ctx, "[PLAN] plan pre-deduct failed, fallback to wallet: %v", planErr)
|
||||
walletNeeded = amount
|
||||
}
|
||||
|
||||
// 套餐扣了部分或全部,但还需要钱包补足
|
||||
if walletNeeded > 0 {
|
||||
ok, walletErr := PreDeduct(ctx, tenantID, walletNeeded, requestID, modelName)
|
||||
if !ok {
|
||||
// 钱包不足,回滚套餐扣费
|
||||
if planDeducted > 0 && planID > 0 {
|
||||
RollbackPlanPreDeduct(ctx, tenantID, planID, planDeducted)
|
||||
}
|
||||
return 0, walletErr
|
||||
}
|
||||
}
|
||||
|
||||
// 失效套餐缓存
|
||||
if planDeducted > 0 {
|
||||
InvalidatePlanCache(ctx, tenantID)
|
||||
ok, err := PreDeduct(ctx, tenantID, amount, requestID, modelName)
|
||||
if !ok {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return amount, nil
|
||||
@@ -91,9 +70,6 @@ func (b *BillingProviderImpl) SettleWithUsage(ctx context.Context, tenantID, use
|
||||
BillingMode: result.BillingMode,
|
||||
BillingSource: result.BillingSource,
|
||||
RateMultiplier: result.RateMultiplier,
|
||||
PlanID: result.PlanID,
|
||||
PlanDeduction: result.PlanDeduction,
|
||||
WalletDeduction: result.WalletDeduction,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ type SettlementResult struct {
|
||||
BillingMode string // 计费模式
|
||||
BillingSource string // 定价来源
|
||||
RateMultiplier float64 // 费率倍率
|
||||
PlanID int64 // 扣费套餐 ID
|
||||
PlanDeduction float64 // 套餐扣费金额
|
||||
WalletDeduction float64 // 钱套餐扣费金额
|
||||
}
|
||||
|
||||
// Settle 结算请求费用
|
||||
@@ -49,26 +46,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
}
|
||||
actualCost := breakdown.TotalCost
|
||||
|
||||
// 2. 读取预扣拆分(包/钱包)
|
||||
pkgPreDeducted, walletPreDeducted, packageID := GetPreDeductSplit(ctx, requestID)
|
||||
// 兼容旧流程(无包扣费时,全走钱包)
|
||||
if pkgPreDeducted == 0 && walletPreDeducted == 0 {
|
||||
walletPreDeducted = preDeductAmount
|
||||
}
|
||||
|
||||
// 3. 计算包/钱包各自的实际扣费
|
||||
var pkgActual, walletActual float64
|
||||
if pkgPreDeducted > 0 {
|
||||
pkgActual = actualCost
|
||||
if pkgActual > pkgPreDeducted {
|
||||
pkgActual = pkgPreDeducted
|
||||
}
|
||||
walletActual = actualCost - pkgActual
|
||||
} else {
|
||||
walletActual = actualCost
|
||||
}
|
||||
|
||||
// 4. 计算差额
|
||||
// 2. 计算差额
|
||||
var refundAmt, supplementAmt float64
|
||||
if preDeductAmount > actualCost {
|
||||
refundAmt = preDeductAmount - actualCost
|
||||
@@ -76,36 +54,29 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
supplementAmt = actualCost - preDeductAmount
|
||||
}
|
||||
|
||||
// 5. 获取钱包
|
||||
// 3. 获取钱包
|
||||
wallet, err := GetWallet(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "settle: get wallet")
|
||||
}
|
||||
|
||||
// 6. 获取定价信息(事务外只读)
|
||||
// 4. 获取定价信息(事务外只读)
|
||||
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
|
||||
// 7. 事务内执行结算
|
||||
// 5. 事务内执行结算(钱包扣款 + 计费记录)
|
||||
var balanceAfter, frozenAfter float64
|
||||
var billingID int64
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 7a. 结算包扣费(退还差额到 remaining_credits)
|
||||
if pkgPreDeducted > 0 && packageID > 0 {
|
||||
SettlePlanDeduction(ctx, tx, packageID, pkgPreDeducted, pkgActual)
|
||||
// 5a. 更新钱包
|
||||
now := time.Now()
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
|
||||
preDeductAmount, actualCost, now, wallet.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "settle: update wallet")
|
||||
}
|
||||
|
||||
// 7b. 更新钱包(仅 wallet 部分)
|
||||
if walletPreDeducted > 0 {
|
||||
now := time.Now()
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
|
||||
walletPreDeducted, walletActual, now, wallet.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "settle: update wallet")
|
||||
}
|
||||
}
|
||||
|
||||
// 7c. 事务内读取准确余额
|
||||
// 5b. 事务内读取准确余额
|
||||
type balRow struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
@@ -120,7 +91,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
frozenAfter = br.FrozenBalance
|
||||
}
|
||||
|
||||
// 7d. 创建计费记录
|
||||
// 5c. 创建计费记录
|
||||
var inputSnapPrice, outputSnapPrice float64
|
||||
var billingMode string
|
||||
var discountRatio, billingInputMult, billingOutputMult float64
|
||||
@@ -141,32 +112,19 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
return gerror.Wrapf(err, "settle: create billing record")
|
||||
}
|
||||
|
||||
// 更新计费记录的包扣费字段
|
||||
if packageID > 0 || pkgActual > 0 || walletActual > 0 {
|
||||
_, err = tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_records SET tenant_plan_id = ?, plan_deduction = ?, wallet_deduction = ? WHERE id = ?",
|
||||
packageID, pkgActual, walletActual, billingID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "settle: update billing record plan fields failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. 清除缓存(事务提交后)
|
||||
// 6. 清除缓存(事务提交后)
|
||||
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
|
||||
InvalidateWalletRedis(ctx, tenantID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID)
|
||||
markPredeductSettled(ctx, requestID)
|
||||
if packageID > 0 {
|
||||
InvalidatePlanCache(ctx, tenantID)
|
||||
}
|
||||
|
||||
// 9. 记录消费流水(事务外,best-effort)
|
||||
// 7. 记录消费流水(事务外,best-effort)
|
||||
recordTransaction(ctx, wallet.ID, tenantID, "consume", -actualCost,
|
||||
fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.6f actual=%.6f", requestID, modelName, inputTokens, outputTokens, preDeductAmount, actualCost),
|
||||
userID, requestID, modelName, billingID, "billing_record", projectID, apiKeyID, "", balanceAfter, frozenAfter)
|
||||
@@ -178,9 +136,6 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
RefundAmount: refundAmt,
|
||||
SupplementAmount: supplementAmt,
|
||||
BillingRecordID: billingID,
|
||||
PlanID: packageID,
|
||||
PlanDeduction: pkgActual,
|
||||
WalletDeduction: walletActual,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -197,25 +152,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
}
|
||||
actualCost := breakdown.TotalCost
|
||||
|
||||
// 2. 读取预扣拆分(包/钱包)
|
||||
pkgPreDeducted, walletPreDeducted, packageID := GetPreDeductSplit(ctx, requestID)
|
||||
if pkgPreDeducted == 0 && walletPreDeducted == 0 {
|
||||
walletPreDeducted = preDeductAmount
|
||||
}
|
||||
|
||||
// 3. 计算包/钱包各自的实际扣费
|
||||
var pkgActual, walletActual float64
|
||||
if pkgPreDeducted > 0 {
|
||||
pkgActual = actualCost
|
||||
if pkgActual > pkgPreDeducted {
|
||||
pkgActual = pkgPreDeducted
|
||||
}
|
||||
walletActual = actualCost - pkgActual
|
||||
} else {
|
||||
walletActual = actualCost
|
||||
}
|
||||
|
||||
// 4. 计算差额
|
||||
// 2. 计算差额
|
||||
var refundAmt, supplementAmt float64
|
||||
if preDeductAmount > actualCost {
|
||||
refundAmt = preDeductAmount - actualCost
|
||||
@@ -223,36 +160,29 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
supplementAmt = actualCost - preDeductAmount
|
||||
}
|
||||
|
||||
// 5. 获取钱包
|
||||
// 3. 获取钱包
|
||||
wallet, err := GetWallet(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "settle_with_usage: get wallet")
|
||||
}
|
||||
|
||||
// 6. 获取定价信息(事务外只读)
|
||||
// 4. 获取定价信息(事务外只读)
|
||||
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
|
||||
// 7. 事务内执行结算
|
||||
// 5. 事务内执行结算(钱包扣款 + 计费记录)
|
||||
var balanceAfter, frozenAfter float64
|
||||
var billingID int64
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 7a. 结算包扣费
|
||||
if pkgPreDeducted > 0 && packageID > 0 {
|
||||
SettlePlanDeduction(ctx, tx, packageID, pkgPreDeducted, pkgActual)
|
||||
// 5a. 更新钱包
|
||||
now := time.Now()
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
|
||||
preDeductAmount, actualCost, now, wallet.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "settle_with_usage: update wallet")
|
||||
}
|
||||
|
||||
// 7b. 更新钱包(仅 wallet 部分)
|
||||
if walletPreDeducted > 0 {
|
||||
now := time.Now()
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
|
||||
walletPreDeducted, walletActual, now, wallet.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "settle_with_usage: update wallet")
|
||||
}
|
||||
}
|
||||
|
||||
// 7c. 事务内读取准确余额
|
||||
// 5b. 事务内读取准确余额
|
||||
type balRow struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
@@ -267,7 +197,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
frozenAfter = br.FrozenBalance
|
||||
}
|
||||
|
||||
// 7d. 创建计费记录(含快照)
|
||||
// 5c. 创建计费记录(含快照)
|
||||
var inputSnapPrice, outputSnapPrice float64
|
||||
var billingMode string
|
||||
var discountRatio, billingInputMult, billingOutputMult float64
|
||||
@@ -288,31 +218,17 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
return gerror.Wrapf(err, "settle_with_usage: create billing record")
|
||||
}
|
||||
|
||||
// 更新计费记录的包扣费字段
|
||||
if packageID > 0 || pkgActual > 0 || walletActual > 0 {
|
||||
_, err = tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_records SET tenant_plan_id = ?, plan_deduction = ?, wallet_deduction = ? WHERE id = ?",
|
||||
packageID, pkgActual, walletActual, billingID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "settle_with_usage: update billing record plan fields failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. 清除缓存(事务提交后)
|
||||
// 6. 清除缓存(事务提交后)
|
||||
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
|
||||
InvalidateWalletRedis(ctx, tenantID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID)
|
||||
markPredeductSettled(ctx, requestID)
|
||||
if packageID > 0 {
|
||||
InvalidatePlanCache(ctx, tenantID)
|
||||
}
|
||||
|
||||
settlementResult := &SettlementResult{
|
||||
PreDeductAmount: preDeductAmount,
|
||||
ActualCost: actualCost,
|
||||
@@ -321,9 +237,6 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
SupplementAmount: supplementAmt,
|
||||
BillingRecordID: billingID,
|
||||
CostBreakdown: breakdown,
|
||||
PlanID: packageID,
|
||||
PlanDeduction: pkgActual,
|
||||
WalletDeduction: walletActual,
|
||||
}
|
||||
|
||||
var snapshotJSON, summaryText string
|
||||
@@ -341,13 +254,13 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
settlementResult.BillingSnapshot = snapshotJSON
|
||||
settlementResult.BillingSummary = summaryText
|
||||
|
||||
// 9. 从 relayInfo 提取 projectID
|
||||
// 8. 从 relayInfo 提取 projectID
|
||||
var txProjectID int64
|
||||
if relayInfo != nil {
|
||||
txProjectID = relayInfo.ProjectID
|
||||
}
|
||||
|
||||
// 10. 记录消费流水(事务外,best-effort)
|
||||
// 9. 记录消费流水(事务外,best-effort)
|
||||
recordTransaction(ctx, wallet.ID, tenantID, "consume", -actualCost,
|
||||
fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.6f actual=%.6f", requestID, modelName, breakdown.InputTokens, breakdown.OutputTokens, preDeductAmount, actualCost),
|
||||
userID, requestID, modelName, billingID, "billing_record", txProjectID, apiKeyID, "", balanceAfter, frozenAfter)
|
||||
@@ -357,27 +270,11 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
|
||||
// SettleFailed 失败请求结算:退还预扣金额
|
||||
func SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error {
|
||||
// 读取预扣拆分(包/钱包)
|
||||
pkgPreDeducted, walletPreDeducted, packageID := GetPreDeductSplit(ctx, requestID)
|
||||
if pkgPreDeducted == 0 && walletPreDeducted == 0 {
|
||||
walletPreDeducted = preDeductAmount
|
||||
}
|
||||
|
||||
// 回滚包扣费
|
||||
if pkgPreDeducted > 0 && packageID > 0 {
|
||||
RollbackPlanPreDeduct(ctx, tenantID, packageID, pkgPreDeducted)
|
||||
InvalidatePlanCache(ctx, tenantID)
|
||||
}
|
||||
|
||||
// 解冻钱包预扣金额
|
||||
if walletPreDeducted > 0 {
|
||||
UnfreezePreDeduct(ctx, tenantID, requestID, walletPreDeducted)
|
||||
} else {
|
||||
// 兼容旧流程:无拆分记录时解冻全额
|
||||
UnfreezePreDeduct(ctx, tenantID, requestID, preDeductAmount)
|
||||
}
|
||||
// 解冻预扣金额
|
||||
UnfreezePreDeduct(ctx, tenantID, requestID, preDeductAmount)
|
||||
markPredeductReleased(ctx, requestID)
|
||||
|
||||
// 无需额外操作,预扣金额原路退回
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@ type BillingSnapshotSettlement struct {
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
SupplementAmount float64 `json:"supplement_amount"`
|
||||
PlanID int64 `json:"plan_id,omitempty"`
|
||||
PlanDeduction float64 `json:"plan_deduction,omitempty"`
|
||||
WalletDeduction float64 `json:"wallet_deduction,omitempty"`
|
||||
}
|
||||
|
||||
// BillingSnapshotRequestMeta 请求元信息
|
||||
@@ -113,9 +110,6 @@ func GenerateBillingSnapshot(
|
||||
ActualCost: settlement.ActualCost,
|
||||
RefundAmount: settlement.RefundAmount,
|
||||
SupplementAmount: settlement.SupplementAmount,
|
||||
PlanID: settlement.PlanID,
|
||||
PlanDeduction: settlement.PlanDeduction,
|
||||
WalletDeduction: settlement.WalletDeduction,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,27 +69,10 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID
|
||||
|
||||
// PreDeductTask 预扣任务费用
|
||||
func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error) {
|
||||
// 尝试从资源包预扣
|
||||
pkgDeducted, walletNeeded, packageID, pkgErr := TryPreDeductFromPlan(ctx, tenantID, estimatedCost, requestID, modelName)
|
||||
if pkgErr != nil {
|
||||
g.Log().Warningf(ctx, "[PKG] task package pre-deduct failed, fallback to wallet: %v", pkgErr)
|
||||
walletNeeded = estimatedCost
|
||||
ok, err := PreDeduct(ctx, tenantID, estimatedCost, requestID, modelName)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("pre-deduct task failed: %w", err)
|
||||
}
|
||||
|
||||
if walletNeeded > 0 {
|
||||
ok, err := PreDeduct(ctx, tenantID, walletNeeded, requestID, modelName)
|
||||
if !ok {
|
||||
if pkgDeducted > 0 && packageID > 0 {
|
||||
RollbackPlanPreDeduct(ctx, tenantID, packageID, pkgDeducted)
|
||||
}
|
||||
return 0, fmt.Errorf("pre-deduct task failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if pkgDeducted > 0 {
|
||||
InvalidatePlanCache(ctx, tenantID)
|
||||
}
|
||||
|
||||
return estimatedCost, nil
|
||||
}
|
||||
|
||||
@@ -99,24 +82,6 @@ func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID in
|
||||
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*common.SettlementResult, error) {
|
||||
diff := actualCost - preDeductAmount
|
||||
|
||||
// 读取预扣拆分(包/钱包)
|
||||
pkgPreDeducted, walletPreDeducted, packageID := GetPreDeductSplit(ctx, requestID)
|
||||
if pkgPreDeducted == 0 && walletPreDeducted == 0 {
|
||||
walletPreDeducted = preDeductAmount
|
||||
}
|
||||
|
||||
// 计算包/钱包各自的实际扣费
|
||||
var pkgActual, walletActual float64
|
||||
if pkgPreDeducted > 0 {
|
||||
pkgActual = actualCost
|
||||
if pkgActual > pkgPreDeducted {
|
||||
pkgActual = pkgPreDeducted
|
||||
}
|
||||
walletActual = actualCost - pkgActual
|
||||
} else {
|
||||
walletActual = actualCost
|
||||
}
|
||||
|
||||
// 1. 获取钱包
|
||||
wallet, err := GetWallet(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -139,23 +104,16 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
var balanceAfter, frozenAfter float64
|
||||
var taskBillingID int64
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 3a. 结算包扣费
|
||||
if pkgPreDeducted > 0 && packageID > 0 {
|
||||
SettlePlanDeduction(ctx, tx, packageID, pkgPreDeducted, pkgActual)
|
||||
// 3a. 更新钱包
|
||||
now := time.Now()
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
|
||||
preDeductAmount, actualCost, now, wallet.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("settle task: update wallet: %w", err)
|
||||
}
|
||||
|
||||
// 3b. 更新钱包(仅 wallet 部分)
|
||||
if walletPreDeducted > 0 {
|
||||
now := time.Now()
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
|
||||
walletPreDeducted, walletActual, now, wallet.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("settle task: update wallet: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. 事务内读取准确余额
|
||||
// 3b. 事务内读取准确余额
|
||||
type balRow struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
@@ -170,7 +128,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
frozenAfter = br.FrozenBalance
|
||||
}
|
||||
|
||||
// 3d. 创建计费记录
|
||||
// 3c. 创建计费记录
|
||||
var billingResult sql.Result
|
||||
billingResult, err = tx.Model("bil_records").Ctx(ctx).Data(do.BilRecords{
|
||||
TenantId: tenantID,
|
||||
@@ -211,16 +169,6 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
taskBillingID, _ = billingResult.LastInsertId()
|
||||
}
|
||||
|
||||
// 更新计费记录的包扣费字段
|
||||
if packageID > 0 || pkgActual > 0 || walletActual > 0 {
|
||||
_, err = tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_records SET tenant_package_id = ?, package_deduction = ?, wallet_deduction = ? WHERE id = ?",
|
||||
packageID, pkgActual, walletActual, taskBillingID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "settle task: update billing record package fields failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -232,9 +180,6 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
InvalidateWalletRedis(ctx, tenantID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID+"_adjust")
|
||||
if packageID > 0 {
|
||||
InvalidatePlanCache(ctx, tenantID)
|
||||
}
|
||||
|
||||
// 5. 差额处理:实际 < 预扣时退还差额(实际 > 预扣时已在步骤 3 扣完,无需额外操作)
|
||||
if diff < -0.001 {
|
||||
@@ -250,9 +195,6 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
BaseCost: breakdown.BaseCost,
|
||||
TotalCost: actualCost,
|
||||
OutputCost: breakdown.OutputCost,
|
||||
PlanID: packageID,
|
||||
PlanDeduction: pkgActual,
|
||||
WalletDeduction: walletActual,
|
||||
}
|
||||
|
||||
if diff > 0.001 {
|
||||
@@ -268,9 +210,6 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
BaseCost: breakdown.BaseCost,
|
||||
RefundAmount: result.RefundAmount,
|
||||
SupplementAmount: result.SupplementAmount,
|
||||
PlanID: packageID,
|
||||
PlanDeduction: pkgActual,
|
||||
WalletDeduction: walletActual,
|
||||
}
|
||||
snapshot := GenerateBillingSnapshot(pricing, breakdown, nil, internalSettlement, nil)
|
||||
snapshot.RequestMeta.RequestedModel = modelName
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcache"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// Cache provides a two-level caching mechanism:
|
||||
@@ -153,9 +154,38 @@ func (c *Cache) Delete(ctx context.Context, key string) {
|
||||
}
|
||||
|
||||
// DeleteByPattern removes all cache entries matching the pattern.
|
||||
// Uses SCAN + DEL to properly handle wildcard patterns (Redis DEL does not support wildcards).
|
||||
func (c *Cache) DeleteByPattern(ctx context.Context, pattern string) {
|
||||
fullPattern := c.fullKey(pattern)
|
||||
_, _ = g.Redis().Do(ctx, "DEL", fullPattern)
|
||||
|
||||
// L2: Redis — SCAN matching keys and DEL them
|
||||
cursor := int64(0)
|
||||
for {
|
||||
result, err := g.Redis().Do(ctx, "SCAN", cursor, "MATCH", fullPattern, "COUNT", 100)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[Cache] SCAN failed pattern=%s: %v", fullPattern, err)
|
||||
break
|
||||
}
|
||||
slice := result.Slice()
|
||||
if len(slice) < 2 {
|
||||
break
|
||||
}
|
||||
cursor = gconv.Int64(slice[0])
|
||||
keys := gconv.Strings(slice[1])
|
||||
if len(keys) > 0 {
|
||||
delArgs := make([]any, len(keys))
|
||||
for i, k := range keys {
|
||||
delArgs[i] = k
|
||||
}
|
||||
_, _ = g.Redis().Do(ctx, "DEL", delArgs...)
|
||||
}
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// L1: memory cache — gcache doesn't support pattern delete,
|
||||
// but entries will expire via TTL anyway
|
||||
}
|
||||
|
||||
// PublishInvalidation publishes a cache invalidation message via Redis Pub/Sub.
|
||||
|
||||
@@ -1,9 +1,62 @@
|
||||
package common
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
)
|
||||
|
||||
// IsFeatureEnabled 检查功能是否启用
|
||||
// 功能开关已移除,所有功能默认启用
|
||||
// 优先级:租户覆盖 > 套餐 > 默认
|
||||
func IsFeatureEnabled(ctx context.Context, tenantID int64, featureKey string) bool {
|
||||
return true
|
||||
// 1. 租户级覆盖
|
||||
if tenantID > 0 {
|
||||
var flag struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
err := dao.PlnFeatureFlags.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("feature_key", featureKey).
|
||||
Where("source", "tenant").
|
||||
Scan(&flag)
|
||||
if err == nil && flag.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查租户当前套餐
|
||||
if tenantID > 0 {
|
||||
var planID int64
|
||||
err := dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Fields("plan_id").
|
||||
Limit(1).
|
||||
Scan(&planID)
|
||||
if err == nil && planID > 0 {
|
||||
var flag struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
err = dao.PlnFeatureFlags.Ctx(ctx).
|
||||
Where("plan_id", planID).
|
||||
Where("feature_key", featureKey).
|
||||
Scan(&flag)
|
||||
if err == nil && flag.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认值
|
||||
var flag struct {
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
}
|
||||
err := dao.PlnFeatureFlags.Ctx(ctx).
|
||||
Where("feature_key", featureKey).
|
||||
Where("source", "manual").
|
||||
Limit(1).
|
||||
Scan(&flag)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return flag.DefaultEnabled
|
||||
}
|
||||
@@ -85,9 +85,10 @@ func ProcessCallback(ctx context.Context, r *http.Request, channelType string) e
|
||||
|
||||
// 5. 幂等检查:仅处理 pending 状态
|
||||
var order *struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
ExpiredAt *gtime.Time `json:"expired_at"`
|
||||
}
|
||||
err = dao.OrdOrders.Ctx(ctx).
|
||||
Where("order_no", result.OrderNo).Scan(&order)
|
||||
@@ -100,6 +101,9 @@ func ProcessCallback(ctx context.Context, r *http.Request, channelType string) e
|
||||
if order.Status != "pending" {
|
||||
return nil // 已处理,幂等返回
|
||||
}
|
||||
if order.ExpiredAt != nil && !order.ExpiredAt.IsZero() && order.ExpiredAt.Before(gtime.Now()) {
|
||||
return gerror.Newf("订单已过期: %s", result.OrderNo)
|
||||
}
|
||||
|
||||
// 6. 金额校验:回调金额与订单金额必须一致(容差 0.01 元)
|
||||
if result.Success && result.PaidAmount > 0 && order.FinalAmount > 0 {
|
||||
|
||||
@@ -152,40 +152,3 @@ func ReadBody(r *http.Request) ([]byte, error) {
|
||||
defer r.Body.Close()
|
||||
return io.ReadAll(r.Body)
|
||||
}
|
||||
|
||||
// VerifyEpayReturn 验证 EPay 浏览器同步跳转的签名,防止伪造成功跳转。
|
||||
func VerifyEpayReturn(ctx context.Context, r *http.Request) error {
|
||||
cfg, err := GetChannelConfigAndProvider(ctx, "epay")
|
||||
if err != nil {
|
||||
return fmt.Errorf("易支付配置加载失败: %w", err)
|
||||
}
|
||||
epayCfg, ok := cfg.(*EpayConfig)
|
||||
if !ok {
|
||||
return fmt.Errorf("易支付配置类型错误")
|
||||
}
|
||||
|
||||
if epayCfg.MerchantKey == "" {
|
||||
return fmt.Errorf("易支付商户密钥未配置")
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
params := make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 && k != "sign" && k != "sign_type" && v[0] != "" {
|
||||
params[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
receivedSign := r.FormValue("sign")
|
||||
expectedSign := epaySign(params, epayCfg.MerchantKey)
|
||||
if receivedSign != expectedSign {
|
||||
return fmt.Errorf("易支付签名验证失败")
|
||||
}
|
||||
|
||||
tradeStatus := r.FormValue("trade_status")
|
||||
if tradeStatus != "TRADE_SUCCESS" {
|
||||
return fmt.Errorf("交易状态非成功: %s", tradeStatus)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package payment
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// FulfillOrder 履约订单
|
||||
@@ -35,10 +35,15 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
|
||||
}
|
||||
|
||||
switch order.OrderType {
|
||||
case "new_plan":
|
||||
err = fulfillNewPlan(ctx, order.TenantID, orderID, order.PlanID, order.FinalAmount)
|
||||
case "new_plan", "renew", "upgrade":
|
||||
months := 1
|
||||
if order.OrderType == "renew" {
|
||||
// 默认续费 1 个月,可根据需求从订单描述中解析
|
||||
months = 1
|
||||
}
|
||||
err = SubscribePlan(ctx, order.TenantID, order.PlanID, months, true)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "fulfill new plan failed")
|
||||
return gerror.Wrapf(err, "subscribe plan failed")
|
||||
}
|
||||
|
||||
case "recharge":
|
||||
@@ -63,70 +68,54 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// fulfillNewPlan 购买套餐履约
|
||||
func fulfillNewPlan(ctx context.Context, tenantID int64, orderID int64, planID int64, paidCNY float64) error {
|
||||
// SubscribePlan 订阅套餐(内部函数,被订单履约、自动续费调用)
|
||||
func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int, autoRenew bool) error {
|
||||
// 查套餐信息
|
||||
var plan struct {
|
||||
Id int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreditAmount float64 `json:"credit_amount"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
Stock int `json:"stock"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
|
||||
}
|
||||
err := dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", planID).
|
||||
Where("status", "active").
|
||||
Scan(&plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if plan.Status != "active" {
|
||||
return gerror.New("plan is not active")
|
||||
|
||||
if months <= 0 {
|
||||
months = 1
|
||||
}
|
||||
|
||||
totalCredits := plan.CreditAmount + plan.BonusAmount
|
||||
now := gtime.Now()
|
||||
endAt := now.AddDate(0, months, 0)
|
||||
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
now := gtime.Now()
|
||||
endAt := now.AddDate(0, 0, plan.ValidityDays)
|
||||
// 先取消当前活跃订阅
|
||||
_, _ = dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Data(do.PlnTenantPlans{
|
||||
Status: "expired",
|
||||
CancelledAt: gtime.Now(),
|
||||
}).Update()
|
||||
|
||||
// 扣减库存、累加已购次数
|
||||
if plan.Stock > 0 {
|
||||
result, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE pln_plans SET stock = stock - 1, total_purchased = total_purchased + 1, updated_at = NOW() WHERE id = ? AND stock > 0",
|
||||
plan.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return gerror.New("plan sold out")
|
||||
}
|
||||
} else {
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE pln_plans SET total_purchased = total_purchased + 1, updated_at = NOW() WHERE id = ?",
|
||||
plan.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 插入租户套餐记录
|
||||
_, err = tx.Ctx(ctx).Model("pln_tenant_plans").Insert(do.PlnTenantPlans{
|
||||
TenantId: tenantID,
|
||||
PlanId: planID,
|
||||
Status: "active",
|
||||
StartAt: now,
|
||||
EndAt: endAt,
|
||||
TotalCredits: totalCredits,
|
||||
RemainingCredits: totalCredits,
|
||||
PaidCny: paidCNY,
|
||||
OrderId: orderID,
|
||||
})
|
||||
return err
|
||||
_, err = dao.PlnTenantPlans.Ctx(ctx).Insert(do.PlnTenantPlans{
|
||||
TenantId: tenantID,
|
||||
PlanId: planID,
|
||||
Status: "active",
|
||||
StartAt: now,
|
||||
EndAt: endAt,
|
||||
AutoRenew: autoRenew,
|
||||
MonthlyQuotaTokens: plan.MonthlyQuotaTokens,
|
||||
UsedTokens: 0,
|
||||
LastResetAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// creditWallet 钱包入账
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"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"
|
||||
)
|
||||
|
||||
// CalculateRefundAmount 计算套餐订单的可退金额
|
||||
// 公式:refund = paid_cny × (remaining_credits / total_credits)
|
||||
func CalculateRefundAmount(ctx context.Context, orderID int64) (refundCNY float64, canRefund bool, err error) {
|
||||
var tenantPlan struct {
|
||||
Id int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
TotalCredits float64 `json:"total_credits"`
|
||||
RemainingCredits float64 `json:"remaining_credits"`
|
||||
PaidCny float64 `json:"paid_cny"`
|
||||
}
|
||||
err = dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("order_id", orderID).
|
||||
Scan(&tenantPlan)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if tenantPlan.Id == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
if tenantPlan.Status != "active" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
if tenantPlan.TotalCredits <= 0 || tenantPlan.PaidCny <= 0 {
|
||||
return 0, true, nil
|
||||
}
|
||||
|
||||
refundCNY = tenantPlan.PaidCny * (tenantPlan.RemainingCredits / tenantPlan.TotalCredits)
|
||||
// 向下取整到分
|
||||
refundCNY = math.Floor(refundCNY*100) / 100
|
||||
|
||||
return refundCNY, true, nil
|
||||
}
|
||||
|
||||
// RefundPlanOrder 执行套餐订单退款
|
||||
func RefundPlanOrder(ctx context.Context, orderID int64, adminUserID int64, reason string) error {
|
||||
// 查订单
|
||||
var order struct {
|
||||
Id int64 `json:"id"`
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
OrderType string `json:"order_type"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
PaymentChannel string `json:"payment_channel"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).
|
||||
Scan(&order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Id == 0 {
|
||||
return gerror.New("order not found")
|
||||
}
|
||||
if order.Status != "paid" && order.Status != "fulfilled" {
|
||||
return gerror.New("order status must be paid or fulfilled to refund")
|
||||
}
|
||||
|
||||
// 计算可退金额
|
||||
refundCNY, canRefund, err := CalculateRefundAmount(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canRefund {
|
||||
return gerror.New("no active plan found for this order")
|
||||
}
|
||||
|
||||
// 事务:更新租户套餐状态 + 创建退款记录 + 更新订单状态
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 将关联的租户套餐设为已退款
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE pln_tenant_plans SET status = 'refunded', remaining_credits = 0, refunded_at = NOW(), updated_at = NOW() WHERE order_id = ? AND status = 'active'",
|
||||
orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建退款记录
|
||||
_, err = tx.Ctx(ctx).Model("ord_refunds").Insert(do.OrdRefunds{
|
||||
OrderId: orderID,
|
||||
TenantId: order.TenantId,
|
||||
Amount: refundCNY,
|
||||
Reason: reason,
|
||||
Status: "approved",
|
||||
PaymentChannel: order.PaymentChannel,
|
||||
ApprovedBy: adminUserID,
|
||||
ApprovedAt: gtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新订单状态为已退款
|
||||
_, err = tx.Ctx(ctx).Exec(
|
||||
"UPDATE ord_orders SET status = 'refunded', updated_at = NOW() WHERE id = ?",
|
||||
orderID)
|
||||
return err
|
||||
})
|
||||
|
||||
// 尝试调用支付渠道退款(非阻塞,失败不影响业务状态)
|
||||
if err == nil && refundCNY > 0 && order.PaymentChannel != "" && order.PaymentChannel != "mock" {
|
||||
provider := GetProvider(order.PaymentChannel)
|
||||
if provider != nil {
|
||||
jsonStr, _, cfgErr := LoadChannelConfig(ctx, order.PaymentChannel)
|
||||
if cfgErr == nil {
|
||||
_ = provider.Refund(ctx, &RefundRequest{
|
||||
OrderID: orderID,
|
||||
RefundAmount: refundCNY,
|
||||
Reason: reason,
|
||||
}, jsonStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -2,12 +2,134 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/qianfree/team-api/internal/logic/payment"
|
||||
)
|
||||
|
||||
// AutoRenewScanner 自动续费扫描(已废弃,套餐改为一次性购买模式)
|
||||
// 保留空实现以防编译引用
|
||||
// AutoRenewScanner 自动续费扫描任务
|
||||
// 扫描即将到期的自动续费订阅,创建续费订单并尝试扣款
|
||||
func AutoRenewScanner(ctx context.Context) {
|
||||
g.Log().Debug(ctx, "[AutoRenew] skipped: subscription model removed")
|
||||
g.Log().Info(ctx, "[AutoRenew] starting scan...")
|
||||
|
||||
// 查找 3 天内到期且开启自动续费的活跃订阅
|
||||
var subscriptions []struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
EndAt string `json:"end_at"`
|
||||
}
|
||||
threeDaysLater := time.Now().Add(72 * time.Hour).Format("2006-01-02 15:04:05")
|
||||
|
||||
err := dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("status", "active").
|
||||
Where("auto_renew", true).
|
||||
Where("end_at < ?", threeDaysLater).
|
||||
Fields("id, tenant_id, plan_id, end_at").
|
||||
Scan(&subscriptions)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[AutoRenew] query failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[AutoRenew] found %d subscriptions to process", len(subscriptions))
|
||||
|
||||
for _, sub := range subscriptions {
|
||||
processAutoRenew(ctx, sub.ID, sub.TenantID, sub.PlanID)
|
||||
}
|
||||
}
|
||||
|
||||
func processAutoRenew(ctx context.Context, subscriptionID, tenantID, planID int64) {
|
||||
// 查用户ID(取租户的 owner)
|
||||
var userID int64
|
||||
err := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("role", "owner").
|
||||
Where("status", "active").
|
||||
Fields("id").
|
||||
Limit(1).
|
||||
Scan(&userID)
|
||||
if err != nil || userID == 0 {
|
||||
g.Log().Warningf(ctx, "[AutoRenew] tenant %d: no active owner found, skipping", tenantID)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建续费订单
|
||||
orderNo := fmt.Sprintf("RNW%s%04d", time.Now().Format("20060102150405"), time.Now().UnixNano()%10000)
|
||||
|
||||
result, err := dao.OrdOrders.Ctx(ctx).Insert(do.OrdOrders{
|
||||
OrderNo: orderNo,
|
||||
TenantId: tenantID,
|
||||
UserId: userID,
|
||||
OrderType: "renew",
|
||||
PlanId: planID,
|
||||
Amount: 0, // will be filled from plan price,
|
||||
FinalAmount: 0,
|
||||
Currency: "USD",
|
||||
PaymentChannel: "auto_renew",
|
||||
Status: "pending",
|
||||
Description: fmt.Sprintf("Auto-renewal for subscription #%d", subscriptionID),
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[AutoRenew] tenant %d: create order failed: %v", tenantID, err)
|
||||
return
|
||||
}
|
||||
orderID, _ := result.LastInsertId()
|
||||
|
||||
// 查套餐价格并更新订单金额
|
||||
var plan struct {
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
}
|
||||
dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", planID).
|
||||
Fields("monthly_price").
|
||||
Scan(&plan)
|
||||
|
||||
dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).
|
||||
Data(do.OrdOrders{
|
||||
Amount: plan.MonthlyPrice,
|
||||
FinalAmount: plan.MonthlyPrice,
|
||||
}).Update()
|
||||
|
||||
// 标记为已支付(自动续费默认成功)
|
||||
_, err = dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).
|
||||
Data(do.OrdOrders{
|
||||
Status: "paid",
|
||||
PaidAt: gtime.Now(),
|
||||
PaymentNo: fmt.Sprintf("AUTO_RENEW_%d", orderID),
|
||||
}).Update()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[AutoRenew] tenant %d: mark as paid failed: %v", tenantID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行履约:续费 1 个月
|
||||
err = payment.SubscribePlan(ctx, tenantID, planID, 1, true)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[AutoRenew] tenant %d: subscribe failed: %v", tenantID, err)
|
||||
// 标记订单为失败
|
||||
dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).
|
||||
Data(do.OrdOrders{
|
||||
Status: "refund_failed",
|
||||
}).Update()
|
||||
return
|
||||
}
|
||||
|
||||
// 标记订单为已履约
|
||||
dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).
|
||||
Data(do.OrdOrders{
|
||||
Status: "fulfilled",
|
||||
FulfilledAt: gtime.Now(),
|
||||
}).Update()
|
||||
|
||||
g.Log().Infof(ctx, "[AutoRenew] tenant %d: renewal successful (order #%d)", tenantID, orderID)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ExpirePendingOrders 批量将超时未支付的 pending 订单标记为 expired。
|
||||
func ExpirePendingOrders(ctx context.Context) error {
|
||||
result, err := g.DB().Ctx(ctx).Exec(ctx, `
|
||||
UPDATE ord_orders
|
||||
SET status = 'expired', updated_at = NOW()
|
||||
WHERE status = 'pending' AND expired_at IS NOT NULL AND expired_at < NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows > 0 {
|
||||
g.Log().Infof(ctx, "[CRON] expired %d pending orders", rows)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
)
|
||||
|
||||
// CheckPlanExpirations 批量检查并过期已到期的租户套餐
|
||||
func CheckPlanExpirations(ctx context.Context) error {
|
||||
result, err := g.DB().Ctx(ctx).Exec(ctx, `
|
||||
UPDATE pln_tenant_plans
|
||||
SET status = 'expired', remaining_credits = 0, updated_at = NOW()
|
||||
WHERE status = 'active' AND end_at < NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows > 0 {
|
||||
g.Log().Infof(ctx, "[CRON] expired %d tenant plans", rows)
|
||||
|
||||
// 获取受影响的租户列表,失效缓存
|
||||
var tenantIDs []int64
|
||||
err := g.DB().Ctx(ctx).Model("pln_tenant_plans").
|
||||
Where("status", "expired").
|
||||
Where("updated_at > NOW() - INTERVAL '1 minute'").
|
||||
Fields("DISTINCT tenant_id").
|
||||
Scan(&tenantIDs)
|
||||
if err == nil {
|
||||
for _, tid := range tenantIDs {
|
||||
billing.InvalidatePlanCache(ctx, tid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -107,6 +107,7 @@ func (s *sTenant) WalletTransactions(ctx context.Context, req *v1.TenantWalletTr
|
||||
var total int
|
||||
err = query.Fields("bil_transactions.id, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.frozen_after, bil_transactions.related_id, 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.project_id, bil_transactions.api_key_id, bil_transactions.task_id, bil_transactions.created_at").
|
||||
LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id").
|
||||
LeftJoin("bil_records br", "bil_transactions.related_id = br.id AND bil_transactions.related_type = 'billing_record'").
|
||||
OrderDesc("bil_transactions.created_at").
|
||||
Page(page, pageSize).
|
||||
ScanAndCount(&records, &total, false)
|
||||
|
||||
@@ -88,8 +88,9 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq)
|
||||
|
||||
// 查套餐价格
|
||||
var plan struct {
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"`
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", planID).
|
||||
@@ -101,7 +102,12 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq)
|
||||
return nil, lcommon.NewBusinessError(422, "套餐不可用")
|
||||
}
|
||||
|
||||
amount := plan.Price
|
||||
var amount float64
|
||||
if months >= 12 {
|
||||
amount = plan.YearlyPrice
|
||||
} else {
|
||||
amount = plan.MonthlyPrice * float64(months)
|
||||
}
|
||||
|
||||
orderNo := fmt.Sprintf("ORD%s%04d", time.Now().Format("20060102150405"), time.Now().UnixNano()%10000)
|
||||
|
||||
@@ -238,7 +244,7 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq)
|
||||
if settings != nil {
|
||||
res.AmountOptions = settings.AmountOptions
|
||||
res.AmountDiscount = settings.AmountDiscount
|
||||
res.MinTopUp = settings.MinTopUp
|
||||
res.MinTopUp = int(settings.MinTopUp)
|
||||
res.Currency = settings.Currency
|
||||
}
|
||||
|
||||
@@ -248,12 +254,13 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq)
|
||||
// getOrderForPay 获取待支付订单信息(供 OrderPay 内部调用)
|
||||
func getOrderForPay(ctx context.Context, tenantID int64, orderID int64) (orderNo string, finalAmount float64, currency string, orderType string, description string, err error) {
|
||||
var order *struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
Currency string `json:"currency"`
|
||||
OrderType string `json:"order_type"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
OrderNo string `json:"order_no"`
|
||||
FinalAmount float64 `json:"final_amount"`
|
||||
Currency string `json:"currency"`
|
||||
OrderType string `json:"order_type"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
ExpiredAt *gtime.Time `json:"expired_at"`
|
||||
}
|
||||
err = dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).Where("tenant_id", tenantID).Scan(&order)
|
||||
@@ -268,6 +275,10 @@ func getOrderForPay(ctx context.Context, tenantID int64, orderID int64) (orderNo
|
||||
err = lcommon.NewBusinessError(422, "订单状态不是待支付")
|
||||
return
|
||||
}
|
||||
if order.ExpiredAt != nil && !order.ExpiredAt.IsZero() && order.ExpiredAt.Before(gtime.Now()) {
|
||||
err = lcommon.NewBusinessError(422, "订åå·²è¿æï¼è¯·éæ°ä¸å")
|
||||
return
|
||||
}
|
||||
return order.OrderNo, order.FinalAmount, order.Currency, order.OrderType, order.Description, nil
|
||||
}
|
||||
|
||||
@@ -369,15 +380,15 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.TenantRechargeCreateRes{
|
||||
OrderId: orderID,
|
||||
OrderNo: orderNo,
|
||||
PaymentUrl: payResult.PaymentURL,
|
||||
PaymentNo: payResult.PaymentNo,
|
||||
Params: payResult.Params,
|
||||
IsRedirect: payResult.IsRedirect,
|
||||
FinalAmount: finalAmount,
|
||||
}, nil
|
||||
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.
|
||||
|
||||
+43
-164
@@ -3,35 +3,24 @@ package tenant
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/logic/payment"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PlanList 获取可购买的套餐列表(仅 active)
|
||||
func (s *sTenant) PlanList(ctx context.Context, req *v1.TenantPlanListReq) (*v1.TenantPlanListRes, error) {
|
||||
var entities []struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
CreditAmount float64 `json:"credit_amount"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
role := ctxUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
var entities []*entity.PlnPlans
|
||||
err := dao.PlnPlans.Ctx(ctx).
|
||||
Fields("id, name, identifier, description, price, credit_amount, bonus_amount, validity_days, is_recommended, sort_order").
|
||||
Where("status", "active").
|
||||
OrderAsc("sort_order").
|
||||
Scan(&entities)
|
||||
@@ -41,173 +30,63 @@ func (s *sTenant) PlanList(ctx context.Context, req *v1.TenantPlanListReq) (*v1.
|
||||
list := make([]*v1.TenantPlanItem, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
list = append(list, &v1.TenantPlanItem{
|
||||
Id: e.Id,
|
||||
Name: e.Name,
|
||||
Identifier: e.Identifier,
|
||||
Description: e.Description,
|
||||
Price: e.Price,
|
||||
CreditAmount: e.CreditAmount,
|
||||
BonusAmount: e.BonusAmount,
|
||||
ValidityDays: e.ValidityDays,
|
||||
IsRecommended: e.IsRecommended,
|
||||
SortOrder: e.SortOrder,
|
||||
Id: e.Id,
|
||||
Name: e.Name,
|
||||
Identifier: e.Identifier,
|
||||
Description: e.Description,
|
||||
MonthlyPrice: e.MonthlyPrice,
|
||||
YearlyPrice: e.YearlyPrice,
|
||||
MonthlyQuotaTokens: e.MonthlyQuotaTokens,
|
||||
AllowedModels: e.AllowedModels,
|
||||
IsRecommended: e.IsRecommended,
|
||||
SortOrder: e.SortOrder,
|
||||
})
|
||||
}
|
||||
return &v1.TenantPlanListRes{List: list}, nil
|
||||
}
|
||||
|
||||
// PlanMine 获取当前租户已购买的套餐
|
||||
func (s *sTenant) PlanMine(ctx context.Context, req *v1.TenantPlanMineReq) (*v1.TenantPlanMineRes, error) {
|
||||
// PlanCurrent 获取租户当前套餐
|
||||
func (s *sTenant) PlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (*v1.TenantPlanCurrentRes, error) {
|
||||
role := ctxUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := ctxTenantID(ctx)
|
||||
|
||||
var items []*v1.TenantPlanMineItem
|
||||
var plan v1.TenantPlanCurrentRes
|
||||
err := dao.PlnTenantPlans.Ctx(ctx).As("tp").
|
||||
Fields("tp.id, tp.plan_id, tp.status, tp.total_credits, tp.remaining_credits, tp.start_at, tp.end_at, tp.created_at, p.name as plan_name").
|
||||
Fields("tp.id, tp.tenant_id, tp.plan_id, tp.status, tp.start_at, tp.end_at, tp.auto_renew, tp.monthly_quota_tokens, tp.used_tokens, tp.last_reset_at, p.name, p.identifier, p.description, p.monthly_price, p.yearly_price").
|
||||
Where("tp.tenant_id", tenantID).
|
||||
WhereIn("tp.status", g.Slice{"active", "expired", "depleted", "refunded"}).
|
||||
Where("tp.status", "active").
|
||||
WhereIn("tp.plan_id", g.DB().Model("pln_plans").Where("status", "active").Fields("id")).
|
||||
LeftJoin("pln_plans p", "p.id = tp.plan_id").
|
||||
OrderDesc("tp.created_at").
|
||||
Scan(&items)
|
||||
if err != nil {
|
||||
OrderDesc("tp.start_at").
|
||||
Limit(1).
|
||||
Safe().
|
||||
Scan(&plan)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var totalRemaining float64
|
||||
activeCount := 0
|
||||
for _, item := range items {
|
||||
if item.Status == "active" {
|
||||
totalRemaining += item.RemainingCredits
|
||||
activeCount++
|
||||
}
|
||||
if plan.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &v1.TenantPlanMineRes{
|
||||
List: items,
|
||||
TotalRemaining: totalRemaining,
|
||||
ActiveCount: activeCount,
|
||||
}, nil
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// PlanOrderCreate 创建套餐购买订单
|
||||
func (s *sTenant) PlanOrderCreate(ctx context.Context, req *v1.TenantPlanOrderCreateReq) (*v1.TenantPlanOrderCreateRes, error) {
|
||||
// PlanCancelAutoRenew 取消自动续费
|
||||
func (s *sTenant) PlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (*v1.TenantPlanCancelAutoRenewRes, error) {
|
||||
role := ctxUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := ctxTenantID(ctx)
|
||||
userID := ctxUserID(ctx)
|
||||
|
||||
// 查套餐信息
|
||||
var plan struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"`
|
||||
PurchaseLimit int `json:"purchase_limit"`
|
||||
PurchaseLimitPeriod string `json:"purchase_limit_period"`
|
||||
Stock int `json:"stock"`
|
||||
}
|
||||
err := dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", req.PlanId).
|
||||
Scan(&plan)
|
||||
if err != nil {
|
||||
_, err := dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Data(do.PlnTenantPlans{
|
||||
AutoRenew: false,
|
||||
}).Update()
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if plan.Id == 0 || plan.Status != "active" {
|
||||
return nil, common.NewBusinessError(422, "套餐不可用")
|
||||
}
|
||||
|
||||
// 检查限购
|
||||
if plan.PurchaseLimit > 0 {
|
||||
count, err := dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("plan_id", req.PlanId).
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= plan.PurchaseLimit {
|
||||
return nil, common.NewBusinessError(422, "已达到该套餐的购买上限")
|
||||
}
|
||||
}
|
||||
|
||||
// 检查库存
|
||||
if plan.Stock > 0 {
|
||||
// stock 字段 > 0 表示有限库存,需检查是否有余量
|
||||
// 这里仅做前端校验,实际扣减在履约时通过 SQL WHERE stock > 0 保证原子性
|
||||
}
|
||||
|
||||
// 获取支付渠道配置
|
||||
cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel)
|
||||
if err != nil {
|
||||
return nil, common.NewBusinessError(422, err.Error())
|
||||
}
|
||||
provider := payment.GetProvider(req.PaymentChannel)
|
||||
if provider == nil {
|
||||
return nil, common.NewBusinessError(422, "不支持的支付渠道")
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
orderNo := fmt.Sprintf("PLN%s%04d", time.Now().Format("20060102150405"), time.Now().UnixNano()%10000)
|
||||
description := fmt.Sprintf("购买套餐「%s」", plan.Name)
|
||||
|
||||
result, err := dao.OrdOrders.Ctx(ctx).Insert(do.OrdOrders{
|
||||
OrderNo: orderNo,
|
||||
TenantId: tenantID,
|
||||
UserId: userID,
|
||||
OrderType: "new_plan",
|
||||
PlanId: req.PlanId,
|
||||
Amount: plan.Price,
|
||||
DiscountAmount: 0,
|
||||
FinalAmount: plan.Price,
|
||||
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()
|
||||
|
||||
// 发起支付
|
||||
settings, _ := payment.GetGlobalPaymentSettings(ctx)
|
||||
baseURL := ""
|
||||
if settings != nil {
|
||||
baseURL = settings.CallbackBaseURL
|
||||
}
|
||||
notifyURL := baseURL + "/api/payment/callback/" + req.PaymentChannel
|
||||
returnURL := baseURL + "/api/payment/epay/return"
|
||||
|
||||
payResult, err := provider.CreatePayment(ctx, &payment.PaymentOrder{
|
||||
OrderID: orderID,
|
||||
OrderNo: orderNo,
|
||||
TenantID: tenantID,
|
||||
Amount: plan.Price,
|
||||
Currency: "CNY",
|
||||
OrderType: "new_plan",
|
||||
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.TenantPlanOrderCreateRes{
|
||||
OrderId: orderID,
|
||||
OrderNo: orderNo,
|
||||
PaymentUrl: payResult.PaymentURL,
|
||||
}, nil
|
||||
return &v1.TenantPlanCancelAutoRenewRes{}, nil
|
||||
}
|
||||
@@ -88,6 +88,10 @@ func convertRelayError(err error) error {
|
||||
}
|
||||
switch relayErr.StatusCode {
|
||||
case 402:
|
||||
// 区分余额不足和成员额度超限
|
||||
if relayErr.Message == "member quota exceeded" {
|
||||
return common.NewBusinessError(10001, "成员额度已用完,请联系管理员调整")
|
||||
}
|
||||
return common.NewBusinessError(10001, "余额不足,请充值后重试")
|
||||
case 429:
|
||||
return common.NewBusinessError(10014, "请求过于频繁,请稍后重试")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/logic/payment"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
lcommon2 "github.com/qianfree/team-api/internal/logic/common"
|
||||
@@ -77,31 +78,19 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
|
||||
if redemption.PlanID == 0 {
|
||||
return nil, lcommon.NewBusinessError(422, "套餐兑换码缺少plan_id")
|
||||
}
|
||||
planEntity, planErr := dao.PlnPlans.Ctx(ctx).Where("id", redemption.PlanID).Where("status", "active").One()
|
||||
if planErr != nil || planEntity == nil {
|
||||
return nil, lcommon.NewBusinessError(422, "套餐不存在或已下架")
|
||||
months := 1
|
||||
if redemption.DurationDays > 0 {
|
||||
months = (redemption.DurationDays + 29) / 30
|
||||
if months < 1 {
|
||||
months = 1
|
||||
}
|
||||
}
|
||||
totalCredits := planEntity["credit_amount"].Float64() + planEntity["bonus_amount"].Float64()
|
||||
validityDays := planEntity["validity_days"].Int()
|
||||
if validityDays <= 0 {
|
||||
validityDays = 30
|
||||
}
|
||||
now := gtime.Now()
|
||||
endAt := now.AddDate(0, 0, validityDays)
|
||||
_, err = dao.PlnTenantPlans.Ctx(ctx).Insert(do.PlnTenantPlans{
|
||||
TenantId: tenantID,
|
||||
PlanId: redemption.PlanID,
|
||||
Status: "active",
|
||||
StartAt: now,
|
||||
EndAt: endAt,
|
||||
TotalCredits: totalCredits,
|
||||
RemainingCredits: totalCredits,
|
||||
PaidCny: 0,
|
||||
})
|
||||
err = payment.SubscribePlan(ctx, tenantID, redemption.PlanID, months, false)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "激活套餐失败")
|
||||
}
|
||||
result["plan_id"] = redemption.PlanID
|
||||
result["months"] = months
|
||||
|
||||
case "duration":
|
||||
if redemption.DurationDays <= 0 {
|
||||
|
||||
@@ -45,7 +45,4 @@ type BilRecords struct {
|
||||
BaseInputPrice any // 基础模型输入单价(快照,应用倍率前)
|
||||
BaseOutputPrice any // 基础模型输出单价(快照,应用倍率前)
|
||||
BillingSnapshot any // 完整计费计算过程快照(JSONB)
|
||||
TenantPlanId any //
|
||||
PlanDeduction any //
|
||||
WalletDeduction any //
|
||||
}
|
||||
@@ -30,5 +30,4 @@ type BilTransactions struct {
|
||||
ProjectId any // 关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)
|
||||
ApiKeyId any // 关联API密钥ID(consume 类型为发起请求的密钥)
|
||||
TaskId any // 关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)
|
||||
TenantPlanId any //
|
||||
}
|
||||
@@ -69,7 +69,4 @@ type BilUsageLogs struct {
|
||||
BillingSnapshot any // 完整计费计算过程快照(JSONB)
|
||||
ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId any // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
TenantPlanId any //
|
||||
PlanDeduction any //
|
||||
WalletDeduction any //
|
||||
}
|
||||
@@ -1,19 +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"
|
||||
)
|
||||
|
||||
// MdlModelGroupItems is the golang structure of table mdl_model_group_items for DAO operations like Where/Data.
|
||||
type MdlModelGroupItems struct {
|
||||
g.Meta `orm:"table:mdl_model_group_items, do:true"`
|
||||
Id any //
|
||||
GroupId any //
|
||||
ModelName any //
|
||||
CreatedAt *gtime.Time //
|
||||
}
|
||||
@@ -1,22 +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"
|
||||
)
|
||||
|
||||
// MdlModelGroups is the golang structure of table mdl_model_groups for DAO operations like Where/Data.
|
||||
type MdlModelGroups struct {
|
||||
g.Meta `orm:"table:mdl_model_groups, do:true"`
|
||||
Id any //
|
||||
Name any //
|
||||
Description any //
|
||||
SortOrder any //
|
||||
Status any //
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// =================================================================================
|
||||
// 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"
|
||||
)
|
||||
|
||||
// PlnFeatureFlags is the golang structure of table pln_feature_flags for DAO operations like Where/Data.
|
||||
type PlnFeatureFlags struct {
|
||||
g.Meta `orm:"table:pln_feature_flags, do:true"`
|
||||
Id any // 主键ID
|
||||
FeatureKey any // 功能标识(如 api_docs, export_csv)
|
||||
Description any // 功能描述
|
||||
DefaultEnabled any // 默认是否启用
|
||||
Enabled any // 当前是否启用(计算后的最终值)
|
||||
Source any // 来源:plan(套餐)/ tenant(租户覆盖)/ manual(手动)
|
||||
SourceId any // 来源ID(plan_id 或 tenant_id)
|
||||
TenantId any // 关联租户ID(租户级覆盖时使用)
|
||||
PlanId any // 关联套餐ID(套餐级配置时使用)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
}
|
||||
@@ -11,23 +11,18 @@ import (
|
||||
|
||||
// PlnPlans is the golang structure of table pln_plans for DAO operations like Where/Data.
|
||||
type PlnPlans struct {
|
||||
g.Meta `orm:"table:pln_plans, do:true"`
|
||||
Id any // 主键ID
|
||||
Name any // 套餐显示名称
|
||||
Identifier any // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description any // 套餐描述(面向用户的营销文案)
|
||||
Price any // 套餐价格(CNY)
|
||||
Status any // 状态:active(上架)/ archived(下架)
|
||||
IsRecommended any // 是否推荐
|
||||
SortOrder any // 排序权重(数字越小越靠前)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CreditAmount any // 套餐包含的额度(USD)
|
||||
BonusAmount any // 赠送额度(USD)
|
||||
ValidityDays any // 有效天数,从激活时起算
|
||||
PurchaseLimit any // 限购数量,0=不限购
|
||||
PurchaseLimitPeriod any // 限购周期:lifetime/monthly/yearly
|
||||
Stock any // 库存数量,NULL=不限
|
||||
TotalPurchased any // 累计购买次数
|
||||
AllowedModels []string // 允许使用的模型列表,空数组=全部模型
|
||||
g.Meta `orm:"table:pln_plans, do:true"`
|
||||
Id any // 主键ID
|
||||
Name any // 套餐显示名称
|
||||
Identifier any // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description any // 套餐描述(面向用户的营销文案)
|
||||
MonthlyPrice any // 月度价格(CNY)
|
||||
YearlyPrice any // 年度价格(CNY,通常为月价×10)
|
||||
Status any // 状态:active(上架)/ archived(下架)
|
||||
MonthlyQuotaTokens any // 每月 Token 配额(0=不限)
|
||||
AllowedModels []string // 允许使用的模型列表(NULL=全部,空数组=无)
|
||||
IsRecommended any // 是否推荐
|
||||
SortOrder any // 排序权重(数字越小越靠前)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
}
|
||||
@@ -11,18 +11,18 @@ import (
|
||||
|
||||
// PlnTenantPlans is the golang structure of table pln_tenant_plans for DAO operations like Where/Data.
|
||||
type PlnTenantPlans struct {
|
||||
g.Meta `orm:"table:pln_tenant_plans, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
PlanId any // 套餐ID
|
||||
Status any // 状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)
|
||||
StartAt *gtime.Time // 生效起始时间
|
||||
EndAt *gtime.Time // 到期时间
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
TotalCredits any // 总额度(USD)= credit_amount + bonus_amount
|
||||
RemainingCredits any // 剩余额度(USD)
|
||||
PaidCny any // 实付金额(CNY)
|
||||
RefundedAt *gtime.Time // 退款时间
|
||||
OrderId any // 关联订单ID
|
||||
g.Meta `orm:"table:pln_tenant_plans, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
PlanId any // 套餐ID
|
||||
Status any // 状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)
|
||||
StartAt *gtime.Time // 生效起始时间
|
||||
EndAt *gtime.Time // 到期时间
|
||||
AutoRenew any // 是否自动续费
|
||||
MonthlyQuotaTokens any // 月度 Token 配额快照
|
||||
UsedTokens any // 本月已使用 Token
|
||||
LastResetAt *gtime.Time // 上次配额重置时间
|
||||
CancelledAt *gtime.Time // 取消时间
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
}
|
||||
@@ -43,7 +43,4 @@ type BilRecords struct {
|
||||
BaseInputPrice float64 `json:"base_input_price" orm:"base_input_price" description:"基础模型输入单价(快照,应用倍率前)"` // 基础模型输入单价(快照,应用倍率前)
|
||||
BaseOutputPrice float64 `json:"base_output_price" orm:"base_output_price" description:"基础模型输出单价(快照,应用倍率前)"` // 基础模型输出单价(快照,应用倍率前)
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
TenantPlanId int64 `json:"tenant_plan_id" orm:"tenant_plan_id" description:""` //
|
||||
PlanDeduction float64 `json:"plan_deduction" orm:"plan_deduction" description:""` //
|
||||
WalletDeduction float64 `json:"wallet_deduction" orm:"wallet_deduction" description:""` //
|
||||
}
|
||||
@@ -28,5 +28,4 @@ type BilTransactions struct {
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)"` // 关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"关联API密钥ID(consume 类型为发起请求的密钥)"` // 关联API密钥ID(consume 类型为发起请求的密钥)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)"` // 关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)
|
||||
TenantPlanId int64 `json:"tenant_plan_id" orm:"tenant_plan_id" description:""` //
|
||||
}
|
||||
@@ -67,7 +67,4 @@ type BilUsageLogs struct {
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
TenantPlanId int64 `json:"tenant_plan_id" orm:"tenant_plan_id" description:""` //
|
||||
PlanDeduction float64 `json:"plan_deduction" orm:"plan_deduction" description:""` //
|
||||
WalletDeduction float64 `json:"wallet_deduction" orm:"wallet_deduction" description:""` //
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// MdlModelGroupItems is the golang structure for table mdl_model_group_items.
|
||||
type MdlModelGroupItems struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
GroupId int64 `json:"group_id" orm:"group_id" description:""` //
|
||||
ModelName string `json:"model_name" orm:"model_name" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// MdlModelGroups is the golang structure for table mdl_model_groups.
|
||||
type MdlModelGroups struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Name string `json:"name" orm:"name" description:""` //
|
||||
Description string `json:"description" orm:"description" description:""` //
|
||||
SortOrder int `json:"sort_order" orm:"sort_order" description:""` //
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// PlnFeatureFlags is the golang structure for table pln_feature_flags.
|
||||
type PlnFeatureFlags struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
FeatureKey string `json:"feature_key" orm:"feature_key" description:"功能标识(如 api_docs, export_csv)"` // 功能标识(如 api_docs, export_csv)
|
||||
Description string `json:"description" orm:"description" description:"功能描述"` // 功能描述
|
||||
DefaultEnabled bool `json:"default_enabled" orm:"default_enabled" description:"默认是否启用"` // 默认是否启用
|
||||
Enabled bool `json:"enabled" orm:"enabled" description:"当前是否启用(计算后的最终值)"` // 当前是否启用(计算后的最终值)
|
||||
Source string `json:"source" orm:"source" description:"来源:plan(套餐)/ tenant(租户覆盖)/ manual(手动)"` // 来源:plan(套餐)/ tenant(租户覆盖)/ manual(手动)
|
||||
SourceId int64 `json:"source_id" orm:"source_id" description:"来源ID(plan_id 或 tenant_id)"` // 来源ID(plan_id 或 tenant_id)
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"关联租户ID(租户级覆盖时使用)"` // 关联租户ID(租户级覆盖时使用)
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:"关联套餐ID(套餐级配置时使用)"` // 关联套餐ID(套餐级配置时使用)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -10,22 +10,17 @@ import (
|
||||
|
||||
// PlnPlans is the golang structure for table pln_plans.
|
||||
type PlnPlans struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
Name string `json:"name" orm:"name" description:"套餐显示名称"` // 套餐显示名称
|
||||
Identifier string `json:"identifier" orm:"identifier" description:"套餐唯一标识(free/basic/pro/enterprise)"` // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description string `json:"description" orm:"description" description:"套餐描述(面向用户的营销文案)"` // 套餐描述(面向用户的营销文案)
|
||||
Price float64 `json:"price" orm:"price" description:"套餐价格(CNY)"` // 套餐价格(CNY)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(上架)/ archived(下架)"` // 状态:active(上架)/ archived(下架)
|
||||
IsRecommended bool `json:"is_recommended" orm:"is_recommended" 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:"更新时间"` // 更新时间
|
||||
CreditAmount float64 `json:"credit_amount" orm:"credit_amount" description:"套餐包含的额度(USD)"` // 套餐包含的额度(USD)
|
||||
BonusAmount float64 `json:"bonus_amount" orm:"bonus_amount" description:"赠送额度(USD)"` // 赠送额度(USD)
|
||||
ValidityDays int `json:"validity_days" orm:"validity_days" description:"有效天数,从激活时起算"` // 有效天数,从激活时起算
|
||||
PurchaseLimit int `json:"purchase_limit" orm:"purchase_limit" description:"限购数量,0=不限购"` // 限购数量,0=不限购
|
||||
PurchaseLimitPeriod string `json:"purchase_limit_period" orm:"purchase_limit_period" description:"限购周期:lifetime/monthly/yearly"` // 限购周期:lifetime/monthly/yearly
|
||||
Stock int `json:"stock" orm:"stock" description:"库存数量,NULL=不限"` // 库存数量,NULL=不限
|
||||
TotalPurchased int `json:"total_purchased" orm:"total_purchased" description:"累计购买次数"` // 累计购买次数
|
||||
AllowedModels []string `json:"allowed_models" orm:"allowed_models" description:"允许使用的模型列表,空数组=全部模型"` // 允许使用的模型列表,空数组=全部模型
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
Name string `json:"name" orm:"name" description:"套餐显示名称"` // 套餐显示名称
|
||||
Identifier string `json:"identifier" orm:"identifier" description:"套餐唯一标识(free/basic/pro/enterprise)"` // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description string `json:"description" orm:"description" description:"套餐描述(面向用户的营销文案)"` // 套餐描述(面向用户的营销文案)
|
||||
MonthlyPrice float64 `json:"monthly_price" orm:"monthly_price" description:"月度价格(CNY)"` // 月度价格(CNY)
|
||||
YearlyPrice float64 `json:"yearly_price" orm:"yearly_price" description:"年度价格(CNY,通常为月价×10)"` // 年度价格(CNY,通常为月价×10)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(上架)/ archived(下架)"` // 状态:active(上架)/ archived(下架)
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens" orm:"monthly_quota_tokens" description:"每月 Token 配额(0=不限)"` // 每月 Token 配额(0=不限)
|
||||
AllowedModels []string `json:"allowed_models" orm:"allowed_models" description:"允许使用的模型列表(NULL=全部,空数组=无)"` // 允许使用的模型列表(NULL=全部,空数组=无)
|
||||
IsRecommended bool `json:"is_recommended" orm:"is_recommended" 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:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -10,17 +10,17 @@ import (
|
||||
|
||||
// PlnTenantPlans is the golang structure for table pln_tenant_plans.
|
||||
type PlnTenantPlans struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:"套餐ID"` // 套餐ID
|
||||
Status string `json:"status" orm:"status" description:"状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)"` // 状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)
|
||||
StartAt *gtime.Time `json:"start_at" orm:"start_at" description:"生效起始时间"` // 生效起始时间
|
||||
EndAt *gtime.Time `json:"end_at" orm:"end_at" description:"到期时间"` // 到期时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
TotalCredits float64 `json:"total_credits" orm:"total_credits" description:"总额度(USD)= credit_amount + bonus_amount"` // 总额度(USD)= credit_amount + bonus_amount
|
||||
RemainingCredits float64 `json:"remaining_credits" orm:"remaining_credits" description:"剩余额度(USD)"` // 剩余额度(USD)
|
||||
PaidCny float64 `json:"paid_cny" orm:"paid_cny" description:"实付金额(CNY)"` // 实付金额(CNY)
|
||||
RefundedAt *gtime.Time `json:"refunded_at" orm:"refunded_at" description:"退款时间"` // 退款时间
|
||||
OrderId int64 `json:"order_id" orm:"order_id" description:"关联订单ID"` // 关联订单ID
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:"套餐ID"` // 套餐ID
|
||||
Status string `json:"status" orm:"status" description:"状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)"` // 状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)
|
||||
StartAt *gtime.Time `json:"start_at" orm:"start_at" description:"生效起始时间"` // 生效起始时间
|
||||
EndAt *gtime.Time `json:"end_at" orm:"end_at" description:"到期时间"` // 到期时间
|
||||
AutoRenew bool `json:"auto_renew" orm:"auto_renew" description:"是否自动续费"` // 是否自动续费
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens" orm:"monthly_quota_tokens" description:"月度 Token 配额快照"` // 月度 Token 配额快照
|
||||
UsedTokens int64 `json:"used_tokens" orm:"used_tokens" description:"本月已使用 Token"` // 本月已使用 Token
|
||||
LastResetAt *gtime.Time `json:"last_reset_at" orm:"last_reset_at" description:"上次配额重置时间"` // 上次配额重置时间
|
||||
CancelledAt *gtime.Time `json:"cancelled_at" orm:"cancelled_at" description:"取消时间"` // 取消时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -256,8 +256,6 @@ type (
|
||||
GetOrder(ctx context.Context, req *v1.OrderDetailReq) (*v1.OrderDetailRes, error)
|
||||
// RefundOrder 发起退款
|
||||
RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.OrderRefundRes, error)
|
||||
// OrderRefundPreview 退款预览
|
||||
OrderRefundPreview(ctx context.Context, req *v1.OrderRefundPreviewReq) (*v1.OrderRefundPreviewRes, error)
|
||||
// OrderComplete 手动完成订单
|
||||
OrderComplete(ctx context.Context, req *v1.OrderCompleteReq) (*v1.OrderCompleteRes, error)
|
||||
// GetPaymentChannels 获取所有渠道配置(单例模式,从 sys_options 读取)
|
||||
|
||||
@@ -225,10 +225,10 @@ type (
|
||||
PersonalApiKeyUsage(ctx context.Context, req *v1.PersonalApiKeyUsageReq) (*v1.PersonalApiKeyUsageRes, error)
|
||||
// PlanList 获取可购买的套餐列表(仅 active)
|
||||
PlanList(ctx context.Context, req *v1.TenantPlanListReq) (*v1.TenantPlanListRes, error)
|
||||
// PlanMine 获取当前租户已购买的套餐
|
||||
PlanMine(ctx context.Context, req *v1.TenantPlanMineReq) (*v1.TenantPlanMineRes, error)
|
||||
// PlanOrderCreate 创建套餐购买订单
|
||||
PlanOrderCreate(ctx context.Context, req *v1.TenantPlanOrderCreateReq) (*v1.TenantPlanOrderCreateRes, error)
|
||||
// PlanCurrent 获取租户当前套餐
|
||||
PlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (*v1.TenantPlanCurrentRes, error)
|
||||
// PlanCancelAutoRenew 取消自动续费
|
||||
PlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (*v1.TenantPlanCancelAutoRenewRes, error)
|
||||
PlaygroundChat(ctx context.Context, req *v1.PlaygroundChatReq) (*v1.PlaygroundChatRes, error)
|
||||
PlaygroundImage(ctx context.Context, req *v1.PlaygroundImageReq) (*v1.PlaygroundImageRes, error)
|
||||
PlaygroundAudioTTS(ctx context.Context, req *v1.PlaygroundAudioTTSReq) (*v1.PlaygroundAudioTTSRes, error)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
-- +goose Up
|
||||
-- 资源包功能:模型分组 + 资源包产品 + 租户持有包 + 购买记录
|
||||
-- 同时修改订单、计费记录等表增加资源包关联字段
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 模型分组
|
||||
-- ============================================================
|
||||
|
||||
-- 模型分组定义(如"GPT-4 系列"、"全部模型")
|
||||
CREATE TABLE mdl_model_groups (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE mdl_model_groups IS '模型分组定义,用于资源包的模型范围管理';
|
||||
|
||||
-- 分组-模型关联
|
||||
CREATE TABLE mdl_model_group_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
group_id BIGINT NOT NULL,
|
||||
model_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
CONSTRAINT fk_mdl_model_group_items_group FOREIGN KEY (group_id) REFERENCES mdl_model_groups(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_mdl_model_group_items UNIQUE (group_id, model_name)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE mdl_model_group_items IS '模型分组项,关联分组与具体模型';
|
||||
|
||||
CREATE INDEX idx_mdl_model_group_items_group ON mdl_model_group_items (group_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 资源包产品定义
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE rsp_packages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
price NUMERIC(20,10) NOT NULL,
|
||||
credit_amount NUMERIC(20,10) NOT NULL,
|
||||
bonus_amount NUMERIC(20,10) DEFAULT 0 NOT NULL,
|
||||
validity_days INTEGER NOT NULL,
|
||||
model_group_id BIGINT,
|
||||
purchase_limit INTEGER DEFAULT 0 NOT NULL,
|
||||
purchase_limit_period VARCHAR(20) DEFAULT 'lifetime' NOT NULL,
|
||||
stock INTEGER,
|
||||
total_purchased INTEGER DEFAULT 0 NOT NULL,
|
||||
priority_access BOOLEAN DEFAULT false NOT NULL,
|
||||
advanced_log BOOLEAN DEFAULT false NOT NULL,
|
||||
support_level VARCHAR(20) DEFAULT 'standard' NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE rsp_packages IS '资源包产品定义';
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 租户持有的资源包(可叠加)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE rsp_tenant_packages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
package_id BIGINT NOT NULL,
|
||||
order_id BIGINT,
|
||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||
total_credits NUMERIC(20,10) NOT NULL,
|
||||
remaining_credits NUMERIC(20,10) NOT NULL,
|
||||
paid_cny NUMERIC(20,10) NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
expired_at TIMESTAMPTZ,
|
||||
refunded_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE rsp_tenant_packages IS '租户持有的资源包,多个可同时有效';
|
||||
|
||||
-- 计费查询核心索引:按租户查有效包,按过期时间排序
|
||||
CREATE INDEX idx_rsp_tenant_packages_billing ON rsp_tenant_packages (tenant_id, status, expires_at);
|
||||
CREATE INDEX idx_rsp_tenant_packages_order ON rsp_tenant_packages (order_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 购买记录(限购计数用)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE rsp_purchase_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
package_id BIGINT NOT NULL,
|
||||
order_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
CONSTRAINT uk_rsp_purchase_records UNIQUE (tenant_id, order_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE rsp_purchase_records IS '资源包购买记录,用于限购计数';
|
||||
|
||||
CREATE INDEX idx_rsp_purchase_records_tenant_pkg ON rsp_purchase_records (tenant_id, package_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 修改现有表
|
||||
-- ============================================================
|
||||
|
||||
-- 订单表增加资源包关联
|
||||
ALTER TABLE ord_orders ADD COLUMN IF NOT EXISTS package_id BIGINT;
|
||||
|
||||
-- 计费记录增加资源包扣费来源
|
||||
ALTER TABLE bil_records ADD COLUMN IF NOT EXISTS tenant_package_id BIGINT;
|
||||
ALTER TABLE bil_records ADD COLUMN IF NOT EXISTS package_deduction NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE bil_records ADD COLUMN IF NOT EXISTS wallet_deduction NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
|
||||
-- 交易流水增加资源包关联
|
||||
ALTER TABLE bil_transactions ADD COLUMN IF NOT EXISTS tenant_package_id BIGINT;
|
||||
|
||||
-- 使用日志增加资源包扣费来源
|
||||
ALTER TABLE bil_usage_logs ADD COLUMN IF NOT EXISTS tenant_package_id BIGINT;
|
||||
ALTER TABLE bil_usage_logs ADD COLUMN IF NOT EXISTS package_deduction NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE bil_usage_logs ADD COLUMN IF NOT EXISTS wallet_deduction NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 初始化默认模型组
|
||||
-- ============================================================
|
||||
|
||||
-- 插入一个"全部模型"默认分组
|
||||
INSERT INTO mdl_model_groups (name, description, sort_order, status) VALUES
|
||||
('全部模型', '包含所有可用模型', 0, 'active');
|
||||
|
||||
-- +goose Down
|
||||
-- 回滚:按逆序删除
|
||||
|
||||
ALTER TABLE bil_usage_logs DROP COLUMN IF EXISTS wallet_deduction;
|
||||
ALTER TABLE bil_usage_logs DROP COLUMN IF EXISTS package_deduction;
|
||||
ALTER TABLE bil_usage_logs DROP COLUMN IF EXISTS tenant_package_id;
|
||||
|
||||
ALTER TABLE bil_transactions DROP COLUMN IF EXISTS tenant_package_id;
|
||||
|
||||
ALTER TABLE bil_records DROP COLUMN IF EXISTS wallet_deduction;
|
||||
ALTER TABLE bil_records DROP COLUMN IF EXISTS package_deduction;
|
||||
ALTER TABLE bil_records DROP COLUMN IF EXISTS tenant_package_id;
|
||||
|
||||
ALTER TABLE ord_orders DROP COLUMN IF EXISTS package_id;
|
||||
|
||||
DROP TABLE IF EXISTS rsp_purchase_records;
|
||||
DROP TABLE IF EXISTS rsp_tenant_packages;
|
||||
DROP TABLE IF EXISTS rsp_packages;
|
||||
DROP TABLE IF EXISTS mdl_model_group_items;
|
||||
DROP TABLE IF EXISTS mdl_model_groups;
|
||||
@@ -1,215 +0,0 @@
|
||||
-- +goose Up
|
||||
-- 将资源包功能合并到套餐功能:改造 pln_plans 和 pln_tenant_plans,
|
||||
-- 移除独立的 rsp_* 表和 pln_feature_flags 表
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 改造 pln_plans 表:加入资源包字段,移除订阅字段
|
||||
-- ============================================================
|
||||
|
||||
-- 添加资源包字段
|
||||
ALTER TABLE pln_plans ADD COLUMN credit_amount NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN bonus_amount NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN validity_days INTEGER DEFAULT 30 NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN model_group_id BIGINT DEFAULT 0;
|
||||
ALTER TABLE pln_plans ADD COLUMN purchase_limit INTEGER DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN purchase_limit_period VARCHAR(20) DEFAULT 'lifetime' NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN stock INTEGER;
|
||||
ALTER TABLE pln_plans ADD COLUMN total_purchased INTEGER DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN priority_access BOOLEAN DEFAULT false NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN advanced_log BOOLEAN DEFAULT false NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN support_level VARCHAR(20) DEFAULT 'standard' NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN pln_plans.credit_amount IS '套餐包含的额度(USD)';
|
||||
COMMENT ON COLUMN pln_plans.bonus_amount IS '赠送额度(USD)';
|
||||
COMMENT ON COLUMN pln_plans.validity_days IS '有效天数,从激活时起算';
|
||||
COMMENT ON COLUMN pln_plans.model_group_id IS '模型组ID,0=全部模型';
|
||||
COMMENT ON COLUMN pln_plans.purchase_limit IS '限购数量,0=不限购';
|
||||
COMMENT ON COLUMN pln_plans.purchase_limit_period IS '限购周期:lifetime/monthly/yearly';
|
||||
COMMENT ON COLUMN pln_plans.stock IS '库存数量,NULL=不限';
|
||||
COMMENT ON COLUMN pln_plans.total_purchased IS '累计购买次数';
|
||||
COMMENT ON COLUMN pln_plans.priority_access IS '是否包含优先通道访问';
|
||||
COMMENT ON COLUMN pln_plans.advanced_log IS '是否包含高级日志';
|
||||
COMMENT ON COLUMN pln_plans.support_level IS '支持级别:standard/premium/dedicated';
|
||||
|
||||
-- monthly_price 重命名为 price
|
||||
ALTER TABLE pln_plans RENAME COLUMN monthly_price TO price;
|
||||
COMMENT ON COLUMN pln_plans.price IS '套餐价格(CNY)';
|
||||
|
||||
-- 移除订阅特有字段
|
||||
ALTER TABLE pln_plans DROP COLUMN yearly_price;
|
||||
ALTER TABLE pln_plans DROP COLUMN monthly_quota_tokens;
|
||||
ALTER TABLE pln_plans DROP COLUMN allowed_models;
|
||||
|
||||
-- identifier 不再需要唯一约束(改为普通索引),但保留字段
|
||||
ALTER TABLE pln_plans DROP CONSTRAINT uk_pln_plans_identifier;
|
||||
CREATE INDEX idx_pln_plans_identifier ON pln_plans (identifier);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 改造 pln_tenant_plans 表:加入额度字段,移除订阅字段
|
||||
-- ============================================================
|
||||
|
||||
-- 添加额度字段
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN total_credits NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN remaining_credits NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN paid_cny NUMERIC(20,10) DEFAULT 0;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN refunded_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN pln_tenant_plans.total_credits IS '总额度(USD)= credit_amount + bonus_amount';
|
||||
COMMENT ON COLUMN pln_tenant_plans.remaining_credits IS '剩余额度(USD)';
|
||||
COMMENT ON COLUMN pln_tenant_plans.paid_cny IS '实付金额(CNY)';
|
||||
COMMENT ON COLUMN pln_tenant_plans.refunded_at IS '退款时间';
|
||||
|
||||
-- 添加 order_id 列(原 rsp_tenant_packages 有此列,pln_tenant_plans 没有)
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN order_id BIGINT;
|
||||
COMMENT ON COLUMN pln_tenant_plans.order_id IS '关联订单ID';
|
||||
|
||||
-- 移除订阅字段
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN auto_renew;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN monthly_quota_tokens;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN used_tokens;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN last_reset_at;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN cancelled_at;
|
||||
|
||||
-- 添加计费查询索引
|
||||
CREATE INDEX idx_pln_tenant_plans_billing ON pln_tenant_plans (tenant_id, status, end_at);
|
||||
CREATE INDEX idx_pln_tenant_plans_order ON pln_tenant_plans (order_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 重命名计费表中的资源包列
|
||||
-- ============================================================
|
||||
|
||||
-- ord_orders: 删除 package_id(已有 plan_id 可用)
|
||||
ALTER TABLE ord_orders DROP COLUMN IF EXISTS package_id;
|
||||
|
||||
-- bil_records: tenant_package_id → tenant_plan_id, package_deduction → plan_deduction
|
||||
ALTER TABLE bil_records RENAME COLUMN tenant_package_id TO tenant_plan_id;
|
||||
ALTER TABLE bil_records RENAME COLUMN package_deduction TO plan_deduction;
|
||||
|
||||
-- bil_transactions: tenant_package_id → tenant_plan_id
|
||||
ALTER TABLE bil_transactions RENAME COLUMN tenant_package_id TO tenant_plan_id;
|
||||
|
||||
-- bil_usage_logs: tenant_package_id → tenant_plan_id, package_deduction → plan_deduction
|
||||
ALTER TABLE bil_usage_logs RENAME COLUMN tenant_package_id TO tenant_plan_id;
|
||||
ALTER TABLE bil_usage_logs RENAME COLUMN package_deduction TO plan_deduction;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 删除不再需要的表
|
||||
-- ============================================================
|
||||
|
||||
DROP TABLE IF EXISTS rsp_purchase_records;
|
||||
DROP TABLE IF EXISTS rsp_tenant_packages;
|
||||
DROP TABLE IF EXISTS rsp_packages;
|
||||
DROP TABLE IF EXISTS pln_feature_flags;
|
||||
|
||||
-- +goose Down
|
||||
-- 回滚:逆向操作
|
||||
|
||||
-- 4. 恢复删除的表(只有结构,不恢复数据)
|
||||
CREATE TABLE pln_feature_flags (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
feature_key VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
default_enabled BOOLEAN DEFAULT false NOT NULL,
|
||||
enabled BOOLEAN DEFAULT false NOT NULL,
|
||||
source VARCHAR(20) DEFAULT 'manual' NOT NULL,
|
||||
source_id BIGINT,
|
||||
tenant_id BIGINT,
|
||||
plan_id BIGINT,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE rsp_packages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
price NUMERIC(20,10) NOT NULL,
|
||||
credit_amount NUMERIC(20,10) NOT NULL,
|
||||
bonus_amount NUMERIC(20,10) DEFAULT 0 NOT NULL,
|
||||
validity_days INTEGER NOT NULL,
|
||||
model_group_id BIGINT,
|
||||
purchase_limit INTEGER DEFAULT 0 NOT NULL,
|
||||
purchase_limit_period VARCHAR(20) DEFAULT 'lifetime' NOT NULL,
|
||||
stock INTEGER,
|
||||
total_purchased INTEGER DEFAULT 0 NOT NULL,
|
||||
priority_access BOOLEAN DEFAULT false NOT NULL,
|
||||
advanced_log BOOLEAN DEFAULT false NOT NULL,
|
||||
support_level VARCHAR(20) DEFAULT 'standard' NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE rsp_tenant_packages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
package_id BIGINT NOT NULL,
|
||||
order_id BIGINT,
|
||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||
total_credits NUMERIC(20,10) NOT NULL,
|
||||
remaining_credits NUMERIC(20,10) NOT NULL,
|
||||
paid_cny NUMERIC(20,10) NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
expired_at TIMESTAMPTZ,
|
||||
refunded_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_rsp_tenant_packages_billing ON rsp_tenant_packages (tenant_id, status, expires_at);
|
||||
CREATE INDEX idx_rsp_tenant_packages_order ON rsp_tenant_packages (order_id);
|
||||
|
||||
CREATE TABLE rsp_purchase_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
package_id BIGINT NOT NULL,
|
||||
order_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
CONSTRAINT uk_rsp_purchase_records UNIQUE (tenant_id, order_id)
|
||||
);
|
||||
CREATE INDEX idx_rsp_purchase_records_tenant_pkg ON rsp_purchase_records (tenant_id, package_id);
|
||||
|
||||
-- 3. 恢复计费表列名
|
||||
ALTER TABLE bil_usage_logs RENAME COLUMN plan_deduction TO package_deduction;
|
||||
ALTER TABLE bil_usage_logs RENAME COLUMN tenant_plan_id TO tenant_package_id;
|
||||
|
||||
ALTER TABLE bil_transactions RENAME COLUMN tenant_plan_id TO tenant_package_id;
|
||||
|
||||
ALTER TABLE bil_records RENAME COLUMN plan_deduction TO package_deduction;
|
||||
ALTER TABLE bil_records RENAME COLUMN tenant_plan_id TO tenant_package_id;
|
||||
|
||||
ALTER TABLE ord_orders ADD COLUMN IF NOT EXISTS package_id BIGINT;
|
||||
|
||||
-- 2. 恢复 pln_tenant_plans
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN IF EXISTS order_id;
|
||||
DROP INDEX IF EXISTS idx_pln_tenant_plans_order;
|
||||
DROP INDEX IF EXISTS idx_pln_tenant_plans_billing;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN IF EXISTS refunded_at;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN IF EXISTS paid_cny;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN IF EXISTS remaining_credits;
|
||||
ALTER TABLE pln_tenant_plans DROP COLUMN IF EXISTS total_credits;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN cancelled_at TIMESTAMPTZ;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN last_reset_at TIMESTAMPTZ;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN used_tokens BIGINT DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN monthly_quota_tokens BIGINT DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_tenant_plans ADD COLUMN auto_renew BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
-- 1. 恢复 pln_plans
|
||||
ALTER TABLE pln_plans ADD COLUMN allowed_models TEXT[];
|
||||
ALTER TABLE pln_plans ADD COLUMN monthly_quota_tokens BIGINT DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_plans ADD COLUMN yearly_price NUMERIC(20,10) DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE pln_plans RENAME COLUMN price TO monthly_price;
|
||||
DROP INDEX IF EXISTS idx_pln_plans_identifier;
|
||||
ALTER TABLE pln_plans ADD CONSTRAINT uk_pln_plans_identifier UNIQUE (identifier);
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS support_level;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS advanced_log;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS priority_access;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS total_purchased;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS stock;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS purchase_limit_period;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS purchase_limit;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS model_group_id;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS validity_days;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS bonus_amount;
|
||||
ALTER TABLE pln_plans DROP COLUMN IF EXISTS credit_amount;
|
||||
@@ -1,40 +0,0 @@
|
||||
-- +goose Up
|
||||
-- 简化套餐模型访问:用 allowed_models TEXT[] 替代 model_group_id,移除 priority_access、advanced_log、support_level
|
||||
|
||||
-- 1. 添加 allowed_models 列(TEXT 数组,空数组 = 全部模型)
|
||||
ALTER TABLE pln_plans ADD COLUMN allowed_models TEXT[] DEFAULT '{}';
|
||||
COMMENT ON COLUMN pln_plans.allowed_models IS '允许使用的模型列表,空数组=全部模型';
|
||||
|
||||
-- 2. 迁移数据:将 model_group_id 引用的模型组内容写入 allowed_models
|
||||
UPDATE pln_plans p
|
||||
SET allowed_models = COALESCE(
|
||||
(SELECT ARRAY_AGG(mgi.model_name)
|
||||
FROM mdl_model_group_items mgi
|
||||
WHERE mgi.group_id = p.model_group_id),
|
||||
'{}'
|
||||
);
|
||||
|
||||
-- 3. 删除旧列
|
||||
ALTER TABLE pln_plans DROP COLUMN model_group_id;
|
||||
ALTER TABLE pln_plans DROP COLUMN priority_access;
|
||||
ALTER TABLE pln_plans DROP COLUMN advanced_log;
|
||||
ALTER TABLE pln_plans DROP COLUMN support_level;
|
||||
|
||||
-- +goose Down
|
||||
-- 回滚:恢复删除的列,将 allowed_models 转回 model_group_id
|
||||
|
||||
-- 恢复列
|
||||
ALTER TABLE pln_plans ADD COLUMN model_group_id BIGINT DEFAULT 0;
|
||||
COMMENT ON COLUMN pln_plans.model_group_id IS '模型组ID,0=全部模型';
|
||||
|
||||
ALTER TABLE pln_plans ADD COLUMN priority_access BOOLEAN DEFAULT false NOT NULL;
|
||||
COMMENT ON COLUMN pln_plans.priority_access IS '是否包含优先通道访问';
|
||||
|
||||
ALTER TABLE pln_plans ADD COLUMN advanced_log BOOLEAN DEFAULT false NOT NULL;
|
||||
COMMENT ON COLUMN pln_plans.advanced_log IS '是否包含高级日志';
|
||||
|
||||
ALTER TABLE pln_plans ADD COLUMN support_level VARCHAR(20) DEFAULT 'standard' NOT NULL;
|
||||
COMMENT ON COLUMN pln_plans.support_level IS '支持级别:standard/premium/dedicated';
|
||||
|
||||
-- 删除新列
|
||||
ALTER TABLE pln_plans DROP COLUMN allowed_models;
|
||||
@@ -70,7 +70,4 @@ type SettlementResult struct {
|
||||
BillingMode string
|
||||
BillingSource string
|
||||
RateMultiplier float64
|
||||
PlanID int64
|
||||
PlanDeduction float64
|
||||
WalletDeduction float64
|
||||
}
|
||||
@@ -194,6 +194,7 @@ type UsageRecord struct {
|
||||
|
||||
// 异步任务关联
|
||||
TaskID string // 异步任务公开ID(task_xxxxx),普通请求为空
|
||||
|
||||
}
|
||||
|
||||
// AuditRecord 请求审计日志记录
|
||||
|
||||
@@ -36,6 +36,15 @@ const billColumns: TableColumnData[] = [
|
||||
title: '总费用', dataIndex: 'total_cost', width: 100,
|
||||
render({ record }) { return `$${(record.total_cost || 0).toFixed(6)}` },
|
||||
},
|
||||
{
|
||||
title: '扣费来源', dataIndex: 'deduction_source', width: 120,
|
||||
render({ record }) {
|
||||
const tags = []
|
||||
if (record.plan_deduction > 0) tags.push(h(Tag, { size: 'small', type: 'info' }, () => '套餐抵扣'))
|
||||
if (record.wallet_deduction > 0) tags.push(h(Tag, { size: 'small' }, () => '钱包'))
|
||||
return tags.length > 0 ? h('div', { class: 'flex gap-1' }, tags) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render({ record }) {
|
||||
|
||||
@@ -30,7 +30,7 @@ const statusLabel: Record<string, string> = {
|
||||
cancelled: '已取消', refunding: '退款中', refunded: '已退款', refund_failed: '退款失败',
|
||||
}
|
||||
const orderTypeLabel: Record<string, string> = {
|
||||
new_plan: '套餐购买', renew: '续费', upgrade: '升级', downgrade: '降级', recharge: '充值',
|
||||
new_plan: '新购', renew: '续费', upgrade: '升级', downgrade: '降级', recharge: '充值',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
@@ -85,29 +85,12 @@ const refundOrderId = ref<number | null>(null)
|
||||
const refundAmount = ref(0)
|
||||
const refundForm = reactive({ reason: '' })
|
||||
const refundLoading = ref(false)
|
||||
const refundPreview = ref<any>(null)
|
||||
const refundPreviewLoading = ref(false)
|
||||
|
||||
async function openRefundModal(row: any) {
|
||||
function openRefundModal(row: any) {
|
||||
refundOrderId.value = row.id
|
||||
refundForm.reason = ''
|
||||
refundAmount.value = Number(row.final_amount)
|
||||
refundPreview.value = null
|
||||
refundForm.reason = ''
|
||||
showRefundModal.value = true
|
||||
|
||||
// Fetch refund preview
|
||||
refundPreviewLoading.value = true
|
||||
try {
|
||||
const res = await request.get(`/admin/orders/${row.id}/refund-preview`)
|
||||
refundPreview.value = res.data?.data
|
||||
if (refundPreview.value?.can_refund && refundPreview.value?.refund_amount >= 0) {
|
||||
refundAmount.value = refundPreview.value.refund_amount
|
||||
}
|
||||
} catch {
|
||||
// Fallback to order final_amount
|
||||
} finally {
|
||||
refundPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefund(done: () => void) {
|
||||
@@ -165,16 +148,7 @@ const { exporting, exportFile } = useExport({
|
||||
<AModal v-model:visible="showRefundModal" title="发起退款" :width="450" :mask-closable="false" :on-before-ok="handleRefund" :ok-loading="refundLoading">
|
||||
<AForm :model="refundForm" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem label="退款金额">
|
||||
<div v-if="refundPreviewLoading" class="text-gray-400 text-sm">计算中...</div>
|
||||
<template v-else-if="refundPreview && !refundPreview.can_refund">
|
||||
<span class="text-gray-400">{{ refundPreview.message || '不可退款' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-red-500 font-medium">¥{{ refundAmount.toFixed(2) }}</span>
|
||||
<span v-if="refundPreview?.order_type === 'new_plan'" class="text-xs text-gray-400 ml-2">
|
||||
(套餐按比例退款)
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-red-500 font-medium">¥{{ refundAmount.toFixed(2) }}</span>
|
||||
</AFormItem>
|
||||
<AFormItem label="退款原因" required>
|
||||
<AInput v-model="refundForm.reason" type="textarea" :auto-size="{ minRows: 3, maxRows: 5 }" placeholder="请填写退款原因" />
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useExport } from '@/composables/useExport'
|
||||
|
||||
const loading = ref(false)
|
||||
const plans = ref<any[]>([])
|
||||
const allModels = ref<string[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50] })
|
||||
const statusFilter = ref<string | undefined>(undefined)
|
||||
const statusOptions = [
|
||||
@@ -20,30 +19,20 @@ const statusOptions = [
|
||||
]
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '标识', dataIndex: 'identifier', width: 90 },
|
||||
{ title: '价格(CNY)', dataIndex: 'price', width: 100, render({ record }) { return `¥${Number(record.price).toFixed(2)}` } },
|
||||
{ title: '额度(USD)', dataIndex: 'credit_amount', width: 100, render({ record }) { return `$${Number(record.credit_amount).toFixed(2)}` } },
|
||||
{ title: '赠送(USD)', dataIndex: 'bonus_amount', width: 90, render({ record }) { return record.bonus_amount > 0 ? `$${Number(record.bonus_amount).toFixed(2)}` : '-' } },
|
||||
{ title: '有效期(天)', dataIndex: 'validity_days', width: 90 },
|
||||
{ title: '可用模型', dataIndex: 'allowed_models', width: 120, render({ record }) {
|
||||
const models = record.allowed_models
|
||||
if (!models || models.length === 0) return '全部'
|
||||
if (models.length <= 2) return models.join(', ')
|
||||
return `${models.slice(0, 2).join(', ')}... (共${models.length}个)`
|
||||
} },
|
||||
{ title: '库存', dataIndex: 'stock', width: 70, render({ record }) { return record.stock === null || record.stock === 0 ? '不限' : record.stock } },
|
||||
{ title: '已购', dataIndex: 'total_purchased', width: 60 },
|
||||
{ title: '标识', dataIndex: 'identifier', width: 100 },
|
||||
{ title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return `¥${Number(record.monthly_price).toFixed(2)}` } },
|
||||
{ title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return `¥${Number(record.yearly_price).toFixed(2)}` } },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 70,
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render({ record }) { return h(Tag, { color: record.status === 'active' ? 'green' : undefined, size: 'small' }, () => record.status === 'active' ? '上架' : '下架') },
|
||||
},
|
||||
{
|
||||
title: '推荐', dataIndex: 'is_recommended', width: 60,
|
||||
title: '推荐', dataIndex: 'is_recommended', width: 70,
|
||||
render({ record }) { return h(Tag, { color: record.is_recommended ? 'orangered' : undefined, size: 'small' }, () => record.is_recommended ? '推荐' : '-') },
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sort_order', width: 60 },
|
||||
{ title: '排序', dataIndex: 'sort_order', width: 70 },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 200, fixed: 'right',
|
||||
render({ record }) {
|
||||
@@ -68,57 +57,22 @@ async function fetchPlans() {
|
||||
} catch { /* interceptor handles error toast */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function fetchAllModels() {
|
||||
try {
|
||||
const names: string[] = []
|
||||
let page = 1
|
||||
while (true) {
|
||||
const res = await request.get('/admin/models', { params: { page, page_size: 100 } })
|
||||
const list = res.data?.data?.list || []
|
||||
for (const m of list) names.push(m.model_id)
|
||||
const total = res.data?.data?.total || 0
|
||||
if (page * 100 >= total) break
|
||||
page++
|
||||
}
|
||||
allModels.value = names
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance | null>(null)
|
||||
const formLoading = ref(false)
|
||||
const formData = reactive({
|
||||
name: '', identifier: '', description: '',
|
||||
price: 0, credit_amount: 0, bonus_amount: 0, validity_days: 30,
|
||||
allowed_models: [] as string[], purchase_limit: 0, purchase_limit_period: 'lifetime',
|
||||
stock: null as number | null, sort_order: 0, is_recommended: false,
|
||||
name: '', identifier: '', description: '', monthly_price: 0, yearly_price: 0,
|
||||
sort_order: 0, is_recommended: false,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, {
|
||||
name: '', identifier: '', description: '',
|
||||
price: 0, credit_amount: 0, bonus_amount: 0, validity_days: 30,
|
||||
allowed_models: [], purchase_limit: 0, purchase_limit_period: 'lifetime',
|
||||
stock: null, sort_order: 0, is_recommended: false,
|
||||
})
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(row: any) {
|
||||
editingId.value = row.id
|
||||
Object.assign(formData, {
|
||||
name: row.name, identifier: row.identifier, description: row.description || '',
|
||||
price: row.price, credit_amount: row.credit_amount, bonus_amount: row.bonus_amount || 0,
|
||||
validity_days: row.validity_days, allowed_models: row.allowed_models || [],
|
||||
purchase_limit: row.purchase_limit || 0, purchase_limit_period: row.purchase_limit_period || 'lifetime',
|
||||
stock: row.stock, sort_order: row.sort_order || 0, is_recommended: row.is_recommended || false,
|
||||
})
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -126,7 +80,7 @@ async function handleSubmit() {
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await request.put(`/admin/plans/${editingId.value}`, { update: { ...formData } })
|
||||
await request.put(`/admin/plans/${editingId.value}`, { ...formData, status: undefined, created_at: undefined, updated_at: undefined, id: undefined, version: undefined })
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
await request.post('/admin/plans', formData)
|
||||
@@ -154,10 +108,7 @@ function archivePlan(row: any) {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlans()
|
||||
fetchAllModels()
|
||||
})
|
||||
onMounted(fetchPlans)
|
||||
|
||||
const { exporting, exportFile } = useExport({
|
||||
url: '/admin/plans/export',
|
||||
@@ -169,7 +120,7 @@ const { exporting, exportFile } = useExport({
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="套餐管理" description="管理平台套餐(额度包)定义和定价">
|
||||
<PageHeader title="套餐管理" description="管理平台套餐定义和定价">
|
||||
<template #actions>
|
||||
<AButton type="primary" @click="openCreateModal">创建套餐</AButton>
|
||||
<ADropdown trigger="hover">
|
||||
@@ -189,7 +140,7 @@ const { exporting, exportFile } = useExport({
|
||||
<ASelect v-model="statusFilter" :options="statusOptions" style="width: 120px" allow-clear @change="() => { pagination.current = 1; fetchPlans() }" />
|
||||
</div>
|
||||
</template>
|
||||
<ATable :columns="columns" :data="plans" :loading="loading" row-key="id" :scroll="{ x: 1400 }" :pagination="false" />
|
||||
<ATable :columns="columns" :data="plans" :loading="loading" row-key="id" :scroll="{ x: 1200 }" :pagination="false" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchPlans" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchPlans() }" />
|
||||
</div>
|
||||
@@ -197,38 +148,14 @@ const { exporting, exportFile } = useExport({
|
||||
|
||||
<ADrawer v-model:visible="showModal" :title="editingId ? '编辑套餐' : '创建套餐'" :width="520" :mask-closable="false" :footer="true" unmount-on-close>
|
||||
<AForm ref="formRef" :model="formData" :auto-label-width="true" layout="vertical">
|
||||
<ADivider>基本信息</ADivider>
|
||||
<AFormItem label="名称" required><AInput v-model="formData.name" placeholder="如:基础套餐" /></AFormItem>
|
||||
<AFormItem label="标识" required><AInput v-model="formData.identifier" placeholder="如:basic" :disabled="!!editingId" /></AFormItem>
|
||||
<AFormItem label="名称" required><AInput v-model="formData.name" placeholder="如:专业版" /></AFormItem>
|
||||
<AFormItem label="标识" required><AInput v-model="formData.identifier" placeholder="如:pro" :disabled="!!editingId" /></AFormItem>
|
||||
<AFormItem label="描述"><AInput v-model="formData.description" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="面向用户的营销文案" /></AFormItem>
|
||||
|
||||
<ADivider>定价与额度</ADivider>
|
||||
<AFormItem label="价格(CNY)" required><AInputNumber v-model="formData.price" :min="0.01" :precision="2" class="w-full" placeholder="购买价格" /></AFormItem>
|
||||
<AFormItem label="额度(USD)" required><AInputNumber v-model="formData.credit_amount" :min="0.01" :precision="2" class="w-full" placeholder="包含的 USD 额度" /></AFormItem>
|
||||
<AFormItem label="赠送额度(USD)"><AInputNumber v-model="formData.bonus_amount" :min="0" :precision="2" class="w-full" placeholder="额外赠送额度" /></AFormItem>
|
||||
<AFormItem label="有效天数" required><AInputNumber v-model="formData.validity_days" :min="1" class="w-full" placeholder="从激活时起算" /></AFormItem>
|
||||
|
||||
<ADivider>使用限制</ADivider>
|
||||
<AFormItem label="可用模型">
|
||||
<ASelect v-model="formData.allowed_models" :options="allModels.map(m => ({ label: m, value: m }))" multiple allow-clear filterable placeholder="留空 = 全部模型" />
|
||||
</AFormItem>
|
||||
<AGrid :cols="2" :col-gap="12">
|
||||
<AFormItem label="限购数量"><AInputNumber v-model="formData.purchase_limit" :min="0" class="w-full" placeholder="0 = 不限购" /></AFormItem>
|
||||
<AFormItem label="限购周期">
|
||||
<ASelect v-model="formData.purchase_limit_period">
|
||||
<AOption value="lifetime">终身</AOption>
|
||||
<AOption value="monthly">每月</AOption>
|
||||
<AOption value="yearly">每年</AOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
</AGrid>
|
||||
<AFormItem label="库存"><AInputNumber v-model="formData.stock" :min="0" class="w-full" placeholder="留空 = 不限库存" /></AFormItem>
|
||||
|
||||
<ADivider>其他设置</ADivider>
|
||||
<AGrid :cols="2" :col-gap="12">
|
||||
<AFormItem label="排序权重"><AInputNumber v-model="formData.sort_order" :min="0" class="w-full" placeholder="越小越靠前" /></AFormItem>
|
||||
<AFormItem label="推荐"><ASwitch v-model="formData.is_recommended" /></AFormItem>
|
||||
</AGrid>
|
||||
<AFormItem label="月价格(CNY)"><AInputNumber v-model="formData.monthly_price" :min="0" :precision="2" class="w-full" /></AFormItem>
|
||||
<AFormItem label="年价格(CNY)"><AInputNumber v-model="formData.yearly_price" :min="0" :precision="2" class="w-full" /></AFormItem>
|
||||
<AFormItem label="月Token配额"><AInputNumber v-model="formData.monthly_quota_tokens" :min="0" class="w-full" placeholder="0 = 不限制" /></AFormItem>
|
||||
<AFormItem label="排序权重"><AInputNumber v-model="formData.sort_order" :min="0" class="w-full" placeholder="越小越靠前" /></AFormItem>
|
||||
<AFormItem label="推荐"><ASwitch v-model="formData.is_recommended" /></AFormItem>
|
||||
</AForm>
|
||||
<template #footer>
|
||||
<Space>
|
||||
|
||||
@@ -114,6 +114,7 @@ onMounted(fetchTransactions)
|
||||
<tr>
|
||||
<th class="min-w-20">类型</th>
|
||||
<th class="min-w-30">金额</th>
|
||||
<th class="min-w-30">扣费来源</th>
|
||||
<th class="min-w-30">余额</th>
|
||||
<th class="min-w-35">用户</th>
|
||||
<th class="min-w-50">请求ID</th>
|
||||
@@ -132,6 +133,13 @@ onMounted(fetchTransactions)
|
||||
<td :class="tx.amount >= 0 ? 'text-emerald-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ formatAmount(tx.amount) }}
|
||||
</td>
|
||||
<td class="text-gray-700">
|
||||
<template v-if="tx.type === 'consume' || tx.type === 'settle'">
|
||||
<span v-if="tx.plan_deduction > 0" class="badge badge-primary text-xs mr-1">套餐抵扣</span>
|
||||
<span v-if="tx.wallet_deduction > 0" class="badge badge-gray text-xs">钱包</span>
|
||||
</template>
|
||||
<template v-else>--</template>
|
||||
</td>
|
||||
<td class="text-gray-700">${{ tx.balance_after?.toFixed(6) ?? '--' }}</td>
|
||||
<td class="text-gray-700 text-sm">{{ tx.username || '--' }}</td>
|
||||
<td class="text-gray-500 text-xs font-mono">{{ tx.request_id || '--' }}</td>
|
||||
|
||||
@@ -24,6 +24,7 @@ const plans = ref<any[]>([])
|
||||
const currentPlan = ref<any>(null)
|
||||
const showConfirm = ref(false)
|
||||
const selectedPlan = ref<any>(null)
|
||||
const selectedMonths = ref(1)
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
// Frozen items
|
||||
@@ -42,6 +43,13 @@ const redeemHistoryLoading = ref(false)
|
||||
const redeemTypeLabels: Record<string, string> = { quota: '额度', plan: '套餐', duration: '时长' }
|
||||
const redeemTypeBadgeClasses: Record<string, string> = { quota: 'badge-success', plan: 'badge-primary', duration: 'badge-warning' }
|
||||
|
||||
const monthsOptions = [
|
||||
{ label: '1 个月', value: 1 },
|
||||
{ label: '3 个月', value: 3 },
|
||||
{ label: '6 个月', value: 6 },
|
||||
{ label: '12 个月(优惠)', value: 12 },
|
||||
]
|
||||
|
||||
const currentPlanId = computed(() => currentPlan.value?.plan_id)
|
||||
|
||||
// Computed: preset amounts from payment settings
|
||||
@@ -104,18 +112,13 @@ async function fetchPaymentInfo() {
|
||||
async function fetchPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [plansRes, mineRes] = await Promise.all([
|
||||
const [plansRes, currentRes] = await Promise.all([
|
||||
request.get('/tenant/plans'),
|
||||
request.get('/tenant/plans/mine'),
|
||||
request.get('/tenant/plan/current').catch(() => ({ data: { data: null } })),
|
||||
])
|
||||
const raw = plansRes.data?.data
|
||||
plans.value = Array.isArray(raw) ? raw : (raw?.list || [])
|
||||
const mineData = mineRes.data?.data
|
||||
if (mineData?.list?.length) {
|
||||
currentPlan.value = mineData.list.find((p: any) => p.status === 'active') || null
|
||||
} else {
|
||||
currentPlan.value = null
|
||||
}
|
||||
plans.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
|
||||
currentPlan.value = currentRes.data?.data || null
|
||||
} catch {
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
@@ -161,33 +164,31 @@ async function handleRecharge() {
|
||||
}
|
||||
}
|
||||
|
||||
function getPlanPrice(plan: any) {
|
||||
return Number(plan.price || 0)
|
||||
function calcPrice(plan: any, months: number) {
|
||||
if (months >= 12) return Number(plan.yearly_price || 0)
|
||||
return Number(plan.monthly_price || 0) * months
|
||||
}
|
||||
|
||||
function openConfirm(plan: any) {
|
||||
selectedPlan.value = plan
|
||||
selectedChannel.value = ''
|
||||
selectedPaymentMethod.value = ''
|
||||
selectedMonths.value = 1
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/tenant/plans/orders', {
|
||||
const res = await request.post('/tenant/orders/create', {
|
||||
order_type: 'new_plan',
|
||||
plan_id: selectedPlan.value.id,
|
||||
payment_channel: selectedChannel.value,
|
||||
payment_method: selectedPaymentMethod.value,
|
||||
months: selectedMonths.value,
|
||||
})
|
||||
const data = res.data?.data
|
||||
if (data?.payment_url) {
|
||||
window.location.href = data.payment_url
|
||||
return
|
||||
const order = res.data?.data
|
||||
if (order?.id) {
|
||||
await request.post(`/tenant/orders/${order.id}/pay`)
|
||||
}
|
||||
showConfirm.value = false
|
||||
await fetchPlans()
|
||||
fetchWallet()
|
||||
} catch {
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
@@ -519,12 +520,12 @@ onBeforeUnmount(() => {
|
||||
<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.price).toFixed(2) }}</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">额度</span>
|
||||
<span v-if="currentPlanDetail.credit_amount > 0" class="font-medium text-gray-700">
|
||||
${{ Number(currentPlanDetail.credit_amount).toFixed(2) }}
|
||||
<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>
|
||||
@@ -631,10 +632,13 @@ onBeforeUnmount(() => {
|
||||
<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.price).toFixed(2) }}
|
||||
{{ 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>
|
||||
|
||||
<!-- Features -->
|
||||
@@ -644,7 +648,7 @@ onBeforeUnmount(() => {
|
||||
<Icon name="check" size="xs" />
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">
|
||||
额度 ${{ Number(plan.credit_amount).toFixed(2) }}
|
||||
{{ 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">
|
||||
@@ -669,7 +673,7 @@ onBeforeUnmount(() => {
|
||||
:class="plan.is_recommended ? 'btn-primary' : 'btn-secondary hover:!border-primary-300 hover:!text-primary-600'"
|
||||
@click="openConfirm(plan)"
|
||||
>
|
||||
立即购买
|
||||
{{ Number(plan.monthly_price) === 0 ? '免费开通' : '立即购买' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -677,16 +681,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Confirm Purchase Modal -->
|
||||
<!-- Confirm Subscribe Modal -->
|
||||
<!-- ============================================ -->
|
||||
<Teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showConfirm" class="modal-overlay">
|
||||
<div v-if="showConfirm" class="modal-overlay" @click.self="showConfirm = false">
|
||||
<div class="modal-content w-full max-w-md">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">确认购买</h3>
|
||||
<h3 class="modal-title">确认订阅</h3>
|
||||
<button @click="showConfirm = false" class="btn-ghost btn-icon">
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
@@ -702,60 +705,45 @@ onBeforeUnmount(() => {
|
||||
<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-2">
|
||||
<button
|
||||
v-for="opt in monthsOptions"
|
||||
:key="opt.value"
|
||||
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'"
|
||||
@click="selectedMonths = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
¥{{ getPlanPrice(selectedPlan).toFixed(2) }}
|
||||
¥{{ calcPrice(selectedPlan, selectedMonths).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Payment Method Selection -->
|
||||
<div v-if="payMethods.length > 0">
|
||||
<label class="input-label">支付方式</label>
|
||||
<div class="grid grid-cols-2 gap-2 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-4 text-center">
|
||||
<p class="text-sm text-gray-400">暂无可用的支付渠道</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="showConfirm = false" class="btn btn-secondary">取消</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="confirmLoading || !selectedChannel || !selectedPaymentMethod"
|
||||
:disabled="confirmLoading"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
<template v-if="confirmLoading">
|
||||
<span class="spinner"></span>
|
||||
处理中...
|
||||
</template>
|
||||
<template v-else>确认购买</template>
|
||||
<template v-else>确认订阅</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -764,7 +752,7 @@ onBeforeUnmount(() => {
|
||||
</Teleport>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Frozen Items Modal -->
|
||||
<!-- Frozen Items Modal -->
|
||||
<!-- ============================================ -->
|
||||
<Teleport to="body">
|
||||
<transition name="modal">
|
||||
|
||||
@@ -756,6 +756,13 @@ onMounted(() => {
|
||||
<span class="text-gray-700">实际费用</span>
|
||||
<span class="text-emerald-600">{{ formatCost(detailLog.actual_cost || 0) }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.plan_deduction > 0 || detailLog.wallet_deduction > 0" class="flex items-center justify-between">
|
||||
<span class="text-gray-500">扣费来源</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span v-if="detailLog.plan_deduction > 0" class="inline-flex items-center rounded-full bg-primary-50 px-2 py-0.5 text-xs font-medium text-primary-700">套餐 ${{ formatCost(detailLog.plan_deduction) }}</span>
|
||||
<span v-if="detailLog.wallet_deduction > 0" class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">余额 ${{ formatCost(detailLog.wallet_deduction) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="detailLog.rate_multiplier && detailLog.rate_multiplier !== 1" class="flex items-center justify-between">
|
||||
<span class="text-gray-500">费率倍率</span>
|
||||
<span class="font-medium" :class="Number(detailLog.rate_multiplier) < 1 ? 'text-emerald-600' : 'text-amber-600'">{{ Number(detailLog.rate_multiplier).toFixed(4) }}x</span>
|
||||
|
||||
Reference in new issue
Block a user