fix(open):修复开放平台权限功能
This commit is contained in:
1 parent
c07b9ec2d4
commit
b63e5440c6
23 files changed
+1622
-109
No files matched your search
@@ -24,4 +24,15 @@ type IOpenV1 interface {
|
||||
OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (res *v1.OpenKeyDeleteRes, err error)
|
||||
OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (res *v1.OpenUsageQueryRes, err error)
|
||||
OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryReq) (res *v1.OpenBillingQueryRes, err error)
|
||||
OpenProjectList(ctx context.Context, req *v1.OpenProjectListReq) (res *v1.OpenProjectListRes, err error)
|
||||
OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreateReq) (res *v1.OpenProjectCreateRes, err error)
|
||||
OpenProjectGet(ctx context.Context, req *v1.OpenProjectGetReq) (res *v1.OpenProjectGetRes, err error)
|
||||
OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdateReq) (res *v1.OpenProjectUpdateRes, err error)
|
||||
OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchiveReq) (res *v1.OpenProjectArchiveRes, err error)
|
||||
OpenProjectUnarchive(ctx context.Context, req *v1.OpenProjectUnarchiveReq) (res *v1.OpenProjectUnarchiveRes, err error)
|
||||
OpenProjectKeyList(ctx context.Context, req *v1.OpenProjectKeyListReq) (res *v1.OpenProjectKeyListRes, err error)
|
||||
OpenProjectKeyCreate(ctx context.Context, req *v1.OpenProjectKeyCreateReq) (res *v1.OpenProjectKeyCreateRes, err error)
|
||||
OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKeyDeleteReq) (res *v1.OpenProjectKeyDeleteRes, err error)
|
||||
OpenProjectUsageStats(ctx context.Context, req *v1.OpenProjectUsageStatsReq) (res *v1.OpenProjectUsageStatsRes, err error)
|
||||
OpenProjectUsageLogs(ctx context.Context, req *v1.OpenProjectUsageLogsReq) (res *v1.OpenProjectUsageLogsRes, err error)
|
||||
}
|
||||
+210
-1
@@ -1,6 +1,9 @@
|
||||
package v1
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 开放平台业务 API(第三方应用调用)
|
||||
@@ -216,3 +219,209 @@ type OpenBillingItem struct {
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目管理
|
||||
// ============================================================
|
||||
|
||||
// OpenProjectList 项目列表
|
||||
type OpenProjectListReq struct {
|
||||
g.Meta `path:"/v1/projects" method:"get" tags:"开放平台API" summary:"项目列表"`
|
||||
Page int `json:"page" in:"query" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
|
||||
Status string `json:"status" in:"query" dc:"按状态筛选:active/archived/budget_exhausted"`
|
||||
Keyword string `json:"keyword" in:"query" dc:"搜索关键词"`
|
||||
}
|
||||
|
||||
type OpenProjectListRes struct {
|
||||
List []OpenProjectItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type OpenProjectItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Budget string `json:"budget"` // USD 6位小数,"unlimited" 表示不限
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OpenProjectCreate 创建项目
|
||||
type OpenProjectCreateReq struct {
|
||||
g.Meta `path:"/v1/projects" method:"post" mime:"json" tags:"开放平台API" summary:"创建项目"`
|
||||
Name string `json:"name" v:"required|length:1,100#请输入项目名称|项目名称长度1-100" dc:"项目名称"`
|
||||
Description string `json:"description" dc:"项目描述"`
|
||||
Budget float64 `json:"budget" dc:"项目预算上限(USD,0=不限)"`
|
||||
}
|
||||
|
||||
type OpenProjectCreateRes struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// OpenProjectGet 项目详情
|
||||
type OpenProjectGetReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}" method:"get" tags:"开放平台API" summary:"项目详情"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
}
|
||||
|
||||
type OpenProjectGetRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Budget string `json:"budget"`
|
||||
ActiveKeys int `json:"active_keys"`
|
||||
TotalKeys int `json:"total_keys"`
|
||||
MonthCost string `json:"month_cost"`
|
||||
MonthRequests int64 `json:"month_requests"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OpenProjectUpdate 更新项目
|
||||
type OpenProjectUpdateReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}" method:"put" mime:"json" tags:"开放平台API" summary:"更新项目"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
Name *string `json:"name" dc:"项目名称"`
|
||||
Description *string `json:"description" dc:"项目描述"`
|
||||
Budget *float64 `json:"budget" dc:"项目预算上限(USD,0=不限)"`
|
||||
}
|
||||
|
||||
type OpenProjectUpdateRes struct{}
|
||||
|
||||
// OpenProjectArchive 归档项目
|
||||
type OpenProjectArchiveReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/archive" method:"post" mime:"json" tags:"开放平台API" summary:"归档项目"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
}
|
||||
|
||||
type OpenProjectArchiveRes struct{}
|
||||
|
||||
// OpenProjectUnarchive 取消归档
|
||||
type OpenProjectUnarchiveReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/unarchive" method:"post" mime:"json" tags:"开放平台API" summary:"取消归档"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
}
|
||||
|
||||
type OpenProjectUnarchiveRes struct{}
|
||||
|
||||
// ============================================================
|
||||
// 项目 API Key 管理
|
||||
// ============================================================
|
||||
|
||||
// OpenProjectKeyList 项目密钥列表
|
||||
type OpenProjectKeyListReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/api-keys" method:"get" tags:"开放平台API" summary:"项目密钥列表"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
Page int `json:"page" in:"query" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
|
||||
}
|
||||
|
||||
type OpenProjectKeyListRes struct {
|
||||
List []OpenProjectKeyItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type OpenProjectKeyItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// OpenProjectKeyCreate 创建项目密钥
|
||||
type OpenProjectKeyCreateReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/api-keys" method:"post" mime:"json" tags:"开放平台API" summary:"创建项目密钥"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
Name string `json:"name" v:"required|length:1,100#请输入Key名称|名称长度1-100" dc:"Key名称"`
|
||||
Scope string `json:"scope" d:"full" v:"in:full,chat_only,embeddings_only,images_only,read_only#权限范围无效" dc:"权限范围"`
|
||||
ExpiresAt *gtime.Time `json:"expires_at" dc:"过期时间"`
|
||||
ModelNames []string `json:"model_names" dc:"可用模型列表"`
|
||||
}
|
||||
|
||||
type OpenProjectKeyCreateRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
}
|
||||
|
||||
// OpenProjectKeyDelete 删除项目密钥
|
||||
type OpenProjectKeyDeleteReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/api-keys/{keyId}" method:"delete" tags:"开放平台API" summary:"删除项目密钥"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
KeyId int64 `json:"keyId" in:"path" v:"required#请指定密钥ID" dc:"密钥ID"`
|
||||
}
|
||||
|
||||
type OpenProjectKeyDeleteRes struct{}
|
||||
|
||||
// ============================================================
|
||||
// 项目用量查询
|
||||
// ============================================================
|
||||
|
||||
// OpenProjectUsageStats 项目用量统计
|
||||
type OpenProjectUsageStatsReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/usage-stats" method:"get" tags:"开放平台API" summary:"项目用量统计"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
StartDate string `json:"start_date" in:"query" dc:"开始日期 (YYYY-MM-DD),默认近30天"`
|
||||
EndDate string `json:"end_date" in:"query" dc:"结束日期 (YYYY-MM-DD)"`
|
||||
}
|
||||
|
||||
type OpenProjectUsageStatsRes struct {
|
||||
TotalCost string `json:"total_cost"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
Daily []OpenProjectDailyStat `json:"daily"`
|
||||
Models []OpenProjectModelStat `json:"models"`
|
||||
}
|
||||
|
||||
type OpenProjectDailyStat struct {
|
||||
Date string `json:"date"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
TotalCost string `json:"total_cost"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type OpenProjectModelStat struct {
|
||||
ModelName string `json:"model_name"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
TotalCost string `json:"total_cost"`
|
||||
}
|
||||
|
||||
// OpenProjectUsageLogs 项目用量日志
|
||||
type OpenProjectUsageLogsReq struct {
|
||||
g.Meta `path:"/v1/projects/{id}/usage-logs" method:"get" tags:"开放平台API" summary:"项目用量日志"`
|
||||
Id int64 `json:"id" in:"path" v:"required#请指定项目ID" dc:"项目ID"`
|
||||
Page int `json:"page" in:"query" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
|
||||
}
|
||||
|
||||
type OpenProjectUsageLogsRes struct {
|
||||
List []OpenProjectUsageLogItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type OpenProjectUsageLogItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ModelName string `json:"model_name"`
|
||||
RelayMode string `json:"relay_mode"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalCost string `json:"total_cost"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -164,4 +164,5 @@ type ITenantV1 interface {
|
||||
TenantWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsReq) (res *v1.TenantWalletTransactionsRes, err error)
|
||||
TenantWalletTransactionsExport(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (res *v1.TenantWalletTransactionsExportRes, err error)
|
||||
TenantWalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (res *v1.TenantWalletFrozenItemsRes, err error)
|
||||
TenantWalletUpdateWarningThreshold(ctx context.Context, req *v1.TenantWalletUpdateWarningThresholdReq) (res *v1.TenantWalletUpdateWarningThresholdRes, err error)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ type OpenAppCreateReq struct {
|
||||
g.Meta `path:"/open/apps" method:"post" mime:"json" tags:"租户控制台-开放平台" summary:"创建应用"`
|
||||
Name string `json:"name" v:"required|length:2,100#请输入应用名称|应用名称长度为2-100位" dc:"应用名称"`
|
||||
Description string `json:"description" v:"length:0,500#描述不超过500字" dc:"应用描述"`
|
||||
Permissions []string `json:"permissions" v:"required|length:1,20#请选择权限范围|权限数量不正确" dc:"权限范围"`
|
||||
Permissions []string `json:"permissions" v:"required#请选择权限范围" dc:"权限范围"`
|
||||
IPWhitelist []string `json:"ip_whitelist" dc:"IP 白名单"`
|
||||
RateLimit int `json:"rate_limit" d:"60" dc:"每分钟请求上限"`
|
||||
}
|
||||
@@ -120,7 +120,7 @@ type WebhookConfigCreateReq struct {
|
||||
g.Meta `path:"/open/webhooks" method:"post" mime:"json" tags:"租户控制台-开放平台" summary:"创建Webhook配置"`
|
||||
Name string `json:"name" v:"required|length:2,100#请输入名称|名称长度2-100" dc:"配置名称"`
|
||||
URL string `json:"url" v:"required|length:10,500#请输入URL|URL长度不正确" dc:"回调URL(HTTPS)"`
|
||||
Events []string `json:"events" v:"required|length:1,50#请选择事件|事件数量不正确" dc:"订阅事件列表"`
|
||||
Events []string `json:"events" v:"required#请选择事件" dc:"订阅事件列表"`
|
||||
MaxConsecutiveFailures int `json:"max_consecutive_failures" d:"10" dc:"最大连续失败次数"`
|
||||
}
|
||||
|
||||
|
||||
@@ -74,3 +74,11 @@ type FrozenItemDetail struct {
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
}
|
||||
|
||||
// TenantWalletUpdateWarningThresholdReq 修改预警阈值
|
||||
type TenantWalletUpdateWarningThresholdReq struct {
|
||||
g.Meta `path:"/wallet/warning-threshold" method:"put" mime:"json" tags:"租户控制台-钱包" summary:"修改预警阈值"`
|
||||
Threshold float64 `json:"threshold" v:"min:0#阈值不能为负数" dc:"预警阈值(USD),0 表示关闭预警"`
|
||||
}
|
||||
|
||||
type TenantWalletUpdateWarningThresholdRes struct{}
|
||||
@@ -464,3 +464,24 @@ dao.SysIdempotencyRecords.Ctx(ctx).Data(do.SysIdempotencyRecords{
|
||||
|
||||
**正确做法**:将原生 SQL 改写为 DO 插入时,逐列核对目标表中**所有 NOT NULL 且无 DEFAULT 的列**是否都在 DO 中赋了值——这类列不能依赖框架的 OmitNil 自动跳过,否则 INSERT 必然失败。框架自动维护的 `created_at`/`updated_at` 不在此列。
|
||||
|
||||
### 2026-06-07:`length` 校验规则对 `[]string` 校验的是 JSON 字符串长度,不是元素个数
|
||||
|
||||
**问题描述**:Webhook 创建接口的 Events 字段使用 `v:"required|length:1,50#请选择事件|事件数量不正确"`,用户选了 3-4 个事件后提交报错"事件数量不正确"。
|
||||
|
||||
**原因**:GoFrame v2 的 `length:min,max` 校验规则对 `[]string` 类型不是检查 slice 元素个数,而是先将整个 slice 通过 `json.Marshal` 转成 JSON 字符串,再检查该字符串的字符长度。例如 `["member.created","key.deleted"]` 的 JSON 字符串长度约 33 个字符。用户选了 3-4 个事件后 JSON 字符串就可能超过 50 字符,导致校验失败。
|
||||
|
||||
**修复方式**:去掉 `length:1,50`,只保留 `required`(事件从固定列表中选择,无需额外长度校验)。
|
||||
|
||||
**正确做法**:
|
||||
```go
|
||||
// 错误 — length 对 []string 校验的是 JSON 字符串长度,不是元素个数
|
||||
Events []string `json:"events" v:"required|length:1,50#请选择事件|事件数量不正确"`
|
||||
|
||||
// 正确 — 只用 required 保证至少选了一个事件
|
||||
Events []string `json:"events" v:"required#请选择事件"`
|
||||
|
||||
// 如果需要校验每个元素字符串长度,用 foreach|length
|
||||
Events []string `json:"events" v:"required|foreach|length:1,100#请选择事件|事件名长度不正确"`
|
||||
```
|
||||
**注意**:GoFrame v2 没有内置规则直接校验 slice 的元素个数。如需限制元素数量,需自定义校验规则或在 logic 层手动检查。
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/logic/monitor"
|
||||
relayLogic "github.com/qianfree/team-api/internal/logic/relay"
|
||||
"github.com/qianfree/team-api/internal/logic/task"
|
||||
"github.com/qianfree/team-api/internal/logic/tenant"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
@@ -285,6 +286,9 @@ var (
|
||||
// Embedded frontend SPA serving
|
||||
registerFrontendRoutes(s)
|
||||
|
||||
// Migrate encryption key (legacy default → configured key)
|
||||
relayLogic.MigrateEncryptionKey(ctx)
|
||||
|
||||
// Initialize active task count and start polling
|
||||
task.InitActiveCount(ctx)
|
||||
go task.StartAsyncPolling(ctx)
|
||||
@@ -292,6 +296,9 @@ var (
|
||||
// Start webhook dispatcher (event-driven delivery)
|
||||
tenant.InitWebhookDispatcher(ctx)
|
||||
|
||||
// 注入 webhook 发布函数到 billing 包(解耦循环依赖)
|
||||
billing.SetPublishWebhookEventFn(tenant.PublishWebhookEvent)
|
||||
|
||||
// Flush usage log writer on shutdown (s.Run blocks until server stops)
|
||||
defer plugin.Shutdown(ctx)
|
||||
defer common.CloseChannelErrorWriter()
|
||||
|
||||
@@ -46,3 +46,36 @@ func (c *ControllerV1) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQuer
|
||||
func (c *ControllerV1) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryReq) (res *v1.OpenBillingQueryRes, err error) {
|
||||
return service.Open().OpenBillingQuery(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectList(ctx context.Context, req *v1.OpenProjectListReq) (res *v1.OpenProjectListRes, err error) {
|
||||
return service.Open().OpenProjectList(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreateReq) (res *v1.OpenProjectCreateRes, err error) {
|
||||
return service.Open().OpenProjectCreate(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectGet(ctx context.Context, req *v1.OpenProjectGetReq) (res *v1.OpenProjectGetRes, err error) {
|
||||
return service.Open().OpenProjectGet(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdateReq) (res *v1.OpenProjectUpdateRes, err error) {
|
||||
return service.Open().OpenProjectUpdate(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchiveReq) (res *v1.OpenProjectArchiveRes, err error) {
|
||||
return service.Open().OpenProjectArchive(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectUnarchive(ctx context.Context, req *v1.OpenProjectUnarchiveReq) (res *v1.OpenProjectUnarchiveRes, err error) {
|
||||
return service.Open().OpenProjectUnarchive(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectKeyList(ctx context.Context, req *v1.OpenProjectKeyListReq) (res *v1.OpenProjectKeyListRes, err error) {
|
||||
return service.Open().OpenProjectKeyList(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectKeyCreate(ctx context.Context, req *v1.OpenProjectKeyCreateReq) (res *v1.OpenProjectKeyCreateRes, err error) {
|
||||
return service.Open().OpenProjectKeyCreate(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKeyDeleteReq) (res *v1.OpenProjectKeyDeleteRes, err error) {
|
||||
return service.Open().OpenProjectKeyDelete(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectUsageStats(ctx context.Context, req *v1.OpenProjectUsageStatsReq) (res *v1.OpenProjectUsageStatsRes, err error) {
|
||||
return service.Open().OpenProjectUsageStats(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) OpenProjectUsageLogs(ctx context.Context, req *v1.OpenProjectUsageLogsReq) (res *v1.OpenProjectUsageLogsRes, err error) {
|
||||
return service.Open().OpenProjectUsageLogs(ctx, req)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package tenant
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/tenant/v1"
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
@@ -19,3 +19,6 @@ func (c *ControllerV1) TenantWalletTransactionsExport(ctx context.Context, req *
|
||||
func (c *ControllerV1) TenantWalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (res *v1.TenantWalletFrozenItemsRes, err error) {
|
||||
return service.Tenant().WalletFrozenItems(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantWalletUpdateWarningThreshold(ctx context.Context, req *v1.TenantWalletUpdateWarningThresholdReq) (res *v1.TenantWalletUpdateWarningThresholdRes, err error) {
|
||||
return service.Tenant().UpdateWarningThreshold(ctx, req)
|
||||
}
|
||||
@@ -10,8 +10,16 @@ import (
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// publishWebhookEventFn 由 tenant 包在初始化时注入,避免 billing → tenant 循环依赖
|
||||
var publishWebhookEventFn func(ctx context.Context, tenantID int64, eventType string, payload any) error
|
||||
|
||||
// SetPublishWebhookEventFn 注入 webhook 事件发布函数(由 cmd 初始化时调用)
|
||||
func SetPublishWebhookEventFn(fn func(ctx context.Context, tenantID int64, eventType string, payload any) error) {
|
||||
publishWebhookEventFn = fn
|
||||
}
|
||||
|
||||
// CheckBalanceWarning 检查余额预警
|
||||
// 如果租户可用余额低于预警线,发送通知给租户管理员
|
||||
// 如果租户可用余额低于预警线,发送通知给租户管理员并投递 webhook 事件
|
||||
func CheckBalanceWarning(ctx context.Context, tenantID int64) (bool, float64, float64, error) {
|
||||
wallet, err := GetWallet(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -23,9 +31,11 @@ func CheckBalanceWarning(ctx context.Context, tenantID int64) (bool, float64, fl
|
||||
g.Log().Warningf(ctx, "[BALANCE WARNING] tenant=%d available=%.6f threshold=%.6f",
|
||||
tenantID, available, wallet.WarningThreshold)
|
||||
|
||||
// 发送余额预警通知(异步,失败不影响主流程)
|
||||
// 发送站内通知 + webhook 事件(异步,失败不影响主流程)
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
|
||||
// 站内通知
|
||||
engine := common.NewNotificationEngine()
|
||||
variables := map[string]any{
|
||||
"available": fmt.Sprintf("%.6f", available),
|
||||
@@ -34,6 +44,17 @@ func CheckBalanceWarning(ctx context.Context, tenantID int64) (bool, float64, fl
|
||||
if notifyErr := engine.SendBroadcast(bgCtx, tenantID, "balance_warning", variables, "owner,admin"); notifyErr != nil {
|
||||
g.Log().Errorf(bgCtx, "[BALANCE WARNING] send notification failed: tenant=%d err=%v", tenantID, notifyErr)
|
||||
}
|
||||
|
||||
// Webhook 事件投递
|
||||
if publishWebhookEventFn != nil {
|
||||
if webhookErr := publishWebhookEventFn(bgCtx, tenantID, "wallet.low_balance", map[string]any{
|
||||
"available_balance": available,
|
||||
"warning_threshold": wallet.WarningThreshold,
|
||||
"wallet_id": wallet.ID,
|
||||
}); webhookErr != nil {
|
||||
g.Log().Errorf(bgCtx, "[BALANCE WARNING] publish webhook event failed: tenant=%d err=%v", tenantID, webhookErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return true, available, wallet.WarningThreshold, nil
|
||||
|
||||
@@ -44,6 +44,9 @@ func (s *sOpen) OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) (
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
@@ -104,6 +107,9 @@ func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateRe
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count()
|
||||
if count > 0 {
|
||||
@@ -137,6 +143,9 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := do.TntUsers{}
|
||||
hasUpdate := false
|
||||
@@ -166,6 +175,9 @@ func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteRe
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user *struct {
|
||||
Role string `json:"role"`
|
||||
@@ -194,6 +206,9 @@ func (s *sOpen) OpenMemberQuota(ctx context.Context, req *v1.OpenMemberQuotaReq)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user *struct {
|
||||
QuotaType string `json:"quota_type"`
|
||||
@@ -226,6 +241,9 @@ func (s *sOpen) OpenMemberQuotaUpdate(ctx context.Context, req *v1.OpenMemberQuo
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.QuotaType == "periodic" && req.Period == "" {
|
||||
return nil, errOpen(consts.CodeBadRequest, "periodic 类型额度必须指定周期")
|
||||
@@ -258,6 +276,9 @@ func (s *sOpen) OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsRe
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var scopes []struct {
|
||||
ModelID string `json:"model_id"`
|
||||
@@ -283,6 +304,9 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "members:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
_, err := tx.Model("tnt_member_model_scopes").Ctx(ctx).
|
||||
@@ -338,6 +362,9 @@ func (s *sOpen) OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.Op
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "keys:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
@@ -391,6 +418,9 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "keys:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
|
||||
if err != nil {
|
||||
@@ -437,6 +467,9 @@ func (s *sOpen) OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (*v
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "keys:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err := dao.ApiKeys.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
|
||||
return nil, err
|
||||
@@ -457,6 +490,9 @@ func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "usage:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
@@ -541,6 +577,9 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "billing:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
@@ -599,3 +638,658 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe
|
||||
func errOpen(code int, message string) error {
|
||||
return gerror.NewCode(gcode.New(code, message, nil), message)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目管理
|
||||
// ============================================================
|
||||
|
||||
func (s *sOpen) OpenProjectList(ctx context.Context, req *v1.OpenProjectListReq) (*v1.OpenProjectListRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
m := dao.TntProjects.Ctx(ctx).Where("tenant_id", tenantID)
|
||||
if req.Status != "" {
|
||||
m = m.Where("status", req.Status)
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
kw := "%" + strings.TrimSpace(req.Keyword) + "%"
|
||||
m = m.Where("name LIKE ?", kw)
|
||||
}
|
||||
|
||||
total, err := m.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type projectRow struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Budget *float64 `json:"budget"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
var projects []projectRow
|
||||
err = m.Fields("id, name, description, status, budget, created_at, updated_at").
|
||||
OrderDesc("id").Page(page, pageSize).Scan(&projects)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]v1.OpenProjectItem, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
item := v1.OpenProjectItem{
|
||||
ID: p.Id,
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Status: p.Status,
|
||||
}
|
||||
if p.Budget != nil {
|
||||
item.Budget = fmt.Sprintf("%.6f", *p.Budget)
|
||||
} else {
|
||||
item.Budget = "unlimited"
|
||||
}
|
||||
if p.CreatedAt != nil {
|
||||
item.CreatedAt = p.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
if p.UpdatedAt != nil {
|
||||
item.UpdatedAt = p.UpdatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return &v1.OpenProjectListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreateReq) (*v1.OpenProjectCreateRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
insertData := do.TntProjects{
|
||||
TenantId: tenantID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if req.Budget > 0 {
|
||||
insertData.Budget = req.Budget
|
||||
}
|
||||
|
||||
result, err := dao.TntProjects.Ctx(ctx).Data(insertData).Insert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &v1.OpenProjectCreateRes{ID: id}, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectGet(ctx context.Context, req *v1.OpenProjectGetReq) (*v1.OpenProjectGetRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type projectRow struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Budget *float64 `json:"budget"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
var p *projectRow
|
||||
err := dao.TntProjects.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("id, name, description, status, budget, created_at, updated_at").
|
||||
Scan(&p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, errOpen(consts.CodeNotFound, "项目不存在")
|
||||
}
|
||||
|
||||
// Key 统计
|
||||
type keyStatsRow struct {
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
}
|
||||
var keyStats keyStatsRow
|
||||
dao.ApiKeys.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Fields("COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active").
|
||||
Scan(&keyStats)
|
||||
|
||||
// 月度用量
|
||||
type monthUsageRow struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
RequestCount int `json:"request_count"`
|
||||
}
|
||||
var monthUsage monthUsageRow
|
||||
dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Where("created_at >= date_trunc('month', NOW())").
|
||||
Fields("COALESCE(SUM(actual_cost), 0) as total_cost, COUNT(*) as request_count").
|
||||
Scan(&monthUsage)
|
||||
|
||||
res := &v1.OpenProjectGetRes{
|
||||
ID: p.Id,
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Status: p.Status,
|
||||
ActiveKeys: keyStats.Active,
|
||||
TotalKeys: keyStats.Total,
|
||||
MonthCost: fmt.Sprintf("%.6f", monthUsage.TotalCost),
|
||||
MonthRequests: int64(monthUsage.RequestCount),
|
||||
}
|
||||
if p.Budget != nil {
|
||||
res.Budget = fmt.Sprintf("%.6f", *p.Budget)
|
||||
} else {
|
||||
res.Budget = "unlimited"
|
||||
}
|
||||
if p.CreatedAt != nil {
|
||||
res.CreatedAt = p.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
if p.UpdatedAt != nil {
|
||||
res.UpdatedAt = p.UpdatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdateReq) (*v1.OpenProjectUpdateRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project.Status == "archived" {
|
||||
return nil, errOpen(consts.CodeBadRequest, "归档的项目不能直接编辑,请先取消归档")
|
||||
}
|
||||
|
||||
data := do.TntProjects{}
|
||||
hasUpdate := false
|
||||
if req.Name != nil {
|
||||
data.Name = *req.Name
|
||||
hasUpdate = true
|
||||
}
|
||||
if req.Description != nil {
|
||||
data.Description = *req.Description
|
||||
hasUpdate = true
|
||||
}
|
||||
if req.Budget != nil {
|
||||
if *req.Budget > 0 {
|
||||
data.Budget = *req.Budget
|
||||
}
|
||||
hasUpdate = true
|
||||
}
|
||||
// 预算耗尽状态的项目,更新预算后自动恢复为 active
|
||||
if project.Status == "budget_exhausted" && req.Budget != nil && *req.Budget > 0 {
|
||||
data.Status = "active"
|
||||
hasUpdate = true
|
||||
}
|
||||
|
||||
if !hasUpdate {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id).Data(data).Update()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchiveReq) (*v1.OpenProjectArchiveRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project.Status == "archived" {
|
||||
return nil, errOpen(consts.CodeBadRequest, "项目已归档")
|
||||
}
|
||||
|
||||
// 吊销项目下所有 active Key
|
||||
_, err = dao.ApiKeys.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Where("status", "active").
|
||||
Data(do.ApiKeys{Status: "revoked"}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id).
|
||||
Data(do.TntProjects{Status: "archived"}).Update()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectUnarchive(ctx context.Context, req *v1.OpenProjectUnarchiveReq) (*v1.OpenProjectUnarchiveRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project.Status != "archived" {
|
||||
return nil, errOpen(consts.CodeBadRequest, "只有归档状态的项目可以取消归档")
|
||||
}
|
||||
|
||||
_, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id).
|
||||
Data(do.TntProjects{Status: "active"}).Update()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目 API Key 管理
|
||||
// ============================================================
|
||||
|
||||
func (s *sOpen) OpenProjectKeyList(ctx context.Context, req *v1.OpenProjectKeyListReq) (*v1.OpenProjectKeyListRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.getProjectOrError(ctx, req.Id, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
m := dao.ApiKeys.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id)
|
||||
|
||||
total, err := m.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type keyRow struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
ExpiresAt *gtime.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
var keys []keyRow
|
||||
err = m.Fields("id, name, key_prefix, status, created_at, expires_at").
|
||||
OrderDesc("id").Page(page, pageSize).Scan(&keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]v1.OpenProjectKeyItem, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
item := v1.OpenProjectKeyItem{
|
||||
ID: k.Id,
|
||||
Name: k.Name,
|
||||
KeyPrefix: k.KeyPrefix,
|
||||
Status: k.Status,
|
||||
}
|
||||
if k.CreatedAt != nil {
|
||||
item.CreatedAt = k.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
if k.ExpiresAt != nil {
|
||||
item.ExpiresAt = k.ExpiresAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return &v1.OpenProjectKeyListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectKeyCreate(ctx context.Context, req *v1.OpenProjectKeyCreateReq) (*v1.OpenProjectKeyCreateRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
project, err := s.getProjectOrError(ctx, req.Id, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project.Status != "active" {
|
||||
return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用")
|
||||
}
|
||||
|
||||
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
insertData := do.ApiKeys{
|
||||
TenantId: tenantID,
|
||||
ProjectId: req.Id,
|
||||
Name: req.Name,
|
||||
EncryptedKey: encryptedKey,
|
||||
KeyPrefix: prefix,
|
||||
Scope: req.Scope,
|
||||
Status: "active",
|
||||
KeyType: "project",
|
||||
}
|
||||
|
||||
if req.ExpiresAt != nil {
|
||||
insertData.ExpiresAt = req.ExpiresAt
|
||||
}
|
||||
|
||||
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
|
||||
for _, modelName := range req.ModelNames {
|
||||
if modelName != "" {
|
||||
if _, err = dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{
|
||||
ApiKeyId: id,
|
||||
ModelName: modelName,
|
||||
}); err != nil {
|
||||
g.Log().Warningf(ctx, "创建密钥模型范围失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &v1.OpenProjectKeyCreateRes{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Key: rawKey,
|
||||
KeyPrefix: prefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKeyDeleteReq) (*v1.OpenProjectKeyDeleteRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证密钥属于该项目和租户
|
||||
count, err := dao.ApiKeys.Ctx(ctx).
|
||||
Where("id", req.KeyId).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, errOpen(consts.CodeNotFound, "密钥不存在")
|
||||
}
|
||||
|
||||
_, err = dao.ApiKeys.Ctx(ctx).
|
||||
Where("id", req.KeyId).
|
||||
Where("tenant_id", tenantID).
|
||||
Data(do.ApiKeys{Status: "revoked"}).Update()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目用量查询
|
||||
// ============================================================
|
||||
|
||||
func (s *sOpen) OpenProjectUsageStats(ctx context.Context, req *v1.OpenProjectUsageStatsReq) (*v1.OpenProjectUsageStatsRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.getProjectOrError(ctx, req.Id, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建日期条件
|
||||
useCustomRange := req.StartDate != "" && req.EndDate != ""
|
||||
if useCustomRange {
|
||||
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
dateCondition := func(m *gdb.Model) *gdb.Model {
|
||||
if useCustomRange {
|
||||
return m.Where("created_at >= ?", req.StartDate+" 00:00:00").
|
||||
Where("created_at <= ?", req.EndDate+" 23:59:59")
|
||||
}
|
||||
return m.Where("created_at >= NOW() - INTERVAL '30 days'")
|
||||
}
|
||||
|
||||
// 汇总
|
||||
type totalRow struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
RequestCount int `json:"request_count"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
var totalStats totalRow
|
||||
dateCondition(dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Fields("COALESCE(SUM(actual_cost), 0) as total_cost, COUNT(*) as request_count, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens")).
|
||||
Scan(&totalStats)
|
||||
|
||||
// 每日趋势
|
||||
type dailyRow struct {
|
||||
Date string `json:"date"`
|
||||
RequestCount int `json:"request_count"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
var dailyStats []dailyRow
|
||||
dateCondition(dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Fields("DATE(created_at) as date, COUNT(*) as request_count, COALESCE(SUM(actual_cost), 0) as total_cost, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens")).
|
||||
Group("DATE(created_at)").OrderAsc("date").Scan(&dailyStats)
|
||||
|
||||
// 模型分布(Top 10)
|
||||
type modelRow struct {
|
||||
ModelName string `json:"model_name"`
|
||||
RequestCount int `json:"request_count"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
}
|
||||
var modelStats []modelRow
|
||||
dateCondition(dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id).
|
||||
Fields("model_name, COUNT(*) as request_count, COALESCE(SUM(actual_cost), 0) as total_cost")).
|
||||
Group("model_name").OrderDesc("total_cost").Limit(10).Scan(&modelStats)
|
||||
|
||||
daily := make([]v1.OpenProjectDailyStat, 0, len(dailyStats))
|
||||
for _, d := range dailyStats {
|
||||
daily = append(daily, v1.OpenProjectDailyStat{
|
||||
Date: d.Date,
|
||||
RequestCount: int64(d.RequestCount),
|
||||
TotalCost: fmt.Sprintf("%.6f", d.TotalCost),
|
||||
InputTokens: d.InputTokens,
|
||||
OutputTokens: d.OutputTokens,
|
||||
})
|
||||
}
|
||||
|
||||
models := make([]v1.OpenProjectModelStat, 0, len(modelStats))
|
||||
for _, m := range modelStats {
|
||||
models = append(models, v1.OpenProjectModelStat{
|
||||
ModelName: m.ModelName,
|
||||
RequestCount: int64(m.RequestCount),
|
||||
TotalCost: fmt.Sprintf("%.6f", m.TotalCost),
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.OpenProjectUsageStatsRes{
|
||||
TotalCost: fmt.Sprintf("%.6f", totalStats.TotalCost),
|
||||
TotalRequests: int64(totalStats.RequestCount),
|
||||
TotalInputTokens: totalStats.InputTokens,
|
||||
TotalOutputTokens: totalStats.OutputTokens,
|
||||
Daily: daily,
|
||||
Models: models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenProjectUsageLogs(ctx context.Context, req *v1.OpenProjectUsageLogsReq) (*v1.OpenProjectUsageLogsRes, error) {
|
||||
tenantID := middleware.GetOpenTenantID(ctx)
|
||||
if tenantID == 0 {
|
||||
return nil, errOpen(consts.CodeUnauthorized, "未认证")
|
||||
}
|
||||
if err := middleware.CheckOpenPermission(ctx, "projects:read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.getProjectOrError(ctx, req.Id, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
m := dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("project_id", req.Id)
|
||||
|
||||
total, err := m.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type logRow struct {
|
||||
Id int64 `json:"id"`
|
||||
ModelName string `json:"model_name"`
|
||||
RelayMode string `json:"relay_mode"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
var logs []logRow
|
||||
err = m.Fields("id, model_name, relay_mode, input_tokens, output_tokens, actual_cost as total_cost, latency_ms, status, error_message, created_at").
|
||||
OrderDesc("created_at").Page(page, pageSize).Scan(&logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]v1.OpenProjectUsageLogItem, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
item := v1.OpenProjectUsageLogItem{
|
||||
ID: l.Id,
|
||||
ModelName: l.ModelName,
|
||||
RelayMode: l.RelayMode,
|
||||
InputTokens: l.InputTokens,
|
||||
OutputTokens: l.OutputTokens,
|
||||
TotalCost: fmt.Sprintf("%.6f", l.TotalCost),
|
||||
LatencyMs: l.LatencyMs,
|
||||
Status: l.Status,
|
||||
ErrorMessage: l.ErrorMessage,
|
||||
}
|
||||
if l.CreatedAt != nil {
|
||||
item.CreatedAt = l.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return &v1.OpenProjectUsageLogsRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// getProjectOrError 验证项目归属并返回基本信息
|
||||
func (s *sOpen) getProjectOrError(ctx context.Context, projectID, tenantID int64) (*struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}, error) {
|
||||
var project *struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.TntProjects.Ctx(ctx).
|
||||
Where("id", projectID).
|
||||
Where("tenant_id", tenantID).
|
||||
Scan(&project)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project == nil {
|
||||
return nil, errOpen(consts.CodeNotFound, "项目不存在")
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
@@ -40,15 +40,83 @@ func DefaultChannelSettings() common.ChannelSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// legacyEncryptionKey 硬编码的旧默认密钥(仅迁移时使用)
|
||||
const legacyEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
// GetEncryptionKey 获取加密密钥
|
||||
func GetEncryptionKey() []byte {
|
||||
key := g.Cfg().MustGet(context.Background(), "crypto.encryptionKey").String()
|
||||
if key == "" {
|
||||
key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
panic("crypto.encryptionKey is not configured")
|
||||
}
|
||||
return uc.MustGetEncryptionKey(key)
|
||||
}
|
||||
|
||||
// MigrateEncryptionKey 检测并迁移旧默认密钥加密的数据到新配置密钥。
|
||||
// 启动时调用一次,将 chn_channel_keys 和 api_keys 中用旧密钥加密的数据重新加密。
|
||||
func MigrateEncryptionKey(ctx context.Context) {
|
||||
configKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
|
||||
if configKey == "" || configKey == legacyEncryptionKey {
|
||||
// 未配置新密钥或新密钥与旧密钥相同,无需迁移
|
||||
return
|
||||
}
|
||||
|
||||
newKey := uc.MustGetEncryptionKey(configKey)
|
||||
oldKey := uc.MustGetEncryptionKey(legacyEncryptionKey)
|
||||
|
||||
migrateTable(ctx, "chn_channel_keys", oldKey, newKey)
|
||||
migrateTable(ctx, "api_keys", oldKey, newKey)
|
||||
}
|
||||
|
||||
// migrateTable 迁移单张表的 encrypted_key 字段
|
||||
func migrateTable(ctx context.Context, table string, oldKey, newKey []byte) {
|
||||
type row struct {
|
||||
Id int64 `json:"id"`
|
||||
EncryptedKey string `json:"encrypted_key"`
|
||||
}
|
||||
|
||||
var rows []row
|
||||
err := g.DB().Model(table).Ctx(ctx).
|
||||
Fields("id, encrypted_key").
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[KeyMigration] query %s failed: %v", table, err)
|
||||
return
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
for _, r := range rows {
|
||||
if r.EncryptedKey == "" {
|
||||
continue
|
||||
}
|
||||
// 尝试用旧密钥解密
|
||||
plaintext, err := uc.DecryptString(oldKey, r.EncryptedKey)
|
||||
if err != nil {
|
||||
// 解密失败说明可能是已经用新密钥加密过的,跳过
|
||||
continue
|
||||
}
|
||||
// 用新密钥重新加密
|
||||
newEncrypted, err := uc.EncryptString(newKey, plaintext)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[KeyMigration] re-encrypt %s id=%d failed: %v", table, r.Id, err)
|
||||
continue
|
||||
}
|
||||
_, err = g.DB().Model(table).Ctx(ctx).
|
||||
Where("id", r.Id).
|
||||
Data(g.Map{"encrypted_key": newEncrypted}).
|
||||
Update()
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[KeyMigration] update %s id=%d failed: %v", table, r.Id, err)
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
if migrated > 0 {
|
||||
g.Log().Infof(ctx, "[KeyMigration] migrated %d rows in %s from legacy key to config key", migrated, table)
|
||||
}
|
||||
}
|
||||
|
||||
// apiKeyHash 计算 API Key 的 SHA-256 哈希
|
||||
func apiKeyHash(rawKey string) string {
|
||||
h := sha256.Sum256([]byte(rawKey))
|
||||
|
||||
@@ -508,3 +508,26 @@ func (s *sTenant) WalletFrozenItems(ctx context.Context, req *v1.TenantWalletFro
|
||||
|
||||
return &v1.TenantWalletFrozenItemsRes{Items: details}, nil
|
||||
}
|
||||
|
||||
// UpdateWarningThreshold 修改钱包预警阈值
|
||||
func (s *sTenant) UpdateWarningThreshold(ctx context.Context, req *v1.TenantWalletUpdateWarningThresholdReq) (*v1.TenantWalletUpdateWarningThresholdRes, error) {
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
_, err := dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Data(do.BilWallets{
|
||||
WarningThreshold: req.Threshold,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清除钱包缓存
|
||||
billing.InvalidateWalletRedis(ctx, tenantID)
|
||||
|
||||
return &v1.TenantWalletUpdateWarningThresholdRes{}, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -356,6 +357,9 @@ func (s *sTenant) WebhookConfigUpdate(ctx context.Context, req *v1.WebhookConfig
|
||||
hasUpdate = true
|
||||
}
|
||||
if req.URL != nil {
|
||||
if !strings.HasPrefix(*req.URL, "https://") {
|
||||
return nil, common.NewBadRequestError("回调 URL 必须以 https:// 开头")
|
||||
}
|
||||
data.Url = *req.URL
|
||||
hasUpdate = true
|
||||
}
|
||||
@@ -387,8 +391,31 @@ func (s *sTenant) WebhookConfigDelete(ctx context.Context, req *v1.WebhookConfig
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
_, err := dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
|
||||
return nil, err
|
||||
|
||||
// 先校验配置存在且属于当前租户
|
||||
count, err := dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, common.NewNotFoundError("Webhook 配置不存在")
|
||||
}
|
||||
|
||||
// 级联删除:投递日志 → 事件 → 配置
|
||||
_, err = dao.OpnWebhookDeliveryLogs.Ctx(ctx).Where("webhook_config_id", req.Id).Where("tenant_id", tenantID).Delete()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = dao.OpnWebhookEvents.Ctx(ctx).Where("webhook_config_id", req.Id).Where("tenant_id", tenantID).Delete()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *sTenant) WebhookDeliveryLogs(ctx context.Context, req *v1.WebhookDeliveryLogsReq) (*v1.WebhookDeliveryLogsRes, error) {
|
||||
@@ -406,52 +433,35 @@ func (s *sTenant) WebhookDeliveryLogs(ctx context.Context, req *v1.WebhookDelive
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
baseQuery := dao.OpnWebhookDeliveryLogs.Ctx(ctx).Where("tenant_id", tenantID).Where("webhook_config_id", req.Id)
|
||||
|
||||
// 分离计数查询和数据查询,避免 JOIN 影响 Count
|
||||
// 计数查询(不 JOIN)
|
||||
countQuery := dao.OpnWebhookDeliveryLogs.Ctx(ctx).Where("tenant_id", tenantID).Where("webhook_config_id", req.Id)
|
||||
if req.Status != "" {
|
||||
// 用子查询过滤 status
|
||||
total, err := baseQuery.
|
||||
Where("event_id IN (SELECT id FROM opn_webhook_events WHERE status = ?)", req.Status).
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []entity.OpnWebhookDeliveryLogs
|
||||
err = baseQuery.
|
||||
Where("event_id IN (SELECT id FROM opn_webhook_events WHERE status = ?)", req.Status).
|
||||
OrderDesc("created_at").Page(page, pageSize).Scan(&logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]v1.WebhookDeliveryLogItem, len(logs))
|
||||
for i, l := range logs {
|
||||
items[i] = v1.WebhookDeliveryLogItem{
|
||||
ID: l.Id,
|
||||
EventID: l.EventId,
|
||||
Attempt: l.Attempt,
|
||||
ResponseStatus: l.ResponseStatus,
|
||||
ResponseTimeMs: l.ResponseTimeMs,
|
||||
ErrorMessage: l.ErrorMessage,
|
||||
}
|
||||
if l.CreatedAt != nil {
|
||||
items[i].CreatedAt = l.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
}
|
||||
|
||||
return &v1.WebhookDeliveryLogsRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
countQuery = countQuery.Where("event_id IN (SELECT id FROM opn_webhook_events WHERE status = ?)", req.Status)
|
||||
}
|
||||
|
||||
// 无 status 过滤时,直接查询
|
||||
total, err := baseQuery.Count()
|
||||
total, err := countQuery.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []entity.OpnWebhookDeliveryLogs
|
||||
err = baseQuery.OrderDesc("created_at").Page(page, pageSize).Scan(&logs)
|
||||
// 数据查询:LEFT JOIN opn_webhook_events 获取 event_type
|
||||
db := g.DB()
|
||||
query := db.Ctx(ctx).Raw(
|
||||
"SELECT dl.*, e.event_type FROM opn_webhook_delivery_logs dl LEFT JOIN opn_webhook_events e ON dl.event_id = e.id WHERE dl.tenant_id = ? AND dl.webhook_config_id = ?",
|
||||
tenantID, req.Id,
|
||||
)
|
||||
if req.Status != "" {
|
||||
query = db.Ctx(ctx).Raw(
|
||||
"SELECT dl.*, e.event_type FROM opn_webhook_delivery_logs dl LEFT JOIN opn_webhook_events e ON dl.event_id = e.id WHERE dl.tenant_id = ? AND dl.webhook_config_id = ? AND e.status = ?",
|
||||
tenantID, req.Id, req.Status,
|
||||
)
|
||||
}
|
||||
|
||||
type deliveryLogWithEventType struct {
|
||||
entity.OpnWebhookDeliveryLogs
|
||||
EventType string `json:"event_type"`
|
||||
}
|
||||
var logs []deliveryLogWithEventType
|
||||
err = query.OrderDesc("dl.created_at").Page(page, pageSize).Scan(&logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -461,6 +471,7 @@ func (s *sTenant) WebhookDeliveryLogs(ctx context.Context, req *v1.WebhookDelive
|
||||
items[i] = v1.WebhookDeliveryLogItem{
|
||||
ID: l.Id,
|
||||
EventID: l.EventId,
|
||||
EventType: l.EventType,
|
||||
Attempt: l.Attempt,
|
||||
ResponseStatus: l.ResponseStatus,
|
||||
ResponseTimeMs: l.ResponseTimeMs,
|
||||
|
||||
@@ -15,6 +15,13 @@ const (
|
||||
dispatcherChannelSize = 1000
|
||||
dispatcherWorkerCount = 5
|
||||
dispatcherSweepInterval = 2 * time.Minute
|
||||
|
||||
// 清理策略
|
||||
cleanupInterval = 6 * time.Hour // 每 6 小时执行一次清理
|
||||
cleanupDeliveredAge = 7 * 24 * time.Hour // 已投递事件保留 7 天
|
||||
cleanupFailedAge = 30 * 24 * time.Hour // 已放弃事件保留 30 天
|
||||
cleanupDeliveryLogAge = 30 * 24 * time.Hour // 投递日志保留 30 天
|
||||
cleanupBatchSize = 500 // 每批删除条数
|
||||
)
|
||||
|
||||
type WebhookDispatcher struct {
|
||||
@@ -36,11 +43,12 @@ func InitWebhookDispatcher(ctx context.Context) {
|
||||
go d.worker()
|
||||
}
|
||||
go d.sweepLoop()
|
||||
go d.cleanupLoop()
|
||||
|
||||
// Recover any pending events left from a previous crash
|
||||
go d.recoverPending()
|
||||
|
||||
g.Log().Infof(ctx, "webhook dispatcher started: %d workers, sweep every %v", dispatcherWorkerCount, dispatcherSweepInterval)
|
||||
g.Log().Infof(ctx, "webhook dispatcher started: %d workers, sweep every %v, cleanup every %v", dispatcherWorkerCount, dispatcherSweepInterval, cleanupInterval)
|
||||
}
|
||||
|
||||
// NotifyNewEvent pushes an event ID to the dispatcher channel (non-blocking).
|
||||
@@ -108,8 +116,96 @@ func (d *WebhookDispatcher) recoverPending() {
|
||||
}
|
||||
|
||||
// scheduleRetry schedules a delayed re-delivery for a failed event.
|
||||
// The retry is skipped if the dispatcher context has been cancelled (e.g. during shutdown).
|
||||
func scheduleRetry(eventID int64, delay time.Duration) {
|
||||
time.AfterFunc(delay, func() {
|
||||
if defaultDispatcher == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-defaultDispatcher.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
NotifyNewEvent(eventID)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupLoop periodically removes old delivered events, exhausted failed events, and delivery logs.
|
||||
func (d *WebhookDispatcher) cleanupLoop() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes old webhook data in batches to prevent table bloat.
|
||||
// Order: delivery_logs → events (delivery_logs reference events via event_id).
|
||||
func (d *WebhookDispatcher) cleanup() {
|
||||
ctx := d.ctx
|
||||
now := gtime.Now()
|
||||
|
||||
// 1. 清理投递日志(保留 30 天)
|
||||
logCutoff := now.Add(-cleanupDeliveryLogAge)
|
||||
for {
|
||||
result, err := dao.OpnWebhookDeliveryLogs.Ctx(ctx).
|
||||
Where("created_at < ?", logCutoff).
|
||||
Limit(cleanupBatchSize).
|
||||
Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "webhook cleanup: delete delivery logs failed: %v", err)
|
||||
break
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
break
|
||||
}
|
||||
g.Log().Infof(ctx, "webhook cleanup: deleted %d delivery logs older than %v", rows, logCutoff.Format("Y-m-d"))
|
||||
}
|
||||
|
||||
// 2. 清理已投递事件(保留 7 天)
|
||||
deliveredCutoff := now.Add(-cleanupDeliveredAge)
|
||||
for {
|
||||
result, err := dao.OpnWebhookEvents.Ctx(ctx).
|
||||
Where("status", "delivered").
|
||||
Where("updated_at < ?", deliveredCutoff).
|
||||
Limit(cleanupBatchSize).
|
||||
Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "webhook cleanup: delete delivered events failed: %v", err)
|
||||
break
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
break
|
||||
}
|
||||
g.Log().Infof(ctx, "webhook cleanup: deleted %d delivered events older than %v", rows, deliveredCutoff.Format("Y-m-d"))
|
||||
}
|
||||
|
||||
// 3. 清理已放弃的重试事件(attempts >= max,保留 30 天)
|
||||
failedCutoff := now.Add(-cleanupFailedAge)
|
||||
for {
|
||||
result, err := dao.OpnWebhookEvents.Ctx(ctx).
|
||||
Where("status", "failed").
|
||||
Where("attempts >= ?", webhookMaxRetries).
|
||||
Where("updated_at < ?", failedCutoff).
|
||||
Limit(cleanupBatchSize).
|
||||
Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "webhook cleanup: delete exhausted events failed: %v", err)
|
||||
break
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
break
|
||||
}
|
||||
g.Log().Infof(ctx, "webhook cleanup: deleted %d exhausted events older than %v", rows, failedCutoff.Format("Y-m-d"))
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@ package tenant
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -28,6 +25,9 @@ const (
|
||||
webhookMaxRetries = 5
|
||||
)
|
||||
|
||||
// webhookHTTPClient 全局复用的 HTTP 客户端,共享连接池,避免每次投递创建新实例。
|
||||
var webhookHTTPClient = &http.Client{Timeout: webhookTimeout}
|
||||
|
||||
var retryIntervals = []time.Duration{
|
||||
1 * time.Minute,
|
||||
5 * time.Minute,
|
||||
@@ -85,7 +85,7 @@ func deliverEvent(ctx context.Context, evt entity.OpnWebhookEvents) {
|
||||
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
body := []byte(evt.Payload)
|
||||
signature := computeDeliverySignature(config.SecretKey, timestamp, body)
|
||||
signature := ComputeWebhookSignature(config.SecretKey, timestamp, body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.Url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -100,9 +100,8 @@ func deliverEvent(ctx context.Context, evt entity.OpnWebhookEvents) {
|
||||
req.Header.Set("X-Webhook-Event", evt.EventType)
|
||||
req.Header.Set("X-Webhook-ID", evt.EventId)
|
||||
|
||||
client := &http.Client{Timeout: webhookTimeout}
|
||||
startTime := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
resp, err := webhookHTTPClient.Do(req)
|
||||
elapsed := time.Since(startTime).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
@@ -201,12 +200,6 @@ func recordDeliveryLog(ctx context.Context, evt entity.OpnWebhookEvents, config
|
||||
}
|
||||
}
|
||||
|
||||
func computeDeliverySignature(secret, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(timestamp + "." + string(body)))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
@@ -20,6 +21,8 @@ import (
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
"github.com/qianfree/team-api/internal/response"
|
||||
"github.com/qianfree/team-api/internal/utility/crypto"
|
||||
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// openContextKey is a private type for context keys to prevent collisions.
|
||||
@@ -38,6 +41,8 @@ const (
|
||||
ctxKeyOpenIsSandbox openContextKey = "openIsSandbox"
|
||||
)
|
||||
|
||||
var openAppCache = lcommon.NewCache("open_app", 60*time.Second)
|
||||
|
||||
// OpenPlatformAuth is the HMAC-SHA256 authentication middleware for /open/* endpoints.
|
||||
func OpenPlatformAuth(r *ghttp.Request) {
|
||||
appID := r.GetHeader(openPlatformHeaderAppID)
|
||||
@@ -61,12 +66,16 @@ func OpenPlatformAuth(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Lookup app by app_id
|
||||
// Lookup app by app_id (cache-first)
|
||||
var app entity.OpnApps
|
||||
err = dao.OpnApps.Ctx(r.Context()).Where("app_id", appID).Scan(&app)
|
||||
if err != nil || app.Id == 0 {
|
||||
response.Error(r, consts.ErrUnauthorized)
|
||||
return
|
||||
cacheKey := appID
|
||||
if !openAppCache.GetJSON(r.Context(), cacheKey, &app) || app.Id == 0 {
|
||||
err = dao.OpnApps.Ctx(r.Context()).Where("app_id", appID).Scan(&app)
|
||||
if err != nil || app.Id == 0 {
|
||||
response.Error(r, consts.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
openAppCache.Set(r.Context(), cacheKey, &app)
|
||||
}
|
||||
|
||||
if app.Status != "active" {
|
||||
@@ -80,6 +89,13 @@ func OpenPlatformAuth(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Per-app rate limiting (fixed window, 1 minute)
|
||||
if app.RateLimit > 0 {
|
||||
if !checkOpenAppRateLimit(r, app.Id, app.RateLimit) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get app secret from Redis
|
||||
secret, err := getOpenAppSecret(r.Context(), app.Id)
|
||||
if err != nil || secret == "" {
|
||||
@@ -88,10 +104,8 @@ func OpenPlatformAuth(r *ghttp.Request) {
|
||||
}
|
||||
|
||||
// Read request body for signature verification
|
||||
var bodyBytes []byte
|
||||
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
|
||||
bodyBytes, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
// Use r.GetBody() instead of io.ReadAll(r.Body) to preserve body for downstream handlers
|
||||
bodyBytes := r.GetBody()
|
||||
|
||||
// Verify HMAC signature (includes body hash for request integrity)
|
||||
expectedSig := computeHMACSignature(secret, timestampStr, nonce, r.Method, r.URL.Path, bodyBytes)
|
||||
@@ -186,3 +200,69 @@ func GetOpenAppID(ctx context.Context) int64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// checkOpenAppRateLimit enforces per-app rate limiting using a Redis fixed-window counter.
|
||||
// Returns true if the request is allowed, false if rate limited (429 response already written).
|
||||
func checkOpenAppRateLimit(r *ghttp.Request, appDBID int64, rateLimit int) bool {
|
||||
ctx := r.Context()
|
||||
now := time.Now()
|
||||
minuteTimestamp := now.Unix() / 60
|
||||
key := fmt.Sprintf("ratelimit:open:%d:%d", appDBID, minuteTimestamp)
|
||||
|
||||
count, err := g.Redis().Do(ctx, "INCR", key)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[OpenRateLimit] INCR failed app=%d: %v", appDBID, err)
|
||||
// Fail open on Redis error
|
||||
return true
|
||||
}
|
||||
|
||||
currentCount := count.Int64()
|
||||
if currentCount == 1 {
|
||||
// First request in this window, set TTL
|
||||
_, _ = g.Redis().Do(ctx, "EXPIRE", key, 60)
|
||||
}
|
||||
|
||||
remaining := rateLimit - int(currentCount)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
r.Response.Header().Set("X-RateLimit-Limit", strconv.Itoa(rateLimit))
|
||||
r.Response.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
|
||||
if int(currentCount) > rateLimit {
|
||||
response.ErrorWithCode(r, 429, consts.CodeRateLimitExceeded, consts.MsgRateLimitExceeded)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetOpenPermissions extracts the permissions string (JSON) injected by OpenPlatformAuth.
|
||||
func GetOpenPermissions(ctx context.Context) string {
|
||||
val := ctx.Value(ctxKeyOpenPermissions)
|
||||
if val != nil {
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckOpenPermission checks if the authenticated app has the given permission.
|
||||
// Returns error if permission is missing.
|
||||
func CheckOpenPermission(ctx context.Context, permission string) error {
|
||||
perms := GetOpenPermissions(ctx)
|
||||
if perms == "" {
|
||||
return gerror.NewCode(gcode.New(consts.CodeForbidden, "无权限", nil), "应用无此操作权限")
|
||||
}
|
||||
var list []string
|
||||
if err := json.Unmarshal([]byte(perms), &list); err != nil {
|
||||
return gerror.NewCode(gcode.New(consts.CodeForbidden, "无权限", nil), "应用无此操作权限")
|
||||
}
|
||||
for _, p := range list {
|
||||
if p == permission {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return gerror.NewCode(gcode.New(consts.CodeForbidden, "无权限", nil), "应用无此操作权限")
|
||||
}
|
||||
@@ -21,6 +21,17 @@ type (
|
||||
OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (*v1.OpenKeyDeleteRes, error)
|
||||
OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (*v1.OpenUsageQueryRes, error)
|
||||
OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryReq) (*v1.OpenBillingQueryRes, error)
|
||||
OpenProjectList(ctx context.Context, req *v1.OpenProjectListReq) (*v1.OpenProjectListRes, error)
|
||||
OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreateReq) (*v1.OpenProjectCreateRes, error)
|
||||
OpenProjectGet(ctx context.Context, req *v1.OpenProjectGetReq) (*v1.OpenProjectGetRes, error)
|
||||
OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdateReq) (*v1.OpenProjectUpdateRes, error)
|
||||
OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchiveReq) (*v1.OpenProjectArchiveRes, error)
|
||||
OpenProjectUnarchive(ctx context.Context, req *v1.OpenProjectUnarchiveReq) (*v1.OpenProjectUnarchiveRes, error)
|
||||
OpenProjectKeyList(ctx context.Context, req *v1.OpenProjectKeyListReq) (*v1.OpenProjectKeyListRes, error)
|
||||
OpenProjectKeyCreate(ctx context.Context, req *v1.OpenProjectKeyCreateReq) (*v1.OpenProjectKeyCreateRes, error)
|
||||
OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKeyDeleteReq) (*v1.OpenProjectKeyDeleteRes, error)
|
||||
OpenProjectUsageStats(ctx context.Context, req *v1.OpenProjectUsageStatsReq) (*v1.OpenProjectUsageStatsRes, error)
|
||||
OpenProjectUsageLogs(ctx context.Context, req *v1.OpenProjectUsageLogsReq) (*v1.OpenProjectUsageLogsRes, error)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ type (
|
||||
ExportWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (*v1.TenantWalletTransactionsExportRes, error)
|
||||
// WalletFrozenItems 获取冻结明细
|
||||
WalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (*v1.TenantWalletFrozenItemsRes, error)
|
||||
// UpdateWarningThreshold 修改钱包预警阈值
|
||||
UpdateWarningThreshold(ctx context.Context, req *v1.TenantWalletUpdateWarningThresholdReq) (*v1.TenantWalletUpdateWarningThresholdRes, error)
|
||||
// Dashboard returns the tenant dashboard statistics.
|
||||
Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v1.TenantDashboardRes, error)
|
||||
// TokenTrends returns daily token usage for the past N days.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- +goose Up
|
||||
-- Webhook 性能优化:补充索引 + 事件清理策略
|
||||
-- 1. opn_webhook_configs: GIN 索引支持 JSONB @> 查询,复合索引加速活跃配置查找
|
||||
-- 2. opn_webhook_delivery_logs: 按配置+租户查询索引
|
||||
-- 3. opn_webhook_events: 按配置查询索引(级联删除用),BRIN 索引支持追加写入
|
||||
|
||||
-- 1. JSONB GIN 索引:支持 PublishWebhookEvent 中的 events::jsonb @> ? 查询
|
||||
CREATE INDEX idx_opn_webhook_configs_events_gin ON opn_webhook_configs USING gin (events jsonb_path_ops);
|
||||
|
||||
-- 2. 复合索引:加速查找租户的活跃 webhook 配置
|
||||
CREATE INDEX idx_opn_webhook_configs_tenant_active ON opn_webhook_configs (tenant_id, is_active) WHERE is_active = true;
|
||||
|
||||
-- 3. 按配置+租户查询投递日志(WebhookDeliveryLogs API 使用)
|
||||
CREATE INDEX idx_opn_webhook_delivery_logs_config ON opn_webhook_delivery_logs (webhook_config_id, tenant_id, created_at DESC);
|
||||
|
||||
-- 4. 按配置查询事件(级联删除时使用)
|
||||
CREATE INDEX idx_opn_webhook_events_config ON opn_webhook_events (webhook_config_id);
|
||||
|
||||
-- 5. BRIN 索引:events 表追加写入模式,磁盘友好
|
||||
CREATE INDEX idx_opn_webhook_events_created_brin ON opn_webhook_events USING brin (created_at);
|
||||
|
||||
-- 6. BRIN 索引:delivery_logs 表追加写入模式
|
||||
CREATE INDEX idx_opn_webhook_delivery_logs_created_brin ON opn_webhook_delivery_logs USING brin (created_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_opn_webhook_delivery_logs_created_brin;
|
||||
DROP INDEX IF EXISTS idx_opn_webhook_events_created_brin;
|
||||
DROP INDEX IF EXISTS idx_opn_webhook_events_config;
|
||||
DROP INDEX IF EXISTS idx_opn_webhook_delivery_logs_config;
|
||||
DROP INDEX IF EXISTS idx_opn_webhook_configs_tenant_active;
|
||||
DROP INDEX IF EXISTS idx_opn_webhook_configs_events_gin;
|
||||
@@ -67,6 +67,33 @@ const isLowBalance = computed(() => {
|
||||
return wallet.value.available_balance <= (wallet.value.warning_threshold || 0)
|
||||
})
|
||||
|
||||
// Warning threshold setting
|
||||
const showThresholdModal = ref(false)
|
||||
const thresholdInput = ref('')
|
||||
const thresholdSaving = ref(false)
|
||||
|
||||
function openThresholdModal() {
|
||||
thresholdInput.value = wallet.value?.warning_threshold?.toFixed(2) ?? '1.00'
|
||||
showThresholdModal.value = true
|
||||
}
|
||||
|
||||
async function saveThreshold() {
|
||||
const val = parseFloat(thresholdInput.value)
|
||||
if (isNaN(val) || val < 0) return
|
||||
thresholdSaving.value = true
|
||||
try {
|
||||
const res: any = await request.put('/tenant/wallet/warning-threshold', { threshold: val })
|
||||
if (res.data?.code === 0) {
|
||||
showThresholdModal.value = false
|
||||
await fetchWallet()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
thresholdSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWallet() {
|
||||
try {
|
||||
const res: any = await request.get('/tenant/wallet')
|
||||
@@ -307,6 +334,15 @@ onBeforeUnmount(() => {
|
||||
<div v-if="isLowBalance && wallet" class="balance-warning">
|
||||
<Icon name="exclamationTriangle" size="xs" />
|
||||
<span>余额低于预警线 ${{ wallet.warning_threshold?.toFixed(2) }}</span>
|
||||
<button class="ml-2 underline underline-offset-2 opacity-70 hover:opacity-100" @click="openThresholdModal">修改</button>
|
||||
</div>
|
||||
|
||||
<!-- Threshold setting (when not low balance) -->
|
||||
<div v-else-if="wallet" class="mt-3">
|
||||
<button class="balance-threshold-btn" @click="openThresholdModal">
|
||||
<Icon name="cog" size="xs" />
|
||||
<span>预警线: ${{ wallet.warning_threshold?.toFixed(2) || '0.00' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Secondary balances -->
|
||||
@@ -599,6 +635,47 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Warning Threshold Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showThresholdModal" class="modal-overlay" @click.self="showThresholdModal = false">
|
||||
<div class="modal-content bg-white w-full max-w-md">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">余额预警设置</h3>
|
||||
<button class="btn btn-ghost btn-icon" @click="showThresholdModal = false">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body space-y-4">
|
||||
<div class="rounded-xl bg-gray-50 border border-gray-200 p-4">
|
||||
<p class="text-sm text-gray-600">当可用余额低于设定的阈值时,系统将通过站内通知和 Webhook 推送预警消息。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">预警阈值(USD)</label>
|
||||
<div class="relative mt-1">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
v-model="thresholdInput"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input pl-7"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">设为 0 表示关闭余额预警</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showThresholdModal = false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="thresholdSaving" @click="saveThreshold">
|
||||
<div v-if="thresholdSaving" class="spinner h-4 w-4 border-white"></div>
|
||||
{{ thresholdSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -696,6 +773,12 @@ onBeforeUnmount(() => {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.balance-threshold-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.375rem;
|
||||
font-size: 0.75rem; font-weight: 500; color: rgba(255,255,255,0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.balance-threshold-btn:hover { color: rgba(255,255,255,0.75); }
|
||||
.balance-chip {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-radius: 0.75rem; padding: 0.625rem 0.875rem;
|
||||
|
||||
@@ -45,6 +45,7 @@ const newAppId = ref('')
|
||||
const availablePermissions = [
|
||||
'members:read', 'members:write',
|
||||
'keys:read', 'keys:write',
|
||||
'projects:read', 'projects:write',
|
||||
'models:read',
|
||||
'usage:read',
|
||||
'billing:read',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -21,6 +21,7 @@ interface WebhookConfig {
|
||||
interface DeliveryLog {
|
||||
id: number
|
||||
event_id: number
|
||||
event_type: string
|
||||
attempt: number
|
||||
response_status: number
|
||||
response_time_ms: number
|
||||
@@ -49,13 +50,15 @@ const showLogsModal = ref(false)
|
||||
const logs = ref<DeliveryLog[]>([])
|
||||
const logsLoading = ref(false)
|
||||
const logsConfigId = ref(0)
|
||||
const logsPage = ref(1)
|
||||
const logsPageSize = 20
|
||||
const logsTotal = ref(0)
|
||||
const logsStatusFilter = ref('')
|
||||
|
||||
const logsTotalPages = computed(() => Math.max(1, Math.ceil(logsTotal.value / logsPageSize)))
|
||||
|
||||
const availableEvents = [
|
||||
'member.created', 'member.updated', 'member.deleted',
|
||||
'key.created', 'key.deleted',
|
||||
'order.paid', 'order.refunded',
|
||||
'plan.subscribed', 'plan.expired',
|
||||
'wallet.topup', 'wallet.low_balance',
|
||||
'wallet.low_balance',
|
||||
]
|
||||
|
||||
onMounted(() => loadConfigs())
|
||||
@@ -79,7 +82,7 @@ function openCreateModal() {
|
||||
form.id = 0
|
||||
form.name = ''
|
||||
form.url = ''
|
||||
form.events = []
|
||||
form.events = ['wallet.low_balance']
|
||||
form.max_consecutive_failures = 10
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -153,12 +156,26 @@ async function handleToggle(cfg: WebhookConfig) {
|
||||
|
||||
async function loadLogs(configId: number) {
|
||||
logsConfigId.value = configId
|
||||
logsPage.value = 1
|
||||
logsStatusFilter.value = ''
|
||||
showLogsModal.value = true
|
||||
await fetchLogs()
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await request.get(`/tenant/open/webhooks/${configId}/logs`)
|
||||
const params: Record<string, string | number> = {
|
||||
page: logsPage.value,
|
||||
page_size: logsPageSize,
|
||||
}
|
||||
if (logsStatusFilter.value) {
|
||||
params.status = logsStatusFilter.value
|
||||
}
|
||||
const res = await request.get(`/tenant/open/webhooks/${logsConfigId.value}/logs`, { params })
|
||||
if (res.data?.code === 0) {
|
||||
logs.value = res.data.data.list || []
|
||||
logsTotal.value = res.data.data.total || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -167,24 +184,39 @@ async function loadLogs(configId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function setLogsFilter(status: string) {
|
||||
logsStatusFilter.value = status
|
||||
logsPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
function goToLogsPage(page: number) {
|
||||
if (page < 1 || page > logsTotalPages.value) return
|
||||
logsPage.value = page
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
async function handleRetry(eventId: number) {
|
||||
try {
|
||||
const res = await request.post(`/tenant/open/webhooks/events/${eventId}/retry`)
|
||||
if (res.data?.code === 0) await loadLogs(logsConfigId.value)
|
||||
if (res.data?.code === 0) await fetchLogs()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEvent(evt: string) {
|
||||
const idx = form.events.indexOf(evt)
|
||||
if (idx >= 0) form.events.splice(idx, 1)
|
||||
else form.events.push(evt)
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
function isLogSuccess(log: DeliveryLog) {
|
||||
return log.response_status >= 200 && log.response_status < 300
|
||||
}
|
||||
|
||||
function formatStatus(status: number) {
|
||||
if (!status) return '---'
|
||||
return String(status)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -226,7 +258,7 @@ function copyToClipboard(text: string) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<button class="btn btn-ghost btn-sm" @click="loadLogs(cfg.id)">日志</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="loadLogs(cfg.id)">投递记录</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="handleToggle(cfg)">
|
||||
{{ cfg.is_active ? '禁用' : '启用' }}
|
||||
</button>
|
||||
@@ -268,11 +300,9 @@ function copyToClipboard(text: string) {
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">订阅事件</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label v-for="evt in availableEvents" :key="evt" class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" :checked="form.events.includes(evt)" @change="toggleEvent(evt)" class="rounded border-gray-300" />
|
||||
<span class="text-sm">{{ evt }}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="badge badge-primary">wallet.low_balance</span>
|
||||
<span class="text-xs text-gray-400">余额低于预警线时触发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -326,39 +356,115 @@ function copyToClipboard(text: string) {
|
||||
<!-- Delivery Logs Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showLogsModal" class="modal-overlay" @click.self="showLogsModal = false">
|
||||
<div class="modal-content bg-white w-full max-w-2xl max-h-[80vh]">
|
||||
<div class="modal-content bg-white w-full max-w-3xl max-h-[85vh]">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">投递日志</h3>
|
||||
<h3 class="modal-title">投递记录</h3>
|
||||
<button class="btn btn-ghost btn-icon" @click="showLogsModal = false">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="px-4 sm:px-6 pt-4">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ 'tab-active': logsStatusFilter === '' }"
|
||||
@click="setLogsFilter('')"
|
||||
>全部</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ 'tab-active': logsStatusFilter === 'delivered' }"
|
||||
@click="setLogsFilter('delivered')"
|
||||
>已送达</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ 'tab-active': logsStatusFilter === 'failed' }"
|
||||
@click="setLogsFilter('failed')"
|
||||
>失败</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-8">
|
||||
<!-- Loading -->
|
||||
<div v-if="logsLoading" class="text-center py-12">
|
||||
<div class="spinner h-6 w-6 mx-auto"></div>
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无投递日志
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="logs.length === 0" class="text-center py-12 text-gray-500">
|
||||
<p>暂无投递记录</p>
|
||||
<p class="text-xs mt-1 text-gray-400">事件触发后将在此显示投递详情</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="log in logs" :key="log.id" class="rounded-xl border border-gray-200 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
|
||||
<!-- Log list -->
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="rounded-xl border p-3 transition-colors"
|
||||
:class="isLogSuccess(log) ? 'border-gray-200 hover:bg-gray-50' : 'border-red-200 bg-red-50/30 hover:bg-red-50/50'"
|
||||
>
|
||||
<!-- Row 1: event type + status badge + time -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="log.response_status >= 200 && log.response_status < 300 ? 'text-emerald-600' : 'text-red-600'" class="text-sm font-medium">
|
||||
{{ log.response_status || '---' }}
|
||||
<span class="badge text-xs" :class="isLogSuccess(log) ? 'badge-success' : 'badge-danger'">
|
||||
{{ isLogSuccess(log) ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">第 {{ log.attempt }} 次</span>
|
||||
<span class="badge badge-primary text-xs">{{ log.event_type || '未知事件' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">{{ log.response_time_ms }}ms</span>
|
||||
<span class="text-xs text-gray-400">{{ log.created_at }}</span>
|
||||
<button class="btn btn-ghost btn-sm text-xs" @click="handleRetry(log.event_id)">重试</button>
|
||||
<span class="text-xs text-gray-400">{{ log.created_at }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: details -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
HTTP
|
||||
<span class="font-mono font-medium" :class="isLogSuccess(log) ? 'text-emerald-600' : 'text-red-600'">
|
||||
{{ formatStatus(log.response_status) }}
|
||||
</span>
|
||||
</span>
|
||||
<span>第 {{ log.attempt }} 次尝试</span>
|
||||
<span v-if="log.response_time_ms">
|
||||
<span :class="log.response_time_ms > 3000 ? 'text-amber-600 font-medium' : ''">{{ log.response_time_ms }}ms</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="!isLogSuccess(log)"
|
||||
class="btn btn-ghost btn-sm text-xs"
|
||||
@click="handleRetry(log.event_id)"
|
||||
>重试</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="log.error_message" class="text-xs text-red-500">{{ log.error_message }}</p>
|
||||
|
||||
<!-- Row 3: error message -->
|
||||
<p v-if="log.error_message" class="mt-2 text-xs text-red-600 bg-red-100/60 rounded-lg px-2 py-1 break-all">
|
||||
{{ log.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="logsTotal > logsPageSize" class="modal-footer justify-between">
|
||||
<span class="text-xs text-gray-400">
|
||||
共 {{ logsTotal }} 条记录,第 {{ logsPage }} / {{ logsTotalPages }} 页
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="logsPage <= 1"
|
||||
@click="goToLogsPage(logsPage - 1)"
|
||||
>上一页</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="goToLogsPage(logsPage + 1)"
|
||||
>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
Reference in new issue
Block a user