refactor(plan):移除套餐相关的所有修改

This commit is contained in:
qian committed 2026-05-25 22:26:32 +08:00
1 parent 8480d06d21
commit 5d48dbcdc2
73 files changed
+958 -2306

No files matched your search

-1
View File
@@ -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)
+1 -17
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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)
+2 -8
View File
@@ -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"`
}
+32 -43
View File
@@ -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{}
-3
View File
@@ -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)
}
-6
View File
@@ -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.
-6
View File
@@ -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)
}
-91
View File
@@ -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)
}
+26 -36
View File
@@ -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.
+26 -26
View File
@@ -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.
-22
View File
@@ -1,22 +0,0 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// 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.
+2 -8
View File
@@ -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")
}
+22 -97
View File
@@ -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 手动完成订单
+57 -78
View File
@@ -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
}
-216
View File
@@ -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 -27
View File
@@ -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,
}
}
+34 -137
View File
@@ -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
}
-6
View File
@@ -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,
}
}
+12 -73
View File
@@ -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
+31 -1
View File
@@ -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.
+56 -3
View File
@@ -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
}
+7 -3
View File
@@ -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 {
-37
View File
@@ -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
}
+42 -53
View File
@@ -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 钱包入账
-131
View File
@@ -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
}
+125 -3
View File
@@ -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
}
+1
View File
@@ -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)
+30 -19
View File
@@ -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
View File
@@ -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
}
+4
View File
@@ -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, "请求过于频繁,请稍后重试")
+9 -20
View File
@@ -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 {
-3
View File
@@ -45,7 +45,4 @@ type BilRecords struct {
BaseInputPrice any // 基础模型输入单价(快照,应用倍率前)
BaseOutputPrice any // 基础模型输出单价(快照,应用倍率前)
BillingSnapshot any // 完整计费计算过程快照(JSONB)
TenantPlanId any //
PlanDeduction any //
WalletDeduction any //
}
-1
View File
@@ -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 //
}
-3
View File
@@ -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 //
}
-22
View File
@@ -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 //
}
+26
View File
@@ -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 // 更新时间
}
+14 -19
View File
@@ -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 // 更新时间
}
+14 -14
View File
@@ -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 // 更新时间
}
-3
View File
@@ -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:""` //
}
-3
View File
@@ -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:""` //
}
-20
View File
@@ -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:"更新时间"` // 更新时间
}
+13 -18
View File
@@ -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:"更新时间"` // 更新时间
}
+13 -13
View File
@@ -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:"更新时间"` // 更新时间
}
-2
View File
@@ -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 读取)
+4 -4
View File
@@ -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)
-155
View File
@@ -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;
-3
View File
@@ -70,7 +70,4 @@ type SettlementResult struct {
BillingMode string
BillingSource string
RateMultiplier float64
PlanID int64
PlanDeduction float64
WalletDeduction float64
}
+1
View File
@@ -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 }) {
+4 -30
View File
@@ -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="请填写退款原因" />
+20 -93
View File
@@ -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>
+58 -70
View File
@@ -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>