diff --git a/api/admin/admin.go b/api/admin/admin.go index a10d1fc..ebfa2dd 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -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) diff --git a/api/admin/v1/order_admin.go b/api/admin/v1/order_admin.go index 39f3d4a..db93256 100644 --- a/api/admin/v1/order_admin.go +++ b/api/admin/v1/order_admin.go @@ -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:"手动完成订单"` diff --git a/api/admin/v1/plan.go b/api/admin/v1/plan.go index de0fc85..d9a0fa1 100644 --- a/api/admin/v1/plan.go +++ b/api/admin/v1/plan.go @@ -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 { diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index 07e05b3..ff59390 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -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) diff --git a/api/tenant/v1/order_tenant.go b/api/tenant/v1/order_tenant.go index d5ee64c..0ae1962 100644 --- a/api/tenant/v1/order_tenant.go +++ b/api/tenant/v1/order_tenant.go @@ -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"` } diff --git a/api/tenant/v1/plan_tenant.go b/api/tenant/v1/plan_tenant.go index 1fe3287..d0c67f2 100644 --- a/api/tenant/v1/plan_tenant.go +++ b/api/tenant/v1/plan_tenant.go @@ -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{} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index c590c71..bfba4a0 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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) diff --git a/internal/controller/admin/admin_v1_order_refund_preview.go b/internal/controller/admin/admin_v1_order_refund_preview.go deleted file mode 100644 index ec1ca75..0000000 --- a/internal/controller/admin/admin_v1_order_refund_preview.go +++ /dev/null @@ -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) -} diff --git a/internal/controller/tenant/tenant_v1_tenant_plan_cancel_auto_renew.go b/internal/controller/tenant/tenant_v1_tenant_plan_cancel_auto_renew.go new file mode 100644 index 0000000..9b0ac32 --- /dev/null +++ b/internal/controller/tenant/tenant_v1_tenant_plan_cancel_auto_renew.go @@ -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) +} diff --git a/internal/controller/tenant/tenant_v1_tenant_plan_current.go b/internal/controller/tenant/tenant_v1_tenant_plan_current.go new file mode 100644 index 0000000..301d074 --- /dev/null +++ b/internal/controller/tenant/tenant_v1_tenant_plan_current.go @@ -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) +} diff --git a/internal/controller/tenant/tenant_v1_tenant_plan_mine.go b/internal/controller/tenant/tenant_v1_tenant_plan_mine.go deleted file mode 100644 index 252d266..0000000 --- a/internal/controller/tenant/tenant_v1_tenant_plan_mine.go +++ /dev/null @@ -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) -} diff --git a/internal/controller/tenant/tenant_v1_tenant_plan_order_create.go b/internal/controller/tenant/tenant_v1_tenant_plan_order_create.go deleted file mode 100644 index 36ca8fa..0000000 --- a/internal/controller/tenant/tenant_v1_tenant_plan_order_create.go +++ /dev/null @@ -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) -} diff --git a/internal/dao/internal/bil_records.go b/internal/dao/internal/bil_records.go index 973ac52..7510e2f 100644 --- a/internal/dao/internal/bil_records.go +++ b/internal/dao/internal/bil_records.go @@ -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. diff --git a/internal/dao/internal/bil_transactions.go b/internal/dao/internal/bil_transactions.go index a943cad..78700d9 100644 --- a/internal/dao/internal/bil_transactions.go +++ b/internal/dao/internal/bil_transactions.go @@ -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. diff --git a/internal/dao/internal/bil_usage_logs.go b/internal/dao/internal/bil_usage_logs.go index ba6755e..3c5cfdb 100644 --- a/internal/dao/internal/bil_usage_logs.go +++ b/internal/dao/internal/bil_usage_logs.go @@ -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. diff --git a/internal/dao/internal/mdl_model_group_items.go b/internal/dao/internal/mdl_model_group_items.go deleted file mode 100644 index e06d6c1..0000000 --- a/internal/dao/internal/mdl_model_group_items.go +++ /dev/null @@ -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) -} diff --git a/internal/dao/internal/mdl_model_groups.go b/internal/dao/internal/mdl_model_groups.go deleted file mode 100644 index 557cfc4..0000000 --- a/internal/dao/internal/mdl_model_groups.go +++ /dev/null @@ -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) -} diff --git a/internal/dao/internal/pln_feature_flags.go b/internal/dao/internal/pln_feature_flags.go new file mode 100644 index 0000000..c131ff4 --- /dev/null +++ b/internal/dao/internal/pln_feature_flags.go @@ -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) +} diff --git a/internal/dao/internal/pln_plans.go b/internal/dao/internal/pln_plans.go index aebd239..d0c1c93 100644 --- a/internal/dao/internal/pln_plans.go +++ b/internal/dao/internal/pln_plans.go @@ -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. diff --git a/internal/dao/internal/pln_tenant_plans.go b/internal/dao/internal/pln_tenant_plans.go index 90c418e..e12ed98 100644 --- a/internal/dao/internal/pln_tenant_plans.go +++ b/internal/dao/internal/pln_tenant_plans.go @@ -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. diff --git a/internal/dao/mdl_model_group_items.go b/internal/dao/mdl_model_group_items.go deleted file mode 100644 index 2dac435..0000000 --- a/internal/dao/mdl_model_group_items.go +++ /dev/null @@ -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. diff --git a/internal/dao/mdl_model_groups.go b/internal/dao/pln_feature_flags.go similarity index 60% rename from internal/dao/mdl_model_groups.go rename to internal/dao/pln_feature_flags.go index 78b7ea4..aa46674 100644 --- a/internal/dao/mdl_model_groups.go +++ b/internal/dao/pln_feature_flags.go @@ -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. diff --git a/internal/handler/public/payment.go b/internal/handler/public/payment.go index 782475d..65a5bca 100644 --- a/internal/handler/public/payment.go +++ b/internal/handler/public/payment.go @@ -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") } diff --git a/internal/logic/admin/order.go b/internal/logic/admin/order.go index 6e07ed8..7627a07 100644 --- a/internal/logic/admin/order.go +++ b/internal/logic/admin/order.go @@ -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 手动完成订单 diff --git a/internal/logic/admin/plan.go b/internal/logic/admin/plan.go index 328bccb..c1b160b 100644 --- a/internal/logic/admin/plan.go +++ b/internal/logic/admin/plan.go @@ -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 } diff --git a/internal/logic/billing/plan_deduction.go b/internal/logic/billing/plan_deduction.go deleted file mode 100644 index f3757c1..0000000 --- a/internal/logic/billing/plan_deduction.go +++ /dev/null @@ -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)) -} diff --git a/internal/logic/billing/provider.go b/internal/logic/billing/provider.go index d224bdb..4779090 100644 --- a/internal/logic/billing/provider.go +++ b/internal/logic/billing/provider.go @@ -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, } } diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 3bf2716..41b3316 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -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 } diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index 8daad3f..38c5646 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -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, } } diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 73b43f4..49982b4 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -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 diff --git a/internal/logic/common/cache.go b/internal/logic/common/cache.go index d365ca6..337d9e0 100644 --- a/internal/logic/common/cache.go +++ b/internal/logic/common/cache.go @@ -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. diff --git a/internal/logic/common/feature_flag.go b/internal/logic/common/feature_flag.go index 9fccd87..11291b8 100644 --- a/internal/logic/common/feature_flag.go +++ b/internal/logic/common/feature_flag.go @@ -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 } diff --git a/internal/logic/payment/callback.go b/internal/logic/payment/callback.go index 99ac3f6..9762fa9 100644 --- a/internal/logic/payment/callback.go +++ b/internal/logic/payment/callback.go @@ -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 { diff --git a/internal/logic/payment/epay.go b/internal/logic/payment/epay.go index 4234f56..5c87bdb 100644 --- a/internal/logic/payment/epay.go +++ b/internal/logic/payment/epay.go @@ -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 -} diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go index c6dc623..d552b4c 100644 --- a/internal/logic/payment/fulfill.go +++ b/internal/logic/payment/fulfill.go @@ -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 钱包入账 diff --git a/internal/logic/payment/refund.go b/internal/logic/payment/refund.go deleted file mode 100644 index 044004d..0000000 --- a/internal/logic/payment/refund.go +++ /dev/null @@ -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 -} diff --git a/internal/logic/task/auto_renew.go b/internal/logic/task/auto_renew.go index 61901dc..3a9f971 100644 --- a/internal/logic/task/auto_renew.go +++ b/internal/logic/task/auto_renew.go @@ -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) } diff --git a/internal/logic/task/order_expiration_cron.go b/internal/logic/task/order_expiration_cron.go new file mode 100644 index 0000000..02acf85 --- /dev/null +++ b/internal/logic/task/order_expiration_cron.go @@ -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 +} diff --git a/internal/logic/task/plan_expiration_cron.go b/internal/logic/task/plan_expiration_cron.go deleted file mode 100644 index d442d74..0000000 --- a/internal/logic/task/plan_expiration_cron.go +++ /dev/null @@ -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 -} diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 79bab9f..c4bf633 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -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) diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index 59a48d3..2f9058f 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -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. diff --git a/internal/logic/tenant/plan.go b/internal/logic/tenant/plan.go index 28991d7..4bba674 100644 --- a/internal/logic/tenant/plan.go +++ b/internal/logic/tenant/plan.go @@ -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 } diff --git a/internal/logic/tenant/playground.go b/internal/logic/tenant/playground.go index e393abd..6dda624 100644 --- a/internal/logic/tenant/playground.go +++ b/internal/logic/tenant/playground.go @@ -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, "请求过于频繁,请稍后重试") diff --git a/internal/logic/tenant/redemption.go b/internal/logic/tenant/redemption.go index 14cb293..8876c4a 100644 --- a/internal/logic/tenant/redemption.go +++ b/internal/logic/tenant/redemption.go @@ -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 { diff --git a/internal/model/do/bil_records.go b/internal/model/do/bil_records.go index a73dfc3..df9da09 100644 --- a/internal/model/do/bil_records.go +++ b/internal/model/do/bil_records.go @@ -45,7 +45,4 @@ type BilRecords struct { BaseInputPrice any // 基础模型输入单价(快照,应用倍率前) BaseOutputPrice any // 基础模型输出单价(快照,应用倍率前) BillingSnapshot any // 完整计费计算过程快照(JSONB) - TenantPlanId any // - PlanDeduction any // - WalletDeduction any // } diff --git a/internal/model/do/bil_transactions.go b/internal/model/do/bil_transactions.go index 59d8491..773fdef 100644 --- a/internal/model/do/bil_transactions.go +++ b/internal/model/do/bil_transactions.go @@ -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 // } diff --git a/internal/model/do/bil_usage_logs.go b/internal/model/do/bil_usage_logs.go index 394cf75..d45a98b 100644 --- a/internal/model/do/bil_usage_logs.go +++ b/internal/model/do/bil_usage_logs.go @@ -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 // } diff --git a/internal/model/do/mdl_model_group_items.go b/internal/model/do/mdl_model_group_items.go deleted file mode 100644 index f925845..0000000 --- a/internal/model/do/mdl_model_group_items.go +++ /dev/null @@ -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 // -} diff --git a/internal/model/do/mdl_model_groups.go b/internal/model/do/mdl_model_groups.go deleted file mode 100644 index 08ccf49..0000000 --- a/internal/model/do/mdl_model_groups.go +++ /dev/null @@ -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 // -} diff --git a/internal/model/do/pln_feature_flags.go b/internal/model/do/pln_feature_flags.go new file mode 100644 index 0000000..14d7df6 --- /dev/null +++ b/internal/model/do/pln_feature_flags.go @@ -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 // 更新时间 +} diff --git a/internal/model/do/pln_plans.go b/internal/model/do/pln_plans.go index 4039cd8..b3ff346 100644 --- a/internal/model/do/pln_plans.go +++ b/internal/model/do/pln_plans.go @@ -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 // 更新时间 } diff --git a/internal/model/do/pln_tenant_plans.go b/internal/model/do/pln_tenant_plans.go index f222e38..b67416f 100644 --- a/internal/model/do/pln_tenant_plans.go +++ b/internal/model/do/pln_tenant_plans.go @@ -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 // 更新时间 } diff --git a/internal/model/entity/bil_records.go b/internal/model/entity/bil_records.go index eca6b8e..7275875 100644 --- a/internal/model/entity/bil_records.go +++ b/internal/model/entity/bil_records.go @@ -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:""` // } diff --git a/internal/model/entity/bil_transactions.go b/internal/model/entity/bil_transactions.go index 519e051..094a48e 100644 --- a/internal/model/entity/bil_transactions.go +++ b/internal/model/entity/bil_transactions.go @@ -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:""` // } diff --git a/internal/model/entity/bil_usage_logs.go b/internal/model/entity/bil_usage_logs.go index 2dce9bc..1852fbc 100644 --- a/internal/model/entity/bil_usage_logs.go +++ b/internal/model/entity/bil_usage_logs.go @@ -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:""` // } diff --git a/internal/model/entity/mdl_model_group_items.go b/internal/model/entity/mdl_model_group_items.go deleted file mode 100644 index ef3c6b6..0000000 --- a/internal/model/entity/mdl_model_group_items.go +++ /dev/null @@ -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:""` // -} diff --git a/internal/model/entity/mdl_model_groups.go b/internal/model/entity/mdl_model_groups.go deleted file mode 100644 index e1e345d..0000000 --- a/internal/model/entity/mdl_model_groups.go +++ /dev/null @@ -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:""` // -} diff --git a/internal/model/entity/pln_feature_flags.go b/internal/model/entity/pln_feature_flags.go new file mode 100644 index 0000000..5834c1b --- /dev/null +++ b/internal/model/entity/pln_feature_flags.go @@ -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:"更新时间"` // 更新时间 +} diff --git a/internal/model/entity/pln_plans.go b/internal/model/entity/pln_plans.go index d545e12..5a710a2 100644 --- a/internal/model/entity/pln_plans.go +++ b/internal/model/entity/pln_plans.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/pln_tenant_plans.go b/internal/model/entity/pln_tenant_plans.go index 0f020a3..151af8d 100644 --- a/internal/model/entity/pln_tenant_plans.go +++ b/internal/model/entity/pln_tenant_plans.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/service/admin.go b/internal/service/admin.go index f53ef6e..0ae40ca 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -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 读取) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index cc3c71b..bbd4c5d 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -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) diff --git a/migrations/000005_resource_package.sql b/migrations/000005_resource_package.sql deleted file mode 100644 index 7c264f9..0000000 --- a/migrations/000005_resource_package.sql +++ /dev/null @@ -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; diff --git a/migrations/000006_merge_resource_package_into_plan.sql b/migrations/000006_merge_resource_package_into_plan.sql deleted file mode 100644 index 503f3af..0000000 --- a/migrations/000006_merge_resource_package_into_plan.sql +++ /dev/null @@ -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; diff --git a/migrations/000007_simplify_plan_model_access.sql b/migrations/000007_simplify_plan_model_access.sql deleted file mode 100644 index 15d5c31..0000000 --- a/migrations/000007_simplify_plan_model_access.sql +++ /dev/null @@ -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; diff --git a/relay/common/billing.go b/relay/common/billing.go index 6911880..fe8d256 100644 --- a/relay/common/billing.go +++ b/relay/common/billing.go @@ -70,7 +70,4 @@ type SettlementResult struct { BillingMode string BillingSource string RateMultiplier float64 - PlanID int64 - PlanDeduction float64 - WalletDeduction float64 } diff --git a/relay/common/provider.go b/relay/common/provider.go index c301a6c..bdabc66 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -194,6 +194,7 @@ type UsageRecord struct { // 异步任务关联 TaskID string // 异步任务公开ID(task_xxxxx),普通请求为空 + } // AuditRecord 请求审计日志记录 diff --git a/web/admin/src/views/finance/BillingPage.vue b/web/admin/src/views/finance/BillingPage.vue index 9341775..dbca445 100644 --- a/web/admin/src/views/finance/BillingPage.vue +++ b/web/admin/src/views/finance/BillingPage.vue @@ -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 }) { diff --git a/web/admin/src/views/finance/OrdersPage.vue b/web/admin/src/views/finance/OrdersPage.vue index e6e30fc..d0f0d32 100644 --- a/web/admin/src/views/finance/OrdersPage.vue +++ b/web/admin/src/views/finance/OrdersPage.vue @@ -30,7 +30,7 @@ const statusLabel: Record = { cancelled: '已取消', refunding: '退款中', refunded: '已退款', refund_failed: '退款失败', } const orderTypeLabel: Record = { - new_plan: '套餐购买', renew: '续费', upgrade: '升级', downgrade: '降级', recharge: '充值', + new_plan: '新购', renew: '续费', upgrade: '升级', downgrade: '降级', recharge: '充值', } const columns: TableColumnData[] = [ @@ -85,29 +85,12 @@ const refundOrderId = ref(null) const refundAmount = ref(0) const refundForm = reactive({ reason: '' }) const refundLoading = ref(false) -const refundPreview = ref(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({ -
计算中...
- - + ¥{{ refundAmount.toFixed(2) }}
diff --git a/web/admin/src/views/finance/PlansPage.vue b/web/admin/src/views/finance/PlansPage.vue index 6599ae8..250594b 100644 --- a/web/admin/src/views/finance/PlansPage.vue +++ b/web/admin/src/views/finance/PlansPage.vue @@ -10,7 +10,6 @@ import { useExport } from '@/composables/useExport' const loading = ref(false) const plans = ref([]) -const allModels = ref([]) const pagination = reactive({ current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50] }) const statusFilter = ref(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(null) const formRef = ref(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({