diff --git a/api/admin/admin.go b/api/admin/admin.go index 041ace0..9c3741a 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -78,6 +78,7 @@ type IAdminV1 interface { AdminWalletSetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (res *v1.AdminWalletSetWarningThresholdRes, err error) AdminUsageLogExport(ctx context.Context, req *v1.AdminUsageLogExportReq) (res *v1.AdminUsageLogExportRes, err error) AdminBillingRecordExport(ctx context.Context, req *v1.AdminBillingRecordExportReq) (res *v1.AdminBillingRecordExportRes, err error) + AdminTransactionList(ctx context.Context, req *v1.AdminTransactionListReq) (res *v1.AdminTransactionListRes, err error) DataGovernanceSettingsGet(ctx context.Context, req *v1.DataGovernanceSettingsGetReq) (res *v1.DataGovernanceSettingsGetRes, err error) DataGovernanceSettingsUpdate(ctx context.Context, req *v1.DataGovernanceSettingsUpdateReq) (res *v1.DataGovernanceSettingsUpdateRes, err error) DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (res *v1.DataGovernanceExportRes, err error) diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index beff1e6..fa62f96 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -323,3 +323,39 @@ type AdminBillingRecordExportReq struct { } type AdminBillingRecordExportRes struct{} + +// === 交易流水 === + +type AdminTransactionListReq struct { + g.Meta `path:"/transactions" method:"get" mime:"json" tags:"管理后台-交易" summary:"交易流水列表"` + Page int `json:"page" d:"1" dc:"页码"` + PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` + TenantID int64 `json:"tenant_id" dc:"租户ID"` + Type string `json:"type" dc:"交易类型:consume/recharge/adjust"` + Username string `json:"username" dc:"用户名(模糊匹配)"` + ModelName string `json:"model_name" dc:"模型名称(模糊匹配)"` + StartDate string `json:"start_date" dc:"开始日期"` + EndDate string `json:"end_date" dc:"结束日期"` +} + +type AdminTransactionItem struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Type string `json:"type"` + Amount float64 `json:"amount"` + BalanceAfter float64 `json:"balance_after"` + Description string `json:"description"` + UserId int64 `json:"user_id"` + Username string `json:"username"` + RequestId string `json:"request_id"` + ModelName string `json:"model_name"` + CreatedAt string `json:"created_at"` +} + +type AdminTransactionListRes struct { + List []*AdminTransactionItem `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index 5112c09..7aab302 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -153,6 +153,7 @@ type ITenantV1 interface { TenantLoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (res *v1.TenantLoginHistoryRes, err error) TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) (res *v1.TenantTaskListRes, err error) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (res *v1.TenantTaskDetailRes, err error) + TenantTaskExport(ctx context.Context, req *v1.TenantTaskExportReq) (res *v1.TenantTaskExportRes, err error) TenantTicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (res *v1.TenantTicketCreateRes, err error) TenantTicketList(ctx context.Context, req *v1.TenantTicketListReq) (res *v1.TenantTicketListRes, err error) TenantTicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (res *v1.TenantTicketGetRes, err error) diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index ee1bd98..122ad3a 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -4,15 +4,16 @@ import "github.com/gogf/gf/v2/frame/g" // TenantRegisterReq 租户注册请求 type TenantRegisterReq struct { - g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"` - Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"` - Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"` - Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` - TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"` - TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"` - Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"` - CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(邮箱验证关闭时必填)"` - CaptchaX int `json:"captcha_x" dc:"滑块X坐标(邮箱验证关闭时必填)"` + g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"` + Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"` + Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"` + Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` + TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"` + TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"` + Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"` + CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"` + CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"` + TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"` } type TenantRegisterRes struct { @@ -33,12 +34,13 @@ type TenantRegisterRes struct { // TenantLoginReq 租户登录请求 type TenantLoginReq struct { - g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"` - Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"` - Password string `json:"password" v:"required#请输入密码" dc:"密码"` - Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"` - CaptchaKey string `json:"captcha_key" dc:"验证码key(验证码启用时必填)"` - CaptchaX int `json:"captcha_x" dc:"滑块X坐标(验证码启用时必填)"` + g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"` + Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"` + Password string `json:"password" v:"required#请输入密码" dc:"密码"` + Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"` + CaptchaKey string `json:"captcha_key" dc:"验证码key(Turnstile关闭时必填)"` + CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"` + TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"` } type TenantLoginRes struct { diff --git a/api/tenant/v1/task_tenant.go b/api/tenant/v1/task_tenant.go index 340b85a..a614aee 100644 --- a/api/tenant/v1/task_tenant.go +++ b/api/tenant/v1/task_tenant.go @@ -47,3 +47,14 @@ type TenantTaskDetailReq struct { type TenantTaskDetailRes struct { Task TenantTaskItem `json:"task"` } + +// TenantTaskExportReq 导出异步任务日志请求 +type TenantTaskExportReq struct { + g.Meta `path:"/tasks/export" method:"get" mime:"json" tags:"租户控制台-任务管理" summary:"导出异步任务日志"` + Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` + Status string `json:"status" in:"query" dc:"筛选状态"` + Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"` + PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"` +} + +type TenantTaskExportRes struct{} diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 0413d3f..c4e419b 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -174,6 +174,8 @@ const ( // Registration errors CodeRegistrationDisabled = 10077 CodeEmailVerificationDisabled = 10082 + CodeIpRateLimitExceeded = 10090 // IP级别限流 + CodeGlobalRateLimitExceeded = 10091 // 全局限流 // Setup errors CodeSetupCompleted = 10078 @@ -308,6 +310,8 @@ const ( // Registration errors MsgRegistrationDisabled = "注册功能已关闭" MsgEmailVerificationDisabled = "注册邮箱验证未启用" + MsgIpRateLimitExceeded = "该IP注册频率过高,请稍后再试" + MsgGlobalRateLimitExceeded = "系统注册繁忙,请稍后再试" // Setup errors MsgSetupCompleted = "系统已完成初始化" diff --git a/internal/controller/admin/admin_v1_dashboard.go b/internal/controller/admin/admin_v1_dashboard.go index 1d562a5..2c7a99d 100644 --- a/internal/controller/admin/admin_v1_dashboard.go +++ b/internal/controller/admin/admin_v1_dashboard.go @@ -52,3 +52,6 @@ func (c *ControllerV1) AdminUsageLogExport(ctx context.Context, req *v1.AdminUsa func (c *ControllerV1) AdminBillingRecordExport(ctx context.Context, req *v1.AdminBillingRecordExportReq) (res *v1.AdminBillingRecordExportRes, err error) { return service.Admin().ExportBillingRecords(ctx, req) } +func (c *ControllerV1) AdminTransactionList(ctx context.Context, req *v1.AdminTransactionListReq) (res *v1.AdminTransactionListRes, err error) { + return service.Admin().GetAllTransactions(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_task_tenant.go b/internal/controller/tenant/tenant_v1_task_tenant.go index c438290..2b8f344 100644 --- a/internal/controller/tenant/tenant_v1_task_tenant.go +++ b/internal/controller/tenant/tenant_v1_task_tenant.go @@ -13,3 +13,6 @@ func (c *ControllerV1) TenantTaskList(ctx context.Context, req *v1.TenantTaskLis func (c *ControllerV1) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (res *v1.TenantTaskDetailRes, err error) { return service.Tenant().TenantTaskDetail(ctx, req) } +func (c *ControllerV1) TenantTaskExport(ctx context.Context, req *v1.TenantTaskExportReq) (res *v1.TenantTaskExportRes, err error) { + return service.Tenant().ExportTasks(ctx, req) +} diff --git a/internal/dao/internal/aud_request_logs.go b/internal/dao/internal/aud_request_logs.go index 8cb94c2..891cb4f 100644 --- a/internal/dao/internal/aud_request_logs.go +++ b/internal/dao/internal/aud_request_logs.go @@ -52,6 +52,7 @@ type AudRequestLogsColumns struct { TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt string // 异步任务达到终态的时间 AttachmentIds string // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3]) + Model string // 请求使用的模型名称(从请求体或 URL 中提取) } // audRequestLogsColumns holds the columns for the table aud_request_logs. @@ -87,6 +88,7 @@ var audRequestLogsColumns = AudRequestLogsColumns{ TaskUpstreamHeaders: "task_upstream_headers", TaskCompletedAt: "task_completed_at", AttachmentIds: "attachment_ids", + Model: "model", } // NewAudRequestLogsDao creates and returns a new DAO object for table data access. diff --git a/internal/handler/relay/relay.go b/internal/handler/relay/relay.go index c3730f1..13378d2 100644 --- a/internal/handler/relay/relay.go +++ b/internal/handler/relay/relay.go @@ -453,11 +453,15 @@ func captureRequestHeaders(r *ghttp.Request) map[string]string { func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCaptureWriter, body []byte, path string, latencyMs int, ttft int) { // 解析 stream 标志 var isStream bool + var modelName string var rawRequest map[string]json.RawMessage if err := json.Unmarshal(body, &rawRequest); err == nil { if streamVal, ok := rawRequest["stream"]; ok { _ = json.Unmarshal(streamVal, &isStream) } + if modelVal, ok := rawRequest["model"]; ok { + _ = json.Unmarshal(modelVal, &modelName) + } } // 捕获响应体(流式请求记录 SSE 原始数据,非流式记录完整响应) @@ -476,6 +480,7 @@ func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCa StatusCode: capture.StatusCode(), ClientIP: rc.ClientIP, UserAgent: r.Header.Get("User-Agent"), + Model: modelName, RequestBody: string(body), ResponseBody: responseBody, LatencyMs: latencyMs, diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index e2b83d8..0634405 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -544,6 +544,87 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet return &v1.AdminWalletSetWarningThresholdRes{}, nil } +// GetAllTransactions 获取所有租户交易流水(管理后台) +func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) { + page, pageSize := common.NormalizePagination(req.Page, req.PageSize) + + query := dao.BilTransactions.Ctx(ctx) + + if req.TenantID > 0 { + query = query.Where("bil_transactions.tenant_id", req.TenantID) + } + if req.Type != "" { + query = query.Where("bil_transactions.type", req.Type) + } + if req.Username != "" { + query = query.Where("tu.username LIKE ?", "%"+req.Username+"%") + } + if req.ModelName != "" { + query = query.Where("bil_transactions.model_name LIKE ?", "%"+req.ModelName+"%") + } + if req.StartDate != "" { + query = query.Where("bil_transactions.created_at >= ?", req.StartDate+" 00:00:00") + } + if req.EndDate != "" { + query = query.Where("bil_transactions.created_at <= ?", req.EndDate+" 23:59:59") + } + + type transactionRow struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Type string `json:"type"` + Amount float64 `json:"amount"` + BalanceAfter float64 `json:"balance_after"` + Description string `json:"description"` + UserId int64 `json:"user_id"` + Username string `json:"username"` + RequestId string `json:"request_id"` + ModelName string `json:"model_name"` + CreatedAt *gtime.Time `json:"created_at"` + } + + var records []*transactionRow + var total int + err := query.Fields("bil_transactions.id, bil_transactions.tenant_id, COALESCE(tn.name, '') AS tenant_name, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at"). + LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id"). + LeftJoin("tnt_tenants tn", "bil_transactions.tenant_id = tn.id"). + OrderDesc("bil_transactions.created_at"). + Page(page, pageSize). + ScanAndCount(&records, &total, false) + if err != nil { + return nil, err + } + if records == nil { + records = make([]*transactionRow, 0) + } + + list := make([]*v1.AdminTransactionItem, 0, len(records)) + for _, r := range records { + list = append(list, &v1.AdminTransactionItem{ + Id: r.Id, + TenantId: r.TenantId, + TenantName: r.TenantName, + Type: r.Type, + Amount: r.Amount, + BalanceAfter: r.BalanceAfter, + Description: r.Description, + UserId: r.UserId, + Username: r.Username, + RequestId: r.RequestId, + ModelName: r.ModelName, + CreatedAt: r.CreatedAt.String(), + }) + } + + return &v1.AdminTransactionListRes{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + // GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道) func (s *sAdmin) GetDashboardChannelHealth(ctx context.Context, req *v1.AdminDashboardChannelHealthReq) (*v1.AdminDashboardChannelHealthRes, error) { var items []v1.ChannelHealthItem diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index 46919fd..78c0438 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -90,7 +90,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode MaxOutputTokens int `json:"max_output_tokens"` Capabilities string `json:"capabilities"` Description string `json:"description"` - Tags string `json:"tags"` + Tags []string `json:"tags"` CreatedAt *gtime.Time `json:"created_at"` UpdatedAt *gtime.Time `json:"updated_at"` DeprecatedAt *gtime.Time `json:"deprecated_at"` @@ -145,7 +145,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode MaxOutput: m.MaxOutputTokens, Capabilities: parseCapabilities(m.Capabilities), Description: m.Description, - Tags: parseTagsArray(m.Tags), + Tags: m.Tags, CreatedAt: m.CreatedAt.String(), UpdatedAt: m.UpdatedAt.String(), } @@ -362,10 +362,6 @@ func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.M return nil, nil } -func parseTagsArray(tags string) []string { - return parsePgArray(tags) -} - func parseCapabilities(raw string) map[string]bool { if raw == "" || raw == "{}" { return nil diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index ad5dc90..452d74a 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -6,7 +6,6 @@ import ( "encoding/json" "io" "net/url" - "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" @@ -32,7 +31,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"` MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"` Description string `orm:"description" json:"description"` - Tags string `orm:"tags" json:"tags"` + Tags []string `orm:"tags" json:"tags"` Capabilities string `orm:"capabilities" json:"capabilities"` SunsetDate *gtime.Time `orm:"sunset_date" json:"sunset_date"` ReplacementModel string `orm:"replacement_model" json:"replacement_model"` @@ -120,9 +119,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe if m.SunsetDate != nil { em.SunsetDate = m.SunsetDate.Format("Y-m-d") } - if m.Tags != "" { - em.Tags = parsePgArray(m.Tags) - } + em.Tags = m.Tags result = append(result, em) } @@ -334,15 +331,3 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. return res, err } - -// parsePgArray 解析 PostgreSQL 数组格式的字符串,如 {tag1,tag2} -func parsePgArray(raw string) []string { - if raw == "" || raw == "{}" || raw == "NULL" { - return nil - } - s := strings.Trim(raw, "{}") - if s == "" { - return nil - } - return strings.Split(s, ",") -} diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index 070d6e0..0ddcc3d 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -2,6 +2,7 @@ package admin import ( "context" + "strings" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/logic/common" @@ -56,12 +57,46 @@ func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdate for key, val := range req.Settings { strValues[key] = common.NormalizeSettingValue(val) } + + if err := s.validateCrossFieldSettings(ctx, req.Category, strValues); err != nil { + return nil, err + } + if err := common.Config().UpdateCategory(ctx, req.Category, strValues); err != nil { return nil, err } return nil, nil } +// validateCrossFieldSettings validates interdependent settings within a category. +func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error { + if category != "security" { + return nil + } + + enabled, ok := values["turnstile_enabled"] + if !ok || (enabled != "true" && enabled != "1") { + return nil + } + + siteKey := values["turnstile_site_key"] + secretKey := values["turnstile_secret_key"] + + // If keys not in the current request, read existing values from DB + if siteKey == "" { + siteKey = common.Config().GetString(ctx, "turnstile_site_key") + } + if secretKey == "" || secretKey == "******" { + secretKey = common.Config().GetString(ctx, "turnstile_secret_key") + } + + if strings.TrimSpace(siteKey) == "" || strings.TrimSpace(secretKey) == "" { + return common.NewBadRequestError("启用 Turnstile 前必须先配置 Site Key 和 Secret Key") + } + + return nil +} + func isValidCategory(category string) bool { for _, c := range common.Categories { if c.Key == category { diff --git a/internal/logic/common/captcha.go b/internal/logic/common/captcha.go index 5eca1f1..6084b5d 100644 --- a/internal/logic/common/captcha.go +++ b/internal/logic/common/captcha.go @@ -15,6 +15,7 @@ import ( "github.com/wenlng/go-captcha/v2/slide" "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/utility/turnstile" ) const ( @@ -211,3 +212,46 @@ func getCaptchaExpireSeconds(ctx context.Context) time.Duration { } return time.Duration(secs) * time.Second } + +// CheckTurnstileRequired checks Cloudflare Turnstile verification when enabled. +// If Turnstile is disabled, passes through (allows fallback to captcha). +func CheckTurnstileRequired(ctx context.Context, token string) error { + // Check if Turnstile is enabled + enabled := Config().GetBool(ctx, "turnstile_enabled") + if !enabled { + // Turnstile not enabled, allow pass-through to captcha + return nil + } + + // Turnstile is enabled, token is required + if token == "" { + return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired) + } + + // Verify token with Cloudflare + secretKey := Config().GetString(ctx, "turnstile_secret_key") + if secretKey == "" { + g.Log().Errorf(ctx, "[Turnstile] secret_key not configured") + return NewBusinessError(consts.CodeInternalServerError, "验证服务配置错误") + } + + // Get client IP for verification + ipAddress := "" + if r := g.RequestFromCtx(ctx); r != nil { + ipAddress = r.GetClientIp() + } + + result, err := turnstile.Verify(ctx, secretKey, token, ipAddress) + if err != nil { + g.Log().Errorf(ctx, "[Turnstile] verification failed: %v", err) + return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败") + } + + if !result.Success { + g.Log().Warningf(ctx, "[Turnstile] verification failed: error codes=%v", result.ErrorCodes) + return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败") + } + + // Verification successful + return nil +} diff --git a/internal/logic/common/register_rate_limit.go b/internal/logic/common/register_rate_limit.go new file mode 100644 index 0000000..f5322a8 --- /dev/null +++ b/internal/logic/common/register_rate_limit.go @@ -0,0 +1,155 @@ +package common + +import ( + "context" + "fmt" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/consts" +) + +// CheckRegisterRateLimit 检查注册速率限制 +// 1. IP级别限流(每小时/每天) +// 2. 全局限流(每分钟) +func CheckRegisterRateLimit(ctx context.Context, ipAddress string) error { + // 1. 检查IP级别限流 + if err := checkIPRateLimit(ctx, ipAddress); err != nil { + return err + } + + // 2. 检查全局限流 + if err := checkGlobalRateLimit(ctx); err != nil { + return err + } + + return nil +} + +// checkIPRateLimit 检查IP级别的注册限流 +func checkIPRateLimit(ctx context.Context, ipAddress string) error { + if ipAddress == "" { + return nil // 无法获取IP时跳过检查 + } + + // 每小时限制 + hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour") + if hourlyLimit > 0 { + hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress) + count, err := incrementWithExpire(ctx, hourlyKey, 3600) // 1小时过期 + if err != nil { + g.Log().Warningf(ctx, "IP注册限流检查失败: %v", err) + return nil // Redis失败时不阻塞注册 + } + if count > int64(hourlyLimit) { + return NewBusinessError(consts.CodeIpRateLimitExceeded, consts.MsgIpRateLimitExceeded) + } + } + + // 每天限制 + dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day") + if dailyLimit > 0 { + dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress) + count, err := incrementWithExpire(ctx, dailyKey, 86400) // 24小时过期 + if err != nil { + g.Log().Warningf(ctx, "IP注册限流检查失败: %v", err) + return nil // Redis失败时不阻塞注册 + } + if count > int64(dailyLimit) { + return NewBusinessError(consts.CodeIpRateLimitExceeded, consts.MsgIpRateLimitExceeded) + } + } + + return nil +} + +// checkGlobalRateLimit 检查全局注册速率限制 +func checkGlobalRateLimit(ctx context.Context) error { + globalLimit := Config().GetInt(ctx, "register_global_limit_per_minute") + if globalLimit <= 0 { + return nil // 未启用全局限流 + } + + // 使用当前分钟的Unix时间戳作为key,确保每分钟重置 + now := time.Now() + minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60) // 每分钟一个key + + count, err := incrementWithExpire(ctx, minuteKey, 120) // 2分钟过期(确保当前分钟和下一分钟都能计数) + if err != nil { + g.Log().Warningf(ctx, "全局注册限流检查失败: %v", err) + return nil // Redis失败时不阻塞注册 + } + + if count > int64(globalLimit) { + return NewBusinessError(consts.CodeGlobalRateLimitExceeded, consts.MsgGlobalRateLimitExceeded) + } + + return nil +} + +// incrementWithExpire 原子递增计数器,首次递增时设置过期时间 +// 使用Lua脚本确保原子性:INCR + EXPIRE(仅在第一次) +func incrementWithExpire(ctx context.Context, key string, expireSeconds int) (int64, error) { + // Lua脚本:先递增,如果结果为1则设置过期时间 + luaScript := ` + local count = redis.call("INCR", KEYS[1]) + if count == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) + end + return count + ` + + result, err := g.Redis().Do(ctx, "EVAL", luaScript, 1, key, expireSeconds) + if err != nil { + return 0, err + } + + return result.Int64(), nil +} + +// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示) +func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[string]any { + status := make(map[string]any) + + if ipAddress == "" { + return status + } + + // 查询IP每小时限制 + hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour") + if hourlyLimit > 0 { + hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress) + hourlyCount, _ := g.Redis().Do(ctx, "GET", hourlyKey) + if !hourlyCount.IsNil() { + remaining := hourlyLimit - hourlyCount.Int() + status["hourly_remaining"] = remaining + status["hourly_limit"] = hourlyLimit + if ttl, err := g.Redis().Do(ctx, "TTL", hourlyKey); err == nil { + status["hourly_reset_seconds"] = ttl.Int() + } + } else { + status["hourly_remaining"] = hourlyLimit + status["hourly_limit"] = hourlyLimit + } + } + + // 查询IP每天限制 + dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day") + if dailyLimit > 0 { + dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress) + dailyCount, _ := g.Redis().Do(ctx, "GET", dailyKey) + if !dailyCount.IsNil() { + remaining := dailyLimit - dailyCount.Int() + status["daily_remaining"] = remaining + status["daily_limit"] = dailyLimit + if ttl, err := g.Redis().Do(ctx, "TTL", dailyKey); err == nil { + status["daily_reset_seconds"] = ttl.Int() + } + } else { + status["daily_remaining"] = dailyLimit + status["daily_limit"] = dailyLimit + } + } + + return status +} diff --git a/internal/logic/common/register_rate_limit_test.go b/internal/logic/common/register_rate_limit_test.go new file mode 100644 index 0000000..3c29ae3 --- /dev/null +++ b/internal/logic/common/register_rate_limit_test.go @@ -0,0 +1,158 @@ +package common + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/consts" +) + +// TestCheckRegisterRateLimit 测试注册速率限制 +func TestCheckRegisterRateLimit(t *testing.T) { + ctx := context.Background() + + // 测试IP级别限流 + t.Run("IP hourly limit", func(t *testing.T) { + testIP := "192.168.1.100" + + // 清理测试数据 + hourlyKey := "register:ip:hourly:" + testIP + g.Redis().Do(ctx, "DEL", hourlyKey) + + // 设置配置为每小时最多3次 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "3") + + // 前3次应该成功 + for i := 0; i < 3; i++ { + err := CheckRegisterRateLimit(ctx, testIP) + if err != nil { + t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err) + } + } + + // 第4次应该失败 + err := CheckRegisterRateLimit(ctx, testIP) + if err == nil { + t.Error("第4次注册应该被限流,但成功了") + } else if err.Error() != consts.MsgIpRateLimitExceeded { + t.Errorf("第4次注册应该返回IP限流错误,但得到: %v", err) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", hourlyKey) + }) + + t.Run("IP daily limit", func(t *testing.T) { + testIP := "192.168.1.101" + + // 清理测试数据 + dailyKey := "register:ip:daily:" + testIP + g.Redis().Do(ctx, "DEL", dailyKey) + + // 设置配置为每天最多5次 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "5") + + // 禁用每小时限制 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0") + + // 前5次应该成功 + for i := 0; i < 5; i++ { + err := CheckRegisterRateLimit(ctx, testIP) + if err != nil { + t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err) + } + } + + // 第6次应该失败 + err := CheckRegisterRateLimit(ctx, testIP) + if err == nil { + t.Error("第6次注册应该被限流,但成功了") + } else if err.Error() != consts.MsgIpRateLimitExceeded { + t.Errorf("第6次注册应该返回IP限流错误,但得到: %v", err) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", dailyKey) + }) +} + +// TestGlobalRateLimit 测试全局限流 +func TestGlobalRateLimit(t *testing.T) { + ctx := context.Background() + + // 清理测试数据 + now := time.Now() + minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60) + g.Redis().Do(ctx, "DEL", minuteKey) + + // 设置配置为每分钟最多2次 + g.Redis().Do(ctx, "SETEX", "config:register_global_limit_per_minute", 60, "2") + + // 禁用IP限制 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0") + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "0") + + // 前2次应该成功(使用不同IP) + for i := 0; i < 2; i++ { + testIP := fmt.Sprintf("192.168.1.%d", i+100) + err := CheckRegisterRateLimit(ctx, testIP) + if err != nil { + t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err) + } + } + + // 第3次应该失败(无论使用什么IP) + err := CheckRegisterRateLimit(ctx, "192.168.1.200") + if err == nil { + t.Error("第3次注册应该被全局限流,但成功了") + } else if err.Error() != consts.MsgGlobalRateLimitExceeded { + t.Errorf("第3次注册应该返回全局限流错误,但得到: %v", err) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", minuteKey) +} + +// TestGetRegisterRateLimitStatus 测试获取限流状态 +func TestGetRegisterRateLimitStatus(t *testing.T) { + ctx := context.Background() + testIP := "192.168.1.102" + + // 清理测试数据 + hourlyKey := "register:ip:hourly:" + testIP + dailyKey := "register:ip:daily:" + testIP + g.Redis().Do(ctx, "DEL", hourlyKey) + g.Redis().Do(ctx, "DEL", dailyKey) + + // 设置配置 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "5") + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "20") + + // 注册2次 + g.Redis().Do(ctx, "SETEX", hourlyKey, 3600, "2") + g.Redis().Do(ctx, "SETEX", dailyKey, 86400, "2") + + // 获取状态 + status := GetRegisterRateLimitStatus(ctx, testIP) + + // 验证状态 + if hourlyLimit, ok := status["hourly_limit"].(int); !ok || hourlyLimit != 5 { + t.Errorf("期望每小时限制为5,得到: %v", status["hourly_limit"]) + } + if hourlyRemaining, ok := status["hourly_remaining"].(int); !ok || hourlyRemaining != 3 { + t.Errorf("期望每小时剩余3次,得到: %v", status["hourly_remaining"]) + } + if dailyLimit, ok := status["daily_limit"].(int); !ok || dailyLimit != 20 { + t.Errorf("期望每天限制为20,得到: %v", status["daily_limit"]) + } + if dailyRemaining, ok := status["daily_remaining"].(int); !ok || dailyRemaining != 18 { + t.Errorf("期望每天剩余18次,得到: %v", status["daily_remaining"]) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", hourlyKey) + g.Redis().Do(ctx, "DEL", dailyKey) +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 0db7299..d3dc563 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -45,7 +45,6 @@ var Categories = []SettingCategory{ {Key: "channel", Label: "渠道配置", Icon: "channel", Order: 9}, {Key: "storage", Label: "存储配置", Icon: "cloud", Order: 10}, {Key: "data_governance", Label: "数据治理", Icon: "database", Order: 11}, - {Key: "update", Label: "系统更新", Icon: "sync", Order: 12}, } // Registry is the central definition of all configuration items. @@ -59,6 +58,12 @@ var Registry = []SettingDef{ Label: "开放注册", Description: "是否允许新用户注册", IsPublic: true}, {Key: "register_email_verification", Type: SettingTypeBool, Default: "false", Category: "general", Label: "注册邮箱验证", Description: "注册时是否需要邮箱验证码,关闭时使用滑块验证", IsPublic: true}, + {Key: "register_ip_limit_per_hour", Type: SettingTypeInt, Default: "3", Category: "general", + Label: "IP每小时注册限制", Description: "同一IP每小时最多注册次数(0表示不限制)", Validation: "min:0,max:20"}, + {Key: "register_ip_limit_per_day", Type: SettingTypeInt, Default: "10", Category: "general", + Label: "IP每天注册限制", Description: "同一IP每天最多注册次数(0表示不限制)", Validation: "min:0,max:100"}, + {Key: "register_global_limit_per_minute", Type: SettingTypeInt, Default: "10", Category: "general", + Label: "全局每分钟注册限制", Description: "全系统每分钟最多注册次数(0表示不限制)", Validation: "min:0,max:100"}, {Key: "maintenance_mode", Type: SettingTypeBool, Default: "false", Category: "general", Label: "维护模式", Description: "开启后控制台显示维护提示"}, {Key: "maintenance_message", Type: SettingTypeString, Default: "", Category: "general", @@ -67,10 +72,6 @@ var Registry = []SettingDef{ Label: "预计维护时长", Description: "维护模式预计持续时间,展示给用户", IsPublic: true}, {Key: "api_maintenance_enabled", Type: SettingTypeBool, Default: "false", Category: "general", Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true}, - {Key: "demo_mode", Type: SettingTypeBool, Default: "false", Category: "general", - Label: "演示模式", Description: "开启后所有写操作被拦截,仅允许只读访问", IsPublic: true}, - {Key: "demo_message", Type: SettingTypeString, Default: "演示环境,数据不可修改", Category: "general", - Label: "演示模式提示", Description: "演示模式下返回给用户的提示信息", IsPublic: true}, {Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general", Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"}, @@ -253,14 +254,6 @@ var Registry = []SettingDef{ Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"}, {Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance", Label: "启用文件保留期检查"}, - - // Update settings - {Key: "update_auto_check_enabled", Type: SettingTypeBool, Default: "true", Category: "update", - Label: "自动检查更新", Description: "启用后系统会定期检查是否有新版本"}, - {Key: "update_check_interval_hours", Type: SettingTypeInt, Default: "24", Category: "update", - Label: "检查间隔(小时)", Description: "自动检查更新的时间间隔", Validation: "min:1,max:168"}, - {Key: "update_channel", Type: SettingTypeString, Default: "stable", Category: "update", - Label: "更新通道", Description: "选择更新通道", Validation: "enum:stable,beta"}, } // registryMap is a lookup index for fast access by key. diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 769dd6d..34aa549 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -540,6 +540,7 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit StatusCode: record.StatusCode, ClientIp: record.ClientIP, UserAgent: record.UserAgent, + Model: record.Model, RequestBody: sysReq, ResponseBody: sysResp, TenantRequestBody: tntReq, diff --git a/internal/logic/settings/settings.go b/internal/logic/settings/settings.go index a3d6a76..071ead7 100644 --- a/internal/logic/settings/settings.go +++ b/internal/logic/settings/settings.go @@ -3,6 +3,7 @@ package settings import ( "context" + "github.com/gogf/gf/v2/frame/g" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/service" @@ -42,6 +43,14 @@ func (s *sSettings) PublicSettingsGet(ctx context.Context, _ *v1.PublicSettingsG } } + // Inject demo mode from config file (not database — avoids the self-lock bug) + settings["demo_mode"] = g.Cfg().MustGet(ctx, "demo.enabled").Bool() + demoMsg := g.Cfg().MustGet(ctx, "demo.message").String() + if demoMsg == "" { + demoMsg = "演示环境,数据不可修改" + } + settings["demo_message"] = demoMsg + return &v1.PublicSettingsGetRes{Settings: settings}, nil } diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 43ffb13..4c4b99a 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -40,6 +40,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error()) } + // Check register rate limit (IP + global) + ipAddress := g.RequestFromCtx(ctx).GetClientIp() + if err := common.CheckRegisterRateLimit(ctx, ipAddress); err != nil { + return nil, err + } + // 根据配置选择验证方式 emailVerificationEnabled := common.Config().GetBool(ctx, "register_email_verification") if emailVerificationEnabled { @@ -52,7 +58,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, err } } else { - // 滑块验证模式 + // 人机验证:优先 Turnstile,否则滑块验证 + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + + // Turnstile 未启用时,使用滑块验证 if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { return nil, err } @@ -80,8 +91,6 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. var tenantID, ownerUserID int64 - ipAddress := g.RequestFromCtx(ctx).GetClientIp() - err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // Create tenant tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{ @@ -184,6 +193,10 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant account := strings.TrimSpace(req.Account) // Check captcha if required + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { return nil, err } diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index 282cc3b..21a4ac1 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -18,6 +18,7 @@ import ( "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" do "github.com/qianfree/team-api/internal/model/do" + "github.com/qianfree/team-api/internal/utility/crypto" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -61,6 +62,34 @@ func getOAuthSetting(ctx context.Context, key string) string { return common.Config().GetOption(ctx, key) } +// encryptOAuthToken encrypts a token string for secure storage. +func encryptOAuthToken(ctx context.Context, token string) string { + if token == "" { + return "" + } + encKey := getEncKey(ctx) + encrypted, err := crypto.EncryptString(encKey, token) + if err != nil { + g.Log().Warningf(ctx, "[OAuth] encrypt token failed: %v", err) + return token + } + return encrypted +} + +// decryptOAuthToken decrypts a stored token string. +func decryptOAuthToken(ctx context.Context, encrypted string) string { + if encrypted == "" { + return "" + } + encKey := getEncKey(ctx) + decrypted, err := crypto.DecryptString(encKey, encrypted) + if err != nil { + // 可能是未加密的历史数据,直接返回原值 + return encrypted + } + return decrypted +} + // GetOAuthAuthorizeURL 获取 OAuth 授权跳转 URL func (s *sTenant) GetOAuthAuthorizeURL(ctx context.Context, req *v1.OAuthAuthorizeReq) (*v1.OAuthAuthorizeRes, error) { provider, ok := providers[req.Provider] @@ -155,8 +184,8 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( _, err = dao.TntOauthIdentities.Ctx(ctx). Where("id", identity.Id). Data(do.TntOauthIdentities{ - AccessToken: oauthToken.AccessToken, - RefreshToken: oauthToken.RefreshToken, + AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken), + RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken), TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), ProviderUsername: userInfo.Username, }).Update() @@ -241,8 +270,8 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( ProviderUsername: userInfo.Username, Email: userInfo.Email, AvatarUrl: userInfo.AvatarURL, - AccessToken: oauthToken.AccessToken, - RefreshToken: oauthToken.RefreshToken, + AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken), + RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken), TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), RawData: string(rawData), }).Insert() @@ -308,6 +337,11 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用") } + enabled := getOAuthSetting(ctx, fmt.Sprintf("oauth_%s_enabled", req.Provider)) + if enabled != "true" { + return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用") + } + tenantID := middleware.GetTenantID(ctx) userID := middleware.GetUserID(ctx) @@ -342,8 +376,8 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut _, err = dao.TntOauthIdentities.Ctx(ctx). Where("id", existing.Id). Data(do.TntOauthIdentities{ - AccessToken: oauthToken.AccessToken, - RefreshToken: oauthToken.RefreshToken, + AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken), + RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken), TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), RawData: string(rawData), }).Update() @@ -359,8 +393,8 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut ProviderUsername: userInfo.Username, Email: userInfo.Email, AvatarUrl: userInfo.AvatarURL, - AccessToken: oauthToken.AccessToken, - RefreshToken: oauthToken.RefreshToken, + AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken), + RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken), TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), RawData: string(rawData), }).Insert() @@ -498,6 +532,11 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU name = login } + // GitHub 用户邮箱私密时 /user 返回空邮箱,需要调用 /user/emails 获取主邮箱 + if email == "" { + email = githubFetchPrimaryEmail(token) + } + return &OAuthUserInfo{ ProviderUserID: id, Username: login, @@ -508,6 +547,36 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU }, nil } +// githubFetchPrimaryEmail fetches the primary verified email from GitHub /user/emails API. +func githubFetchPrimaryEmail(token string) string { + req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + } + if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil { + return "" + } + for _, e := range emails { + if e.Primary { + return e.Email + } + } + if len(emails) > 0 { + return emails[0].Email + } + return "" +} + // === Google OAuth Provider === type GoogleProvider struct{} @@ -524,7 +593,11 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth v.Set("code", code) v.Set("client_id", getOAuthSetting(ctx, "oauth_google_client_id")) v.Set("client_secret", getOAuthSetting(ctx, "oauth_google_client_secret")) - v.Set("redirect_uri", getOAuthSetting(ctx, "site_url")) + siteURL := getOAuthSetting(ctx, "site_url") + if siteURL == "" { + siteURL = "http://localhost:3000" + } + v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback") v.Set("grant_type", "authorization_code") resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode())) diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index 28e1600..91e582a 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -2,11 +2,17 @@ package tenant import ( "context" + "fmt" + "strconv" + + "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/middleware" + "github.com/qianfree/team-api/internal/utility/export" ) // TenantTaskList 租户异步任务列表 @@ -168,3 +174,144 @@ func (s *sTenant) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetail Task: item, }, nil } + +// ExportTasks 导出异步任务日志 +// owner/admin 可导出租户所有任务,member 只能导出自己的任务 +func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) (*v1.TenantTaskExportRes, error) { + tenantID := middleware.GetTenantID(ctx) + userID := middleware.GetUserID(ctx) + role := middleware.GetUserRole(ctx) + + statusLabel := map[string]string{ + "NOT_START": "未开始", + "SUBMITTED": "已提交", + "IN_PROGRESS": "进行中", + "SUCCESS": "成功", + "FAILURE": "失败", + "TIMEOUT": "超时", + } + + platformLabel := map[string]string{ + "sora": "Sora", + "kling": "Kling", + "midjourney": "Midjourney", + "suno": "Suno", + } + + columns := []export.Column{ + {Field: "public_task_id", Header: "任务ID"}, + {Field: "platform_name", Header: "平台"}, + {Field: "status_name", Header: "状态"}, + {Field: "action", Header: "动作"}, + {Field: "model_name", Header: "模型"}, + {Field: "progress", Header: "进度"}, + {Field: "username", Header: "用户"}, + {Field: "pre_deduct_amount", Header: "预扣金额(USD)"}, + {Field: "actual_cost", Header: "实际费用(USD)"}, + {Field: "billing_settled", Header: "是否已结算"}, + {Field: "fail_reason", Header: "失败原因"}, + {Field: "submit_time", Header: "提交时间"}, + {Field: "finish_time", Header: "完成时间"}, + {Field: "created_at", Header: "创建时间"}, + } + + config := export.Config{ + Format: req.Format, + Filename: "任务日志_" + gtime.Now().Format("Ymd_His"), + Columns: columns, + } + + var conditions []string + var args []any + + conditions = append(conditions, "t.tenant_id = ?") + args = append(args, tenantID) + + // member 只能导出自己的任务 + if role == "member" { + conditions = append(conditions, "t.user_id = ?") + args = append(args, userID) + } + if req.Status != "" { + conditions = append(conditions, "t.status = ?") + args = append(args, req.Status) + } + if req.Platform != "" { + conditions = append(conditions, "t.platform = ?") + args = append(args, req.Platform) + } + if req.PublicTaskID != "" { + conditions = append(conditions, "t.public_task_id = ?") + args = append(args, req.PublicTaskID) + } + + where := "" + for i, c := range conditions { + if i > 0 { + where += " AND " + } + where += c + } + + fromClause := "tsk_model_tasks t LEFT JOIN tnt_users u ON t.user_id = u.id AND t.tenant_id = u.tenant_id" + + return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { + offset := 0 + for { + dataSQL := fmt.Sprintf( + `SELECT t.public_task_id, t.platform, t.status, t.action, t.model_name, t.progress, + COALESCE(u.username, '') AS username, + t.pre_deduct_amount, t.actual_cost, t.billing_settled, + COALESCE(t.fail_reason, '') AS fail_reason, + t.submit_time, t.finish_time, t.created_at + FROM %s WHERE %s ORDER BY t.id DESC LIMIT 1000 OFFSET ?`, + fromClause, where, + ) + exportArgs := make([]any, len(args)+1) + copy(exportArgs, args) + exportArgs[len(args)] = offset + result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, exportArgs...) + if err != nil { + return + } + for _, row := range result { + m := make(map[string]any, len(row)) + for k, v := range row { + switch raw := v.Val().(type) { + case []byte: + s := string(raw) + if f, err := strconv.ParseFloat(s, 64); err == nil { + m[k] = f + } else { + m[k] = s + } + default: + m[k] = raw + } + } + // 翻译平台和状态为中文 + if platform, ok := m["platform"].(string); ok { + if label, exists := platformLabel[platform]; exists { + m["platform_name"] = label + } else { + m["platform_name"] = platform + } + } + if status, ok := m["status"].(string); ok { + if label, exists := statusLabel[status]; exists { + m["status_name"] = label + } else { + m["status_name"] = status + } + } + if !yield(m) { + return + } + } + if len(result) < 1000 { + break + } + offset += 1000 + } + }) +} diff --git a/internal/middleware/demo.go b/internal/middleware/demo.go index 06d9b56..c8694ca 100644 --- a/internal/middleware/demo.go +++ b/internal/middleware/demo.go @@ -3,10 +3,10 @@ package middleware import ( "strings" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/consts" - "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/response" ) @@ -21,10 +21,11 @@ var demoWhitelistPaths = map[string]bool{ } // DemoMode blocks all write operations when demo mode is enabled. +// Controlled via config file: demo.enabled = true. // Read-only requests (GET/HEAD/OPTIONS) and whitelisted paths are allowed. func DemoMode(r *ghttp.Request) { ctx := r.Context() - if !common.Config().GetBool(ctx, "demo_mode") { + if !g.Cfg().MustGet(ctx, "demo.enabled").Bool() { r.Middleware.Next() return } @@ -50,7 +51,7 @@ func DemoMode(r *ghttp.Request) { return } - message := common.Config().GetString(ctx, "demo_message") + message := g.Cfg().MustGet(ctx, "demo.message").String() if message == "" { message = consts.MsgDemoModeRestricted } diff --git a/internal/model/do/aud_request_logs.go b/internal/model/do/aud_request_logs.go index 6d6dbbd..60b7f18 100644 --- a/internal/model/do/aud_request_logs.go +++ b/internal/model/do/aud_request_logs.go @@ -43,4 +43,5 @@ type AudRequestLogs struct { TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt *gtime.Time // 异步任务达到终态的时间 AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3]) + Model any // 请求使用的模型名称(从请求体或 URL 中提取) } diff --git a/internal/model/entity/aud_request_logs.go b/internal/model/entity/aud_request_logs.go index bf20994..6c30d51 100644 --- a/internal/model/entity/aud_request_logs.go +++ b/internal/model/entity/aud_request_logs.go @@ -41,4 +41,5 @@ type AudRequestLogs struct { TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间 AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3]) + Model string `json:"model" orm:"model" description:"请求使用的模型名称(从请求体或 URL 中提取)"` // 请求使用的模型名称(从请求体或 URL 中提取) } diff --git a/internal/service/admin.go b/internal/service/admin.go index 3c3b0a3..e3a916d 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -141,6 +141,8 @@ type ( GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) // SetWarningThreshold 设置租户钱包预警阈值(管理后台) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error) + // GetAllTransactions 获取所有租户交易流水(管理后台) + GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) // GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道) GetDashboardChannelHealth(ctx context.Context, req *v1.AdminDashboardChannelHealthReq) (*v1.AdminDashboardChannelHealthRes, error) // GetDashboardRecentAlerts 获取最近5条告警 diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 9e220f2..9c9cfd8 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -294,6 +294,9 @@ type ( // TenantTaskDetail 租户异步任务详情 // owner/admin 可查看租户所有任务,member 只能查看自己的任务 TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (*v1.TenantTaskDetailRes, error) + // ExportTasks 导出异步任务日志 + // owner/admin 可导出租户所有任务,member 只能导出自己的任务 + ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) (*v1.TenantTaskExportRes, error) // TicketCreate 创建工单 TicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (*v1.TenantTicketCreateRes, error) // TicketList 获取租户工单列表 diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index e60b232..d7787bd 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -65,5 +65,11 @@ email: password: "" from: "" +# Demo mode (read-only mode, configured here — not in admin UI) +# Set enabled: true to block all write operations and show a watermark +demo: + enabled: false + message: "演示环境,数据不可修改" + # Channel proxy (for accessing Google/Anthropic etc. from China) channel_proxy_url: "" diff --git a/manifest/docker/config.example.yaml b/manifest/docker/config.example.yaml index 5d0cc04..2982df3 100644 --- a/manifest/docker/config.example.yaml +++ b/manifest/docker/config.example.yaml @@ -81,3 +81,9 @@ email: username: "" password: "" from: "" + +# 演示模式(通过配置文件控制,不在管理后台设置) +# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印 +demo: + enabled: false + message: "演示环境,数据不可修改" diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index c95c943..dec6f38 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -119,10 +119,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index af01819..5ffd592 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -108,10 +108,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index f61f961..4339881 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -133,10 +133,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index f910bd4..aeea9c0 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -129,10 +129,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go index f60c9d5..20797e6 100644 --- a/relay/channel/cloudflare/adaptor.go +++ b/relay/channel/cloudflare/adaptor.go @@ -125,10 +125,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) return client.Do(httpReq) diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 1fc0cf6..fb18e77 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -103,10 +103,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go index 1e2e811..721855b 100644 --- a/relay/channel/coze/adaptor.go +++ b/relay/channel/coze/adaptor.go @@ -96,6 +96,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request if timeout <= 0 { timeout = 120 // Coze 可能响应较慢,给更长超时 } + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { + timeout = 600 + } client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index cc45e31..7577bc1 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -108,9 +108,13 @@ func injectStreamOptions(rawMap map[string]json.RawMessage, info *common.RelayIn // injectThinkingParams 根据 RelayInfo 中的思考后缀注入 DeepSeek 思考模式参数。 // -// DeepSeek 思考模式参数说明: -// - thinking.type: "enabled"(默认) / "disabled" -// - reasoning_effort: "high"(默认) / "max",兼容映射:low/medium→high, xhigh→max +// DeepSeek 两类模型的参数差异: +// - V3 思考版(deepseek-chat):通过 thinking.type: "enabled"/"disabled" 控制;启用时需要 budget_tokens +// - R1 系列(deepseek-reasoner):不识别 thinking 参数,通过 reasoning_effort: "none"/"low"/"high"/"max" 控制 +// +// 关闭思考时同时注入两个参数,兼容两类模型: +// - thinking.type: "disabled"(V3 识别) +// - reasoning_effort: "none"(R1 识别) // // 注入优先级:客户端已显式设置 > 后缀路由注入 > 不干预(保留 DeepSeek 默认行为) func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { @@ -120,16 +124,18 @@ func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayI } // -nothinking 后缀:显式关闭思考 + // V3 模型:thinking.type: "disabled" 生效 + // R1 模型:thinking 参数被忽略,需 reasoning_effort: "none" 才能关闭思考 if info.ThinkingDisabled { rawMap["thinking"] = json.RawMessage(`{"type":"disabled"}`) - // 关闭思考时清理 reasoning_effort - delete(rawMap, "reasoning_effort") + rawMap["reasoning_effort"] = json.RawMessage(`"none"`) return rawMap } // -thinking 后缀:显式开启思考 + 可选 effort + // budget_tokens 为 V3 思考版必需字段,R1 模型忽略此参数 if info.ThinkingEnabled { - rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`) + rawMap["thinking"] = json.RawMessage(`{"type":"enabled","budget_tokens":16000}`) rawMap = injectReasoningEffort(rawMap, info) return rawMap } @@ -208,6 +214,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request if timeout <= 0 { timeout = 300 } + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { + timeout = 600 + } client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go index 038ab22..24e3a3a 100644 --- a/relay/channel/dify/adaptor.go +++ b/relay/channel/dify/adaptor.go @@ -93,6 +93,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request if timeout <= 0 { timeout = 120 // Dify 应用可能包含复杂工作流,给更长超时 } + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { + timeout = 600 + } client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 8ddd21d..8b02f1f 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -297,10 +297,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go index 3ab426d..413166c 100644 --- a/relay/channel/jimeng/adaptor.go +++ b/relay/channel/jimeng/adaptor.go @@ -118,6 +118,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request if timeout <= 0 { timeout = 120 // 图像生成通常较慢 } + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { + timeout = 600 + } client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/minimax/adaptor.go b/relay/channel/minimax/adaptor.go index 83a053d..3a54242 100644 --- a/relay/channel/minimax/adaptor.go +++ b/relay/channel/minimax/adaptor.go @@ -105,10 +105,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go index 2e769e4..49d6163 100644 --- a/relay/channel/mistral/adaptor.go +++ b/relay/channel/mistral/adaptor.go @@ -94,10 +94,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go index 3fb9f69..2d6fa22 100644 --- a/relay/channel/moonshot/adaptor.go +++ b/relay/channel/moonshot/adaptor.go @@ -102,10 +102,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index 56ceddf..e5ba06d 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -109,11 +109,15 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } + // Ollama 默认 300s 超时(大模型推理可能很慢) // Ollama 默认 300s 超时(大模型推理可能很慢) timeout := info.ChannelMeta.Settings.TimeoutSeconds if timeout <= 0 { timeout = 300 } + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { + timeout = 600 + } client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 2405e0b..22e201b 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -207,10 +207,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request } } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/openai/realtime.go b/relay/channel/openai/realtime.go index fa31b5d..d449509 100644 --- a/relay/channel/openai/realtime.go +++ b/relay/channel/openai/realtime.go @@ -66,10 +66,7 @@ func (p *RealtimeProxy) DialUpstream() error { targetURL := buildRealtimeURL(p.info.ChannelMeta.BaseURL) header := buildRealtimeHeaders(p.info) - timeout := p.info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := p.info.ChannelMeta.Settings.GetTimeoutSeconds(p.info.RelayMode) dialer := websocket.Dialer{ HandshakeTimeout: time.Duration(timeout) * time.Second, diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index 1f56d48..680085b 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -117,10 +117,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) return client.Do(httpReq) diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go index f197541..ddcbf2c 100644 --- a/relay/channel/submodel/adaptor.go +++ b/relay/channel/submodel/adaptor.go @@ -88,10 +88,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go index 6bea976..636b1e2 100644 --- a/relay/channel/tencent/adaptor.go +++ b/relay/channel/tencent/adaptor.go @@ -159,10 +159,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request httpReq.Header.Set("Authorization", authorization) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 3a179e0..84757bf 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -154,10 +154,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index 5165fcd..8892a40 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -112,10 +112,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 22bf64a..0f56faf 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -125,10 +125,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go index a31f46d..8bc699a 100644 --- a/relay/channel/xunfei/adaptor.go +++ b/relay/channel/xunfei/adaptor.go @@ -96,10 +96,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go index d7722d4..8a3b5f9 100644 --- a/relay/channel/zhipu/adaptor.go +++ b/relay/channel/zhipu/adaptor.go @@ -192,13 +192,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) } - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 60 - if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { - timeout = 300 - } - } + timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode) client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) diff --git a/relay/common/provider.go b/relay/common/provider.go index 0606938..d096a0c 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -217,6 +217,7 @@ type AuditRecord struct { StatusCode int ClientIP string UserAgent string + Model string // 请求使用的模型名称 RequestBody string ResponseBody string LatencyMs int diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 21aba2c..2a8a3b0 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -20,6 +20,23 @@ type ChannelMeta struct { Settings ChannelSettings } +const ( + DefaultTimeoutSeconds = 60 + ImagesGenerationTimeoutSecs = 600 +) + +// GetTimeoutSeconds 返回请求超时秒数。 +// 渠道自定义优先,未配置时根据请求模式返回默认值(图片生成 600s,其余 60s)。 +func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { + if s.TimeoutSeconds > 0 { + return s.TimeoutSeconds + } + if constant.RelayMode(relayMode) == constant.RelayModeImagesGenerations { + return ImagesGenerationTimeoutSecs + } + return DefaultTimeoutSeconds +} + // ChannelSettings 渠道配置(来自 chn_channels.settings JSONB) type ChannelSettings struct { TimeoutSeconds int `json:"timeout_seconds"` // 请求超时秒数,默认 60 diff --git a/web/admin/src/components/ModelPricingDrawer.vue b/web/admin/src/components/ModelPricingDrawer.vue index 062fc81..53dd53e 100644 --- a/web/admin/src/components/ModelPricingDrawer.vue +++ b/web/admin/src/components/ModelPricingDrawer.vue @@ -211,12 +211,18 @@ watch(() => props.visible, (val) => { + + 取消 + 保存定价 + + @@ -449,13 +455,8 @@ watch(() => props.visible, (val) => { + 添加梯度 - - - - 取消 - 保存定价 - + @@ -513,14 +514,6 @@ watch(() => props.visible, (val) => { color: var(--ta-text-secondary); } -.editor-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - padding-top: 20px; - border-top: 1px solid var(--ta-border-light); -} - .official-pricing-card { padding: 12px 16px; background: var(--color-fill-1); diff --git a/web/admin/src/composables/useSiteName.ts b/web/admin/src/composables/useSiteName.ts new file mode 100644 index 0000000..51c4858 --- /dev/null +++ b/web/admin/src/composables/useSiteName.ts @@ -0,0 +1,23 @@ +import { ref } from 'vue' +import axios from 'axios' + +const siteName = ref('') +let fetched = false + +async function fetchSiteName(): Promise { + if (fetched) return + fetched = true + try { + const res = await axios.get('/api/settings/public', { timeout: 5000 }) + const settings = res.data?.data?.settings + if (settings?.site_name) { + siteName.value = settings.site_name + } + } catch { + // silent — fallback to default + } +} + +export function useSiteName() { + return { siteName, fetchSiteName } +} diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index a717589..c648564 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -73,10 +73,10 @@ const adminRoutes: RouteRecordRaw[] = [ meta: { title: '用量日志' }, }, { - path: 'billing', - name: 'AdminBilling', - component: () => import('@/views/finance/BillingPage.vue'), - meta: { title: '计费管理' }, + path: 'transactions', + name: 'AdminTransactions', + component: () => import('@/views/finance/TransactionsPage.vue'), + meta: { title: '交易流水' }, }, { path: 'plans', diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 771cb2d..2af1bfa 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' import { shouldRefresh, getRefreshToken } from '@/utils/request' +import { useSiteName } from '@/composables/useSiteName' import axios from 'axios' const routes: RouteRecordRaw[] = [ @@ -42,12 +43,16 @@ async function checkSetupStatus(): Promise { return systemInitialized === true } +const { siteName, fetchSiteName } = useSiteName() + router.beforeEach(async (to) => { const authStore = useAuthStore() authStore.loadFromStorage() if (to.meta.title) { - document.title = `${to.meta.title as string} — Team-API` + fetchSiteName() + const name = siteName.value || 'Team-API' + document.title = `${to.meta.title as string} — ${name}` } if (to.name === 'AdminSetup') { diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index aab0cb1..9774247 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -6,6 +6,7 @@ import axios from 'axios' import { marked } from 'marked' import request from '@/utils/request' import { useWatermark } from '@/composables/useWatermark' +import { useSiteName } from '@/composables/useSiteName' import { IconDashboard, IconUserGroup, @@ -31,12 +32,14 @@ import { IconLayers, IconHome, IconUser, + IconCheckCircleFill, } from '@arco-design/web-vue/es/icon' import { useAuthStore } from '@/stores/auth' const router = useRouter() const route = useRoute() const authStore = useAuthStore() +const { siteName } = useSiteName() const collapsed = ref(false) const mobileMenuOpen = ref(false) @@ -298,8 +301,7 @@ const menuGroups = [ items: [ { name: 'AdminPlans', label: '套餐管理', icon: IconIdcard }, { name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock }, - { name: 'AdminBilling', label: '计费管理', icon: IconCommand }, - { name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage }, + { name: 'AdminTransactions', label: '交易流水', icon: IconCommand }, { name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift }, { name: 'AdminPromoCodes', label: '优惠码管理', icon: IconTag }, ], @@ -351,6 +353,7 @@ const menuGroups = [ { name: 'AdminUsers', label: '用户管理', icon: IconUserGroup }, { name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock }, { name: 'AdminSettings', label: '系统设置', icon: IconSettings }, + { name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage }, { name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers }, { name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile }, { name: 'AdminChangelogs', label: '更新日志', icon: IconFile }, @@ -445,10 +448,10 @@ onUnmounted(() => { - T + - Team-API + {{ siteName || 'Team-API' }} {{ appVersion === 'dev' ? 'dev' : 'v' + appVersion }} @@ -526,8 +529,8 @@ onUnmounted(() => { - T - Team-API + + {{ siteName || 'Team-API' }} @@ -775,17 +778,10 @@ onUnmounted(() => { .admin-sidebar__logo-icon { flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; width: 32px; height: 32px; - background: var(--ta-primary-gradient); border-radius: 8px; - color: #fff; - font-size: 16px; - font-weight: 700; - box-shadow: 0 4px 16px rgba(13, 148, 136, 0.4); + object-fit: contain; } .admin-sidebar__logo-text { diff --git a/web/admin/src/views/DashboardPage.vue b/web/admin/src/views/DashboardPage.vue index 3958939..5cd7830 100644 --- a/web/admin/src/views/DashboardPage.vue +++ b/web/admin/src/views/DashboardPage.vue @@ -309,8 +309,8 @@ function renderModelChart() { modelChart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, - grid: { left: 130, right: 50, top: 10, bottom: 20 }, - xAxis: { type: 'value', name: '费用 ($)' }, + grid: { left: 130, right: 50, top: 10, bottom: 36 }, + xAxis: { type: 'value', name: '费用 ($)', nameLocation: 'middle', nameGap: 22 }, yAxis: { type: 'category', data: names, diff --git a/web/admin/src/views/LoginPage.vue b/web/admin/src/views/LoginPage.vue index bf426c7..ec4e8d5 100644 --- a/web/admin/src/views/LoginPage.vue +++ b/web/admin/src/views/LoginPage.vue @@ -7,12 +7,14 @@ import { useAuthStore } from '@/stores/auth' import { extractApiError } from '@/utils/request' import request from '@/utils/request' import SlideCaptcha from '@/components/SlideCaptcha.vue' +import { useSiteName } from '@/composables/useSiteName' const router = useRouter() const route = useRoute() const authStore = useAuthStore() const formRef = ref(null) const loading = ref(false) +const { siteName } = useSiteName() const form = reactive({ username: '', @@ -94,8 +96,8 @@ async function handle2FAVerify() { - - Team-API + + {{ siteName }} 多租户 大模型 API 网关管理平台 @@ -132,8 +134,8 @@ async function handle2FAVerify() { - - Team-API + + {{ siteName }} diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index a9f7658..c10d085 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -302,10 +302,17 @@ const { exporting, exportFile } = useExport({ - + - - + + + + + + + + + @@ -317,17 +324,51 @@ const { exporting, exportFile } = useExport({ - - - + + + + + + + + + + + + + + + + + + + + + + 需先在系统设置中配置代理 + + + - 启用后该渠道的请求通过系统代理转发(需先在系统设置中配置代理地址) + - - - - + + + + + + + + + + + + + + + + diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index b63cb57..8ca6c5b 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -214,7 +214,7 @@ const columns: TableColumnData[] = [ }, }, - { title: '更新时间', dataIndex: 'updated_at', width: 170 }, + { title: '更新时间', dataIndex: 'updated_at', width: 180 }, { title: '操作', dataIndex: 'actions', diff --git a/web/admin/src/views/ai/TaskLogsPage.vue b/web/admin/src/views/ai/TaskLogsPage.vue index 5520d6c..2f3d738 100644 --- a/web/admin/src/views/ai/TaskLogsPage.vue +++ b/web/admin/src/views/ai/TaskLogsPage.vue @@ -37,6 +37,8 @@ const platformOptions = [ { label: 'Kling', value: 'kling' }, { label: 'Midjourney', value: 'midjourney' }, { label: 'Suno', value: 'suno' }, + { label: '火山引擎', value: 'volcengine' }, + { label: '阿里', value: 'ali' }, ] const statusTagColor: Record = { @@ -60,6 +62,8 @@ const platformLabel: Record = { kling: 'Kling', midjourney: 'Midjourney', suno: 'Suno', + volcengine: '火山引擎', + ali: '阿里', } const columns: TableColumnData[] = [ diff --git a/web/admin/src/views/finance/BillingPage.vue b/web/admin/src/views/finance/BillingPage.vue index dbca445..b530ede 100644 --- a/web/admin/src/views/finance/BillingPage.vue +++ b/web/admin/src/views/finance/BillingPage.vue @@ -1,14 +1,13 @@ + + + + + + + + + + + + + + 搜索 + + + + + + + + + diff --git a/web/admin/src/views/system/SettingsPage.vue b/web/admin/src/views/system/SettingsPage.vue index 9011d2d..37d6e60 100644 --- a/web/admin/src/views/system/SettingsPage.vue +++ b/web/admin/src/views/system/SettingsPage.vue @@ -117,12 +117,11 @@ onMounted(() => { .settings-content { flex: 1; min-width: 0; + max-width: 800px; } .settings-footer { display: flex; justify-content: flex-end; - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid var(--color-border); + margin-top: 16px; } diff --git a/web/admin/src/views/system/settings/AuditTab.vue b/web/admin/src/views/system/settings/AuditTab.vue index 873932e..1428cc0 100644 --- a/web/admin/src/views/system/settings/AuditTab.vue +++ b/web/admin/src/views/system/settings/AuditTab.vue @@ -25,14 +25,14 @@ const values = useFormValues() 日志保留 - + values['audit_retention_days'] = String(v ?? 90)" :min="7" :max="3650" style="width: 100%" /> - + values['operation_log_retention_days'] = String(v ?? 365)" diff --git a/web/admin/src/views/system/settings/ChannelTab.vue b/web/admin/src/views/system/settings/ChannelTab.vue index 9e3fe75..0b1fc18 100644 --- a/web/admin/src/views/system/settings/ChannelTab.vue +++ b/web/admin/src/views/system/settings/ChannelTab.vue @@ -12,16 +12,16 @@ const values = useFormValues() 渠道自动探测 定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token) values['channel_auto_test_enabled'] = String(v)" + :model-value="!!values['channel_auto_test_enabled']" + @change="(v: string | number | boolean) => values['channel_auto_test_enabled'] = v" /> 自动恢复探测 定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启) values['channel_auto_test_recovery_enabled'] = String(v)" + :model-value="!!values['channel_auto_test_recovery_enabled']" + @change="(v: string | number | boolean) => values['channel_auto_test_recovery_enabled'] = v" /> @@ -33,8 +33,8 @@ const values = useFormValues() 渠道自动禁用 连续失败达到阈值时自动禁用渠道 values['channel_auto_disable_enabled'] = String(v)" + :model-value="!!values['channel_auto_disable_enabled']" + @change="(v: string | number | boolean) => values['channel_auto_disable_enabled'] = v" /> diff --git a/web/admin/src/views/system/settings/DataGovernanceTab.vue b/web/admin/src/views/system/settings/DataGovernanceTab.vue index aae21ee..021defb 100644 --- a/web/admin/src/views/system/settings/DataGovernanceTab.vue +++ b/web/admin/src/views/system/settings/DataGovernanceTab.vue @@ -47,8 +47,8 @@ function set(key: string, v: number | undefined): void { 启用文件保留期检查 values['file_retention_enabled'] = String(v)" + :model-value="!!values['file_retention_enabled']" + @change="(v: string | number | boolean) => values['file_retention_enabled'] = v" /> diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index 8ca0ee4..74989ea 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -40,8 +40,8 @@ const values = useFormValues() 启用 TLS 587/465 端口建议开启 values['email_smtp_tls'] = String(v)" + :model-value="!!values['email_smtp_tls']" + @change="(v: string | number | boolean) => values['email_smtp_tls'] = v" /> diff --git a/web/admin/src/views/system/settings/GeneralTab.vue b/web/admin/src/views/system/settings/GeneralTab.vue index 4a9c0cb..7823a12 100644 --- a/web/admin/src/views/system/settings/GeneralTab.vue +++ b/web/admin/src/views/system/settings/GeneralTab.vue @@ -12,7 +12,7 @@ const values = useFormValues() - + @@ -28,38 +28,20 @@ const values = useFormValues() 开放注册 是否允许新用户注册 values['register_enabled'] = String(v)" + :model-value="!!values['register_enabled']" + @change="(v: string | number | boolean) => values['register_enabled'] = v" /> 注册邮箱验证 注册时是否需要邮箱验证码,关闭时使用滑块验证 values['register_email_verification'] = String(v)" + :model-value="!!values['register_email_verification']" + @change="(v: string | number | boolean) => values['register_email_verification'] = v" /> - - - 演示模式 - - 启用演示模式 - 开启后所有写操作被拦截,仅允许只读访问 - values['demo_mode'] = String(v)" - /> - - - - - - - - 维护模式 @@ -67,8 +49,8 @@ const values = useFormValues() 启用维护模式 开启后控制台显示维护提示 values['maintenance_mode'] = String(v)" + :model-value="!!values['maintenance_mode']" + @change="(v: string | number | boolean) => values['maintenance_mode'] = v" /> @@ -83,8 +65,8 @@ const values = useFormValues() 全局 API 维护 开启后 API 代理返回 503 values['api_maintenance_enabled'] = String(v)" + :model-value="!!values['api_maintenance_enabled']" + @change="(v: string | number | boolean) => values['api_maintenance_enabled'] = v" /> diff --git a/web/admin/src/views/system/settings/OAuthTab.vue b/web/admin/src/views/system/settings/OAuthTab.vue index c3f4425..8978e8d 100644 --- a/web/admin/src/views/system/settings/OAuthTab.vue +++ b/web/admin/src/views/system/settings/OAuthTab.vue @@ -12,8 +12,8 @@ const values = useFormValues() OAuth 自动注册 首次 OAuth 登录自动创建租户账号 values['oauth_auto_register'] = String(v)" + :model-value="!!values['oauth_auto_register']" + @change="(v: string | number | boolean) => values['oauth_auto_register'] = v" /> @@ -30,8 +30,8 @@ const values = useFormValues() 启用 GitHub 登录 values['oauth_github_enabled'] = String(v)" + :model-value="!!values['oauth_github_enabled']" + @change="(v: string | number | boolean) => values['oauth_github_enabled'] = v" /> @@ -51,8 +51,8 @@ const values = useFormValues() 启用 Google 登录 values['oauth_google_enabled'] = String(v)" + :model-value="!!values['oauth_google_enabled']" + @change="(v: string | number | boolean) => values['oauth_google_enabled'] = v" /> diff --git a/web/admin/src/views/system/settings/PerformanceTab.vue b/web/admin/src/views/system/settings/PerformanceTab.vue index 855d538..f8d99c6 100644 --- a/web/admin/src/views/system/settings/PerformanceTab.vue +++ b/web/admin/src/views/system/settings/PerformanceTab.vue @@ -65,8 +65,8 @@ function set(key: string, v: number | undefined): void { 启用缓存 values['cache_enabled'] = String(v)" + :model-value="!!values['cache_enabled']" + @change="(v: string | number | boolean) => values['cache_enabled'] = v" /> @@ -107,8 +107,8 @@ function set(key: string, v: number | undefined): void { 启用沙箱 values['sandbox_enabled'] = String(v)" + :model-value="!!values['sandbox_enabled']" + @change="(v: string | number | boolean) => values['sandbox_enabled'] = v" /> diff --git a/web/admin/src/views/system/settings/SecurityTab.vue b/web/admin/src/views/system/settings/SecurityTab.vue index 45f2204..a7ae65b 100644 --- a/web/admin/src/views/system/settings/SecurityTab.vue +++ b/web/admin/src/views/system/settings/SecurityTab.vue @@ -89,7 +89,7 @@ const values = useFormValues() values['new_device_notification'] = v" /> diff --git a/web/admin/src/views/system/settings/StorageTab.vue b/web/admin/src/views/system/settings/StorageTab.vue index 98637fa..da2b5c0 100644 --- a/web/admin/src/views/system/settings/StorageTab.vue +++ b/web/admin/src/views/system/settings/StorageTab.vue @@ -47,8 +47,8 @@ const values = useFormValues() 启用 SSL values['storage_use_ssl'] = String(v)" + :model-value="!!values['storage_use_ssl']" + @change="(v: string | number | boolean) => values['storage_use_ssl'] = v" /> diff --git a/web/admin/src/views/system/settings/useSettings.ts b/web/admin/src/views/system/settings/useSettings.ts index 1b2c3ec..64f8854 100644 --- a/web/admin/src/views/system/settings/useSettings.ts +++ b/web/admin/src/views/system/settings/useSettings.ts @@ -19,12 +19,7 @@ export function useSettings(category: () => string) { const items = res.data?.data?.list || [] const vals: Record = {} for (const item of items) { - let val = item.value ?? item.default ?? '' - // Normalize bool values to strings for consistent switch handling - if (item.type === 'bool') { - val = String(val) - } - vals[item.key] = val + vals[item.key] = item.value ?? item.default ?? '' } Object.keys(formValues).forEach(k => delete formValues[k]) Object.assign(formValues, vals) diff --git a/web/admin/src/views/tenant/MembersPage.vue b/web/admin/src/views/tenant/MembersPage.vue index 95b18b8..158ad3b 100644 --- a/web/admin/src/views/tenant/MembersPage.vue +++ b/web/admin/src/views/tenant/MembersPage.vue @@ -161,9 +161,9 @@ const columns: TableColumnData[] = [ }, { title: '所属租户', dataIndex: 'tenant_name', width: 130, ellipsis: true }, { title: '租户代码', dataIndex: 'tenant_code', width: 100 }, - { title: '最后登录', dataIndex: 'last_login_at', width: 170 }, + { title: '最后登录', dataIndex: 'last_login_at', width: 180 }, { title: '登录IP', dataIndex: 'last_login_ip', width: 130 }, - { title: '创建时间', dataIndex: 'created_at', width: 170 }, + { title: '创建时间', dataIndex: 'created_at', width: 180 }, { title: '操作', dataIndex: 'actions', diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index 58c1f60..59b4c33 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -221,7 +221,7 @@ const columns: TableColumnData[] = [ render({ record }) { return record.api_key_name || record.api_key_id || '-' }, }, { - title: '模型', dataIndex: 'model_name', width: 160, + title: '模型', dataIndex: 'model_name', width: 200, render({ record }) { if (hasUpstreamModel(record)) { return h('div', [ diff --git a/web/tenant/src/components/common/Turnstile.vue b/web/tenant/src/components/common/Turnstile.vue new file mode 100644 index 0000000..d9cceb9 --- /dev/null +++ b/web/tenant/src/components/common/Turnstile.vue @@ -0,0 +1,154 @@ + + + + + + + diff --git a/web/tenant/src/components/layout/AuthLayout.vue b/web/tenant/src/components/layout/AuthLayout.vue index 7c42111..50ba12e 100644 --- a/web/tenant/src/components/layout/AuthLayout.vue +++ b/web/tenant/src/components/layout/AuthLayout.vue @@ -29,10 +29,10 @@ - - Team API + + {{ siteName }} - 开源自托管的多租户 大模型 API 网关平台 + {{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }} @@ -59,7 +59,10 @@ import { ref, onMounted, onBeforeUnmount } from 'vue' import Icon from '../common/Icon.vue' import AnnouncementBanner from '../common/AnnouncementBanner.vue' import request from '@/utils/request' +import { usePublicSettings } from '@/composables/usePublicSettings' +const { settings: publicSettings } = usePublicSettings() +const siteName = publicSettings.value.site_name || 'Team-API' const announcements = ref([]) let timer: ReturnType | null = null diff --git a/web/tenant/src/composables/usePublicSettings.ts b/web/tenant/src/composables/usePublicSettings.ts index 04441e7..908bd35 100644 --- a/web/tenant/src/composables/usePublicSettings.ts +++ b/web/tenant/src/composables/usePublicSettings.ts @@ -24,6 +24,8 @@ const settings = ref({ site_description: '', demo_mode: false, demo_message: '', + turnstile_enabled: false, + turnstile_site_key: '', }) let fetchPromise: Promise | null = null diff --git a/web/tenant/src/composables/useSeo.ts b/web/tenant/src/composables/useSeo.ts index ca514d8..9e19b1e 100644 --- a/web/tenant/src/composables/useSeo.ts +++ b/web/tenant/src/composables/useSeo.ts @@ -3,13 +3,14 @@ import { useSeoMeta, useHead } from '@unhead/vue' interface SeoOptions { title: string description: string + siteName?: string keywords?: string canonicalUrl?: string ogImage?: string ogType?: 'website' | 'article' | 'profile' | 'book' } -const SITE_NAME = 'Team-API' +const DEFAULT_SITE_NAME = 'Team-API' const DEFAULT_OG_IMAGE = '/og-image.png' /** @@ -20,6 +21,7 @@ export function useSeo(options: SeoOptions) { const { title, description, + siteName = DEFAULT_SITE_NAME, keywords, canonicalUrl, ogImage = DEFAULT_OG_IMAGE, @@ -33,7 +35,7 @@ export function useSeo(options: SeoOptions) { ogDescription: description, keywords, ogType, - ogSiteName: SITE_NAME, + ogSiteName: siteName, ogLocale: 'zh_CN', ogImage, twitterCard: 'summary_large_image', diff --git a/web/tenant/src/router/index.ts b/web/tenant/src/router/index.ts index cb639ad..63974be 100644 --- a/web/tenant/src/router/index.ts +++ b/web/tenant/src/router/index.ts @@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router' import tenantRoutes from './tenant' import { useTenantAuthStore } from '@/stores/tenant-auth' import { shouldRefresh, getRefreshToken } from '@/utils/request' +import { usePublicSettings } from '@/composables/usePublicSettings' const routes: RouteRecordRaw[] = [ { @@ -33,7 +34,9 @@ router.beforeEach((to) => { tenantAuthStore.loadFromStorage() if (to.meta.title) { - document.title = to.name === 'TenantHome' ? to.meta.title : `${to.meta.title} — Team-API` + const { settings: publicSettings } = usePublicSettings() + const siteName = publicSettings.value.site_name || 'Team-API' + document.title = to.name === 'TenantHome' ? `${siteName} — 企业级多租户大模型 API 网关平台 | 开源自托管` : `${to.meta.title} — ${siteName}` } // Auth pages — always allow diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index 5e93f6a..70a0b01 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -177,6 +177,9 @@ onMounted(async () => { } await fetchPublicSettings() + if (publicSettings.site_name && route.meta.title) { + document.title = `${pageTitle.value} — ${publicSettings.site_name}` + } if (publicSettings.demo_mode) { demoMessage.value = publicSettings.demo_message || '演示环境,数据不可修改' mountWatermark(document.body) diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index 4ebcf9b..e719029 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -1,5 +1,5 @@ @@ -417,6 +436,37 @@ function clearRamError(field: string) { + + + + + + + + 或使用第三方账号登录 + + + + + + GitHub + + + + Google + + + diff --git a/web/tenant/src/views/auth/RegisterPage.vue b/web/tenant/src/views/auth/RegisterPage.vue index 24468c8..7422a2c 100644 --- a/web/tenant/src/views/auth/RegisterPage.vue +++ b/web/tenant/src/views/auth/RegisterPage.vue @@ -5,6 +5,7 @@ import { useTenantAuthStore } from '@/stores/tenant-auth' import { usePublicSettings } from '@/composables/usePublicSettings' import AuthLayout from '@/components/layout/AuthLayout.vue' import SlideCaptcha from '@/components/common/SlideCaptcha.vue' +import Turnstile from '@/components/common/Turnstile.vue' import Icon from '@/components/common/Icon.vue' import request from '@/utils/request' import { extractApiError } from '@/utils/request' @@ -19,6 +20,7 @@ const codeSending = ref(false) const countdown = ref(0) const emailVerification = ref(true) const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 }) + const turnstileToken = ref('') let countdownTimer: ReturnType | null = null const orgForm = reactive({ orgName: '', orgCode: '' }) diff --git a/web/tenant/src/views/landing/LandingPage.vue b/web/tenant/src/views/landing/LandingPage.vue index 88fe174..3fb67e5 100644 --- a/web/tenant/src/views/landing/LandingPage.vue +++ b/web/tenant/src/views/landing/LandingPage.vue @@ -5,8 +5,8 @@ - - Team-API + + {{ siteName }} - + © 2026 qianfree. Released under AGPL-3.0. (null) +const { exporting, exportFile } = useExport({ + url: '/tenant/tasks/export', + getFilters: () => ({ + status: filterStatus.value || undefined, + platform: filterPlatform.value || undefined, + public_task_id: filterTaskId.value || undefined, + }), +}) + const statusBadge: Record = { NOT_START: 'bg-gray-100 text-gray-800', SUBMITTED: 'bg-blue-100 text-blue-800', @@ -63,6 +73,8 @@ const platformLabel: Record = { kling: 'Kling', midjourney: 'Midjourney', suno: 'Suno', + volcengine: '火山引擎', + ali: '阿里', } function formatCost(n: number | undefined): string { @@ -153,11 +165,20 @@ onMounted(() => { - + 任务日志 查看异步生成任务(视频/图片/音乐)的执行记录 + + + + {{ exporting ? '导出中...' : '导出 CSV' }} + + + {{ exporting ? '导出中...' : '导出 Excel' }} + + @@ -174,7 +195,7 @@ onMounted(() => { 平台 - + 搜索 @@ -199,25 +220,25 @@ onMounted(() => { - + - 任务 ID - 平台 - 状态 - 模型 - 费用 - 提交时间 - 完成时间 - + 任务 ID + 平台 + 状态 + 模型 + 费用 + 提交时间 + 完成时间 + 操作 - + {{ task.public_task_id }} - + {{ platformLabel[task.platform] || task.platform }} @@ -225,19 +246,19 @@ onMounted(() => { {{ statusLabel[task.status] || task.status }} - + {{ task.model_name || '-' }} - + {{ formatCost(task.actual_cost) }} {{ formatCost(task.pre_deduct_amount) }} (预扣) - - - {{ formatTime(task.submit_time) }} + + {{ formatTime(task.submit_time) }} - - {{ formatTime(task.finish_time) }} + + {{ formatTime(task.finish_time) }} diff --git a/web/tenant/src/views/settings/ProfilePage.vue b/web/tenant/src/views/settings/ProfilePage.vue index 6b456b3..060da88 100644 --- a/web/tenant/src/views/settings/ProfilePage.vue +++ b/web/tenant/src/views/settings/ProfilePage.vue @@ -1,12 +1,22 @@ @@ -259,6 +350,45 @@ onMounted(() => { + + + + + 第三方账号 + + + + + + + + + GitHub + 已绑定:{{ getLinkedProvider('github')?.provider_username || getLinkedProvider('github')?.provider_user_id }} + 未绑定 + + + 解绑 + 绑定 + + + + + + + + Google + 已绑定:{{ getLinkedProvider('google')?.provider_username || getLinkedProvider('google')?.provider_user_id }} + 未绑定 + + + 解绑 + 绑定 + + + {{ linkError }} + +
多租户 大模型 API 网关管理平台
开源自托管的多租户 大模型 API 网关平台
{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}
查看异步生成任务(视频/图片/音乐)的执行记录
GitHub
已绑定:{{ getLinkedProvider('github')?.provider_username || getLinkedProvider('github')?.provider_user_id }}
未绑定
Google
已绑定:{{ getLinkedProvider('google')?.provider_username || getLinkedProvider('google')?.provider_user_id }}
{{ linkError }}