Merge pull request #5 from qianfree/dev

0.1.9细节优化
This commit is contained in:
Qian authored and GitHub committed 2026-06-10 22:40:14 +08:00
commit 5009726d66
93 files changed
+1613 -423

No files matched your search

+1
View File
@@ -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)
+36
View File
@@ -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"`
}
+1
View File
@@ -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)
+17 -15
View File
@@ -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 {
+11
View File
@@ -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{}
+4
View File
@@ -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 = "系统已完成初始化"
@@ -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)
}
@@ -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)
}
@@ -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.
+5
View File
@@ -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,
+81
View File
@@ -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
+2 -6
View File
@@ -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
+2 -17
View File
@@ -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, ",")
}
+35
View File
@@ -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 {
+44
View File
@@ -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
}
@@ -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
}
@@ -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)
}
+6 -13
View File
@@ -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.
+1
View File
@@ -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,
+9
View File
@@ -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
}
+16 -3
View File
@@ -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
}
+82 -9
View File
@@ -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()))
+147
View File
@@ -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
}
})
}
+4 -3
View File
@@ -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
}
+1
View File
@@ -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 中提取)
}
@@ -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 中提取)
}
+2
View File
@@ -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条告警
+3
View File
@@ -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 获取租户工单列表
+6
View File
@@ -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: ""
+6
View File
@@ -81,3 +81,9 @@ email:
username: ""
password: ""
from: ""
# 演示模式(通过配置文件控制,不在管理后台设置)
# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印
demo:
enabled: false
message: "演示环境,数据不可修改"
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+3
View File
@@ -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)
+15 -6
View File
@@ -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)
+3
View File
@@ -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)
+1 -4
View File
@@ -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)
+3
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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,
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -4
View File
@@ -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)
+1 -7
View File
@@ -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)
+1
View File
@@ -217,6 +217,7 @@ type AuditRecord struct {
StatusCode int
ClientIP string
UserAgent string
Model string // 请求使用的模型名称
RequestBody string
ResponseBody string
LatencyMs int
+17
View File
@@ -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
@@ -211,12 +211,18 @@ watch(() => props.visible, (val) => {
<template>
<ADrawer
:visible="visible"
:width="720"
:width="580"
:title="modelId ? `定价设置 - ${modelIdStr}` : '定价设置'"
:mask-closable="false"
:footer="true"
@cancel="emit('update:visible', false)"
>
<template #footer>
<AButton @click="emit('update:visible', false)">取消</AButton>
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
</template>
<ASpin :loading="editorLoading" style="width: 100%">
<AForm :model="{}" layout="vertical">
<div class="pricing-editor">
<!-- Official Pricing Reference -->
<div class="editor-section">
@@ -449,13 +455,8 @@ watch(() => props.visible, (val) => {
<AButton type="dashed" long @click="addTier" class="mt-3">+ 添加梯度</AButton>
</div>
</template>
<!-- Save -->
<div class="editor-actions">
<AButton @click="emit('update:visible', false)">取消</AButton>
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
</div>
</div>
</AForm>
</ASpin>
</ADrawer>
</template>
@@ -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);
+23
View File
@@ -0,0 +1,23 @@
import { ref } from 'vue'
import axios from 'axios'
const siteName = ref('')
let fetched = false
async function fetchSiteName(): Promise<void> {
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 }
}
+4 -4
View File
@@ -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',
+6 -1
View File
@@ -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<boolean> {
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') {
+10 -14
View File
@@ -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(() => {
<!-- Desktop Sidebar -->
<div class="admin-sidebar" :class="{ 'admin-sidebar--collapsed': collapsed }">
<div class="admin-sidebar__logo" @click="openUpdateModal" style="cursor: pointer;">
<div class="admin-sidebar__logo-icon">T</div>
<img src="/favicon.png" alt="Logo" class="admin-sidebar__logo-icon" />
<Transition name="fade">
<span v-if="!collapsed" class="admin-sidebar__logo-text">
Team-API
{{ siteName || 'Team-API' }}
<span class="admin-sidebar__version">
{{ appVersion === 'dev' ? 'dev' : 'v' + appVersion }}
<span v-if="hasUpdate" class="admin-sidebar__update-dot" title="有新版本可用"></span>
@@ -526,8 +529,8 @@ onUnmounted(() => {
<Transition name="slide">
<div v-if="mobileMenuOpen" class="admin-sidebar admin-sidebar--mobile">
<div class="admin-sidebar__logo">
<div class="admin-sidebar__logo-icon">T</div>
<span class="admin-sidebar__logo-text">Team-API</span>
<img src="/favicon.png" alt="Logo" class="admin-sidebar__logo-icon" />
<span class="admin-sidebar__logo-text">{{ siteName || 'Team-API' }}</span>
</div>
<div class="admin-sidebar__menu">
<div v-for="group in menuGroups" :key="group.key">
@@ -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 {
+2 -2
View File
@@ -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,
+6 -4
View File
@@ -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<FormInstance | null>(null)
const loading = ref(false)
const { siteName } = useSiteName()
const form = reactive({
username: '',
@@ -94,8 +96,8 @@ async function handle2FAVerify() {
<div class="login-orb login-orb--3" />
<div class="login-brand__content">
<img src="/favicon.png" alt="Team-API" class="login-brand__logo" />
<h1 class="login-brand__title">Team-API</h1>
<img src="/favicon.png" :alt="siteName" class="login-brand__logo" />
<h1 class="login-brand__title">{{ siteName }}</h1>
<p class="login-brand__desc">多租户 大模型 API 网关管理平台</p>
<div class="login-brand__features">
@@ -132,8 +134,8 @@ async function handle2FAVerify() {
<div class="login-form-wrapper">
<!-- Mobile Brand (hidden on desktop) -->
<div class="login-mobile-brand">
<img src="/favicon.png" alt="Team-API" class="login-brand__logo" />
<span class="login-mobile-brand__name">Team-API</span>
<img src="/favicon.png" :alt="siteName" class="login-brand__logo" />
<span class="login-mobile-brand__name">{{ siteName }}</span>
</div>
<div class="login-form glass animate-slideUp">
+52 -11
View File
@@ -302,10 +302,17 @@ const { exporting, exportFile } = useExport({
</ACard>
<!-- Create Channel Modal -->
<AModal v-model:visible="showModal" title="创建渠道" :width="550" :mask-closable="false" :on-before-ok="handleSubmit" :ok-loading="formLoading">
<AModal v-model:visible="showModal" title="创建渠道" :width="640" :mask-closable="false" :on-before-ok="handleSubmit" :ok-loading="formLoading">
<AForm ref="formRef" :model="form" :rules="formRules" :auto-label-width="true" layout="vertical">
<AFormItem field="name" label="渠道名称" required><AInput v-model="form.name" placeholder="例如:OpenAI 主力" /></AFormItem>
<AFormItem field="type" label="供应商类型" required><ASelect v-model="form.type" :options="providerTypeOptions" placeholder="输入关键词搜索或选择" allow-search :filter-option="filterProviderOption" /></AFormItem>
<!-- 基本信息 -->
<ARow :gutter="16">
<ACol :span="12">
<AFormItem field="name" label="渠道名称" required><AInput v-model="form.name" placeholder="例如:OpenAI 主力" /></AFormItem>
</ACol>
<ACol :span="12">
<AFormItem field="type" label="供应商类型" required><ASelect v-model="form.type" :options="providerTypeOptions" placeholder="搜索或选择" allow-search :filter-option="filterProviderOption" /></AFormItem>
</ACol>
</ARow>
<AFormItem label="Base URL">
<AInput v-model="form.base_url" :placeholder="providerDefaultURLs[form.type] || 'https://api.openai.com'" />
<template #extra>
@@ -317,17 +324,51 @@ const { exporting, exportFile } = useExport({
<AFormItem field="api_key" label="API Key" required>
<AInput v-model="form.api_key" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="sk-xxxx..." />
</AFormItem>
<AFormItem label="优先级"><AInputNumber v-model="form.priority" :min="0" placeholder="数字越大优先级越高" class="w-full" /></AFormItem>
<AFormItem label="权重"><AInputNumber v-model="form.weight" :min="1" :max="100" class="w-full" /></AFormItem>
<AFormItem label="测试模型"><AInput v-model="form.test_model" placeholder="gpt-4o-mini" /></AFormItem>
<!-- 调度 & 状态 -->
<ARow :gutter="16">
<ACol :span="8">
<AFormItem label="优先级"><AInputNumber v-model="form.priority" :min="0" placeholder="越高越优先" class="w-full" /></AFormItem>
</ACol>
<ACol :span="8">
<AFormItem label="权重"><AInputNumber v-model="form.weight" :min="1" :max="100" class="w-full" /></AFormItem>
</ACol>
<ACol :span="8">
<AFormItem label="状态">
<ASelect v-model="form.status" :options="[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }, { label: '测试中', value: 'testing' }]" />
</AFormItem>
</ACol>
</ARow>
<ARow :gutter="16">
<ACol :span="8">
<AFormItem label="测试模型"><AInput v-model="form.test_model" placeholder="gpt-4o-mini" /></AFormItem>
</ACol>
<ACol :span="8">
<AFormItem label="使用代理">
<ASwitch v-model="form.use_proxy" />
<template #extra><span style="color:var(--color-text-3);font-size:12px">需先在系统设置中配置代理</span></template>
</AFormItem>
</ACol>
</ARow>
<AFormItem label="备注"><AInput v-model="form.remark" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="备注(选填)" /></AFormItem>
<AFormItem label="使用代理"><ASwitch v-model="form.use_proxy" /><template #extra><span style="color:var(--color-text-3);font-size:12px">启用后该渠道的请求通过系统代理转发(需先在系统设置中配置代理地址)</span></template></AFormItem>
<!-- VIP 设置 -->
<ACollapse :bordered="false" :default-active-key="form.is_vip ? ['vip'] : []">
<ACollapseItem header="VIP 设置" key="vip" :header-style="{ fontSize: '13px' }">
<AFormItem label="VIP 渠道"><ASwitch v-model="form.is_vip" /></AFormItem>
<AFormItem v-if="form.is_vip" label="共享阈值"><AInputNumber v-model="form.sharing_threshold" :min="0" :max="100" placeholder="允许共享给非VIP的健康分数阈值" class="w-full" /></AFormItem>
<AFormItem v-if="form.is_vip" label="抢占阈值"><AInputNumber v-model="form.preemption_threshold" :min="0" :max="100" placeholder="抢占共享渠道时的优先级阈值" class="w-full" /></AFormItem>
<AFormItem v-if="form.is_vip" label="借用冷却(秒)"><AInputNumber v-model="form.borrowing_cooldown_seconds" :min="0" placeholder="非VIP借用后冷却时间" class="w-full" /></AFormItem>
<ARow :gutter="16">
<ACol :span="8">
<AFormItem label="VIP 渠道"><ASwitch v-model="form.is_vip" /></AFormItem>
</ACol>
<ACol v-if="form.is_vip" :span="8">
<AFormItem label="共享阈值"><AInputNumber v-model="form.sharing_threshold" :min="0" :max="100" placeholder="共享健康分阈值" class="w-full" /></AFormItem>
</ACol>
<ACol v-if="form.is_vip" :span="8">
<AFormItem label="抢占阈值"><AInputNumber v-model="form.preemption_threshold" :min="0" :max="100" placeholder="抢占优先级阈值" class="w-full" /></AFormItem>
</ACol>
</ARow>
<ARow v-if="form.is_vip" :gutter="16">
<ACol :span="8">
<AFormItem label="借用冷却(秒)"><AInputNumber v-model="form.borrowing_cooldown_seconds" :min="0" placeholder="非VIP冷却时间" class="w-full" /></AFormItem>
</ACol>
</ARow>
</ACollapseItem>
</ACollapse>
</AForm>
+1 -1
View File
@@ -214,7 +214,7 @@ const columns: TableColumnData[] = [
},
},
{ title: '更新时间', dataIndex: 'updated_at', width: 170 },
{ title: '更新时间', dataIndex: 'updated_at', width: 180 },
{
title: '操作',
dataIndex: 'actions',
+4
View File
@@ -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<string, string | undefined> = {
@@ -60,6 +62,8 @@ const platformLabel: Record<string, string> = {
kling: 'Kling',
midjourney: 'Midjourney',
suno: 'Suno',
volcengine: '火山引擎',
ali: '阿里',
}
const columns: TableColumnData[] = [
+15 -115
View File
@@ -1,14 +1,13 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import {
Tag, Button, Message,
Tag, Message,
} from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
// === Billing Records ===
const billLoading = ref(false)
const billData = ref<any[]>([])
const billPagination = reactive({
@@ -69,80 +68,7 @@ async function fetchBillingRecords() {
} finally { billLoading.value = false }
}
// === Wallets ===
const walletLoading = ref(false)
const walletData = ref<any[]>([])
const walletPagination = reactive({
current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50],
})
const showAdjustModal = ref(false)
const adjustForm = reactive({ tenant_id: 0, amount: 0, description: '' })
const adjustLoading = ref(false)
const walletColumns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '租户ID', dataIndex: 'tenant_id', width: 90 },
{
title: '余额', dataIndex: 'balance', width: 120,
render({ record }) { return h('span', { style: { fontWeight: 600 } }, `$${(record.balance || 0).toFixed(2)}`) },
},
{
title: '冻结余额', dataIndex: 'frozen_balance', width: 110,
render({ record }) { return `$${(record.frozen_balance || 0).toFixed(2)}` },
},
{
title: '预警阈值', dataIndex: 'warning_threshold', width: 110,
render({ record }) {
if (record.warning_threshold === null || record.warning_threshold === undefined) return 'N/A'
return `$${record.warning_threshold.toFixed(2)}`
},
},
{ title: '货币', dataIndex: 'currency', width: 70 },
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{ title: '更新时间', dataIndex: 'updated_at', width: 170 },
{
title: '操作', dataIndex: 'actions', width: 100, fixed: 'right',
render({ record }) {
return h(Button, { size: 'small', type: 'primary', onClick: () => openAdjust(record) }, () => '调整余额')
},
},
]
async function fetchWallets() {
walletLoading.value = true
try {
const res: any = await request.get('/admin/wallets', { params: { page: walletPagination.current, page_size: walletPagination.pageSize } })
const walletRaw = res.data?.data
walletData.value = walletRaw?.list || []
walletPagination.total = walletRaw?.total || 0
} catch {
walletData.value = []
walletPagination.total = 0
} finally { walletLoading.value = false }
}
function openAdjust(row: any) {
adjustForm.tenant_id = row.tenant_id
adjustForm.amount = 0
adjustForm.description = ''
showAdjustModal.value = true
}
async function handleAdjust(done: () => void) {
if (adjustForm.amount === 0) { Message.warning('金额不能为零'); return }
adjustLoading.value = true
try {
await request.post(`/admin/wallets/${adjustForm.tenant_id}/adjust`, { amount: adjustForm.amount, description: adjustForm.description })
Message.success('余额调整成功')
done()
fetchWallets()
} catch {
return false
} finally { adjustLoading.value = false }
}
onMounted(() => { fetchBillingRecords(); fetchWallets() })
onMounted(() => { fetchBillingRecords() })
const { exporting, exportFile } = useExport({
url: '/admin/billing-records/export',
@@ -154,7 +80,7 @@ const { exporting, exportFile } = useExport({
<template>
<div class="page-table">
<PageHeader title="计费管理" description="查看计费记录和管理租户钱包">
<PageHeader title="账单记录" description="查看计费记录">
<template #actions>
<ADropdown trigger="hover">
<AButton :loading="exporting">导出</AButton>
@@ -166,44 +92,18 @@ const { exporting, exportFile } = useExport({
</template>
</PageHeader>
<ATabs default-active-key="records" type="line">
<ATabPane key="records" title="计费记录">
<ACard :bordered="false" class="mt-4">
<div class="mb-4">
<ASpace>
<AInput v-model="billFilterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="fetchBillingRecords" />
<AButton type="primary" @click="fetchBillingRecords">搜索</AButton>
</ASpace>
</div>
<ATable :columns="billColumns" :data="billData" :loading="billLoading" :scroll="{ x: 1800 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
<div class="table-footer">
<APagination v-model:current="billPagination.current" v-model:page-size="billPagination.pageSize" :total="billPagination.total" :page-size-options="billPagination.pageSizeOptions" show-page-size @change="fetchBillingRecords" @page-size-change="(s: number) => { billPagination.pageSize = s; billPagination.current = 1; fetchBillingRecords() }" />
</div>
</ACard>
</ATabPane>
<ATabPane key="wallets" title="钱包管理">
<ACard :bordered="false" class="mt-4">
<ATable :columns="walletColumns" :data="walletData" :loading="walletLoading" :scroll="{ x: 1200 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
<div class="table-footer">
<APagination v-model:current="walletPagination.current" v-model:page-size="walletPagination.pageSize" :total="walletPagination.total" :page-size-options="walletPagination.pageSizeOptions" show-page-size @change="fetchWallets" @page-size-change="(s: number) => { walletPagination.pageSize = s; walletPagination.current = 1; fetchWallets() }" />
</div>
</ACard>
</ATabPane>
</ATabs>
<!-- Adjust Balance Modal -->
<AModal v-model:visible="showAdjustModal" title="调整余额" :width="400" :on-before-ok="handleAdjust" :ok-loading="adjustLoading">
<div class="mb-2 text-sm" style="color: var(--ta-text-tertiary)">租户ID:{{ adjustForm.tenant_id }}</div>
<AForm :model="adjustForm" :auto-label-width="true" layout="vertical">
<AFormItem label="金额">
<AInputNumber v-model="adjustForm.amount" :precision="2" placeholder="正数充值,负数扣减" class="w-full" />
</AFormItem>
<AFormItem label="描述">
<AInput v-model="adjustForm.description" placeholder="调整原因(选填)" />
</AFormItem>
</AForm>
</AModal>
<ACard :bordered="false" class="mt-4">
<div class="mb-4">
<ASpace>
<AInput v-model="billFilterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="fetchBillingRecords" />
<AButton type="primary" @click="fetchBillingRecords">搜索</AButton>
</ASpace>
</div>
<ATable :columns="billColumns" :data="billData" :loading="billLoading" :scroll="{ x: 1800 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
<div class="table-footer">
<APagination v-model:current="billPagination.current" v-model:page-size="billPagination.pageSize" :total="billPagination.total" :page-size-options="billPagination.pageSizeOptions" show-page-size @change="fetchBillingRecords" @page-size-change="(s: number) => { billPagination.pageSize = s; billPagination.current = 1; fetchBillingRecords() }" />
</div>
</ACard>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { Tag } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
const loading = ref(false)
const data = ref<any[]>([])
const pagination = reactive({
current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50],
})
const filterTenantId = ref('')
const filterType = ref<string | undefined>(undefined)
const filterUsername = ref('')
const filterStartDate = ref('')
const filterEndDate = ref('')
const typeOptions = [
{ label: '全部', value: '' },
{ label: '消费', value: 'consume' },
{ label: '充值', value: 'recharge' },
{ label: '调整', value: 'adjust' },
]
const typeTagColor: Record<string, string> = {
consume: 'orangered', recharge: 'green', adjust: 'arcoblue',
}
const typeLabel: Record<string, string> = {
consume: '消费', recharge: '充值', adjust: '调整',
}
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true, tooltip: true },
{ title: '用户', dataIndex: 'username', width: 120, ellipsis: true, tooltip: true },
{
title: '类型', dataIndex: 'type', width: 80,
render({ record }) {
return h(Tag, { color: typeTagColor[record.type], size: 'small' }, () => typeLabel[record.type] || record.type)
},
},
{
title: '金额', dataIndex: 'amount', width: 130,
render({ record }) {
const amount = record.amount || 0
const color = amount > 0 ? 'rgb(var(--green-6))' : amount < 0 ? 'rgb(var(--red-6))' : undefined
const prefix = amount > 0 ? '+' : ''
return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, `${prefix}$${Math.abs(amount).toFixed(6)}`)
},
},
{
title: '变动后余额', dataIndex: 'balance_after', width: 130,
render({ record }) { return `$${(record.balance_after || 0).toFixed(6)}` },
},
{ title: '描述', dataIndex: 'description', width: 180, ellipsis: true, tooltip: true },
{ title: '模型', dataIndex: 'model_name', width: 140, ellipsis: true, tooltip: true },
{ title: '时间', dataIndex: 'created_at', width: 170 },
]
async function fetchData() {
loading.value = true
try {
const params: Record<string, any> = { page: pagination.current, page_size: pagination.pageSize }
if (filterTenantId.value) params.tenant_id = parseInt(filterTenantId.value)
if (filterType.value) params.type = filterType.value
if (filterUsername.value) params.username = filterUsername.value
if (filterStartDate.value) params.start_date = filterStartDate.value
if (filterEndDate.value) params.end_date = filterEndDate.value
const res: any = await request.get('/admin/transactions', { params })
const raw = res.data?.data
data.value = raw?.list || []
pagination.total = raw?.total || 0
} catch {
data.value = []
pagination.total = 0
} finally { loading.value = false }
}
function resetAndFetch() {
pagination.current = 1
fetchData()
}
onMounted(fetchData)
</script>
<template>
<div class="page-table">
<PageHeader title="交易流水" description="查看所有租户的钱包交易记录" />
<ACard :bordered="false" class="mt-4">
<div class="mb-4">
<ASpace wrap>
<AInput v-model="filterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="resetAndFetch" />
<ASelect v-model="filterType" :options="typeOptions" style="width: 120px" allow-clear @change="resetAndFetch" />
<AInput v-model="filterUsername" placeholder="用户名" allow-clear style="width: 120px" @keydown.enter="resetAndFetch" />
<ADatePicker v-model="filterStartDate" placeholder="开始日期" style="width: 150px" @change="resetAndFetch" />
<ADatePicker v-model="filterEndDate" placeholder="结束日期" style="width: 150px" @change="resetAndFetch" />
<AButton type="primary" @click="resetAndFetch">搜索</AButton>
</ASpace>
</div>
<ATable :columns="columns" :data="data" :loading="loading" :scroll="{ x: 1200 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
<div class="table-footer">
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchData" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchData() }" />
</div>
</ACard>
</div>
</template>
<style scoped>
.table-footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--ta-border-light);
}
</style>
+2 -3
View File
@@ -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;
}
</style>
@@ -25,14 +25,14 @@ const values = useFormValues()
<div class="section">
<div class="section-title">日志保留</div>
<div class="section-grid">
<AFormItem label="审计日志保留天数">
<AFormItem label="审计日志保留天数" class="field-full">
<AInputNumber
:model-value="values['audit_retention_days'] as number"
@change="(v: number | undefined) => values['audit_retention_days'] = String(v ?? 90)"
:min="7" :max="3650" style="width: 100%"
/>
</AFormItem>
<AFormItem label="操作日志保留天数">
<AFormItem label="操作日志保留天数" class="field-full">
<AInputNumber
:model-value="values['operation_log_retention_days'] as number"
@change="(v: number | undefined) => values['operation_log_retention_days'] = String(v ?? 365)"
@@ -12,16 +12,16 @@ const values = useFormValues()
<span class="switch-label">渠道自动探测</span>
<span class="switch-desc">定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)</span>
<ASwitch
:model-value="values['channel_auto_test_enabled'] === 'true' || values['channel_auto_test_enabled'] === '1'"
@change="(v: string | number | boolean) => 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"
/>
</div>
<div class="switch-row" style="margin-top: 12px">
<span class="switch-label">自动恢复探测</span>
<span class="switch-desc">定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)</span>
<ASwitch
:model-value="values['channel_auto_test_recovery_enabled'] === 'true' || values['channel_auto_test_recovery_enabled'] === '1'"
@change="(v: string | number | boolean) => 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"
/>
</div>
</div>
@@ -33,8 +33,8 @@ const values = useFormValues()
<span class="switch-label">渠道自动禁用</span>
<span class="switch-desc">连续失败达到阈值时自动禁用渠道</span>
<ASwitch
:model-value="values['channel_auto_disable_enabled'] === 'true' || values['channel_auto_disable_enabled'] === '1'"
@change="(v: string | number | boolean) => 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"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
@@ -47,8 +47,8 @@ function set(key: string, v: number | undefined): void {
<div class="switch-row">
<span class="switch-label">启用文件保留期检查</span>
<ASwitch
:model-value="values['file_retention_enabled'] === 'true' || values['file_retention_enabled'] === '1'"
@change="(v: string | number | boolean) => values['file_retention_enabled'] = String(v)"
:model-value="!!values['file_retention_enabled']"
@change="(v: string | number | boolean) => values['file_retention_enabled'] = v"
/>
</div>
</div>
@@ -40,8 +40,8 @@ const values = useFormValues()
<span class="switch-label">启用 TLS</span>
<span class="switch-desc">587/465 端口建议开启</span>
<ASwitch
:model-value="values['email_smtp_tls'] === 'true' || values['email_smtp_tls'] === '1'"
@change="(v: string | number | boolean) => values['email_smtp_tls'] = String(v)"
:model-value="!!values['email_smtp_tls']"
@change="(v: string | number | boolean) => values['email_smtp_tls'] = v"
/>
</div>
</div>
@@ -12,7 +12,7 @@ const values = useFormValues()
<AFormItem label="站点名称">
<AInput v-model="values['site_name']" placeholder="Team-API" />
</AFormItem>
<AFormItem label="站点描述">
<AFormItem label="站点描述" class="field-full">
<AInput v-model="values['site_description']" placeholder="简短描述平台用途" />
</AFormItem>
<AFormItem label="租户控制台地址" help="用于生成邀请链接等,如 https://console.example.com" class="field-full">
@@ -28,38 +28,20 @@ const values = useFormValues()
<span class="switch-label">开放注册</span>
<span class="switch-desc">是否允许新用户注册</span>
<ASwitch
:model-value="values['register_enabled'] === 'true' || values['register_enabled'] === '1'"
@change="(v: string | number | boolean) => values['register_enabled'] = String(v)"
:model-value="!!values['register_enabled']"
@change="(v: string | number | boolean) => values['register_enabled'] = v"
/>
</div>
<div class="switch-row">
<span class="switch-label">注册邮箱验证</span>
<span class="switch-desc">注册时是否需要邮箱验证码,关闭时使用滑块验证</span>
<ASwitch
:model-value="values['register_email_verification'] === 'true' || values['register_email_verification'] === '1'"
@change="(v: string | number | boolean) => values['register_email_verification'] = String(v)"
:model-value="!!values['register_email_verification']"
@change="(v: string | number | boolean) => values['register_email_verification'] = v"
/>
</div>
</div>
<!-- 演示模式 -->
<div class="section">
<div class="section-title">演示模式</div>
<div class="switch-row">
<span class="switch-label">启用演示模式</span>
<span class="switch-desc">开启后所有写操作被拦截,仅允许只读访问</span>
<ASwitch
:model-value="values['demo_mode'] === 'true' || values['demo_mode'] === '1'"
@change="(v: string | number | boolean) => values['demo_mode'] = String(v)"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
<AFormItem label="演示模式提示">
<AInput v-model="values['demo_message']" placeholder="演示环境,数据不可修改" />
</AFormItem>
</div>
</div>
<!-- 维护模式 -->
<div class="section">
<div class="section-title">维护模式</div>
@@ -67,8 +49,8 @@ const values = useFormValues()
<span class="switch-label">启用维护模式</span>
<span class="switch-desc">开启后控制台显示维护提示</span>
<ASwitch
:model-value="values['maintenance_mode'] === 'true' || values['maintenance_mode'] === '1'"
@change="(v: string | number | boolean) => values['maintenance_mode'] = String(v)"
:model-value="!!values['maintenance_mode']"
@change="(v: string | number | boolean) => values['maintenance_mode'] = v"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
@@ -83,8 +65,8 @@ const values = useFormValues()
<span class="switch-label">全局 API 维护</span>
<span class="switch-desc">开启后 API 代理返回 503</span>
<ASwitch
:model-value="values['api_maintenance_enabled'] === 'true' || values['api_maintenance_enabled'] === '1'"
@change="(v: string | number | boolean) => values['api_maintenance_enabled'] = String(v)"
:model-value="!!values['api_maintenance_enabled']"
@change="(v: string | number | boolean) => values['api_maintenance_enabled'] = v"
/>
</div>
</div>
@@ -12,8 +12,8 @@ const values = useFormValues()
<span class="switch-label">OAuth 自动注册</span>
<span class="switch-desc">首次 OAuth 登录自动创建租户账号</span>
<ASwitch
:model-value="values['oauth_auto_register'] === 'true' || values['oauth_auto_register'] === '1'"
@change="(v: string | number | boolean) => values['oauth_auto_register'] = String(v)"
:model-value="!!values['oauth_auto_register']"
@change="(v: string | number | boolean) => values['oauth_auto_register'] = v"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
@@ -30,8 +30,8 @@ const values = useFormValues()
<span class="switch-label">启用 GitHub 登录</span>
<span class="switch-desc"></span>
<ASwitch
:model-value="values['oauth_github_enabled'] === 'true' || values['oauth_github_enabled'] === '1'"
@change="(v: string | number | boolean) => values['oauth_github_enabled'] = String(v)"
:model-value="!!values['oauth_github_enabled']"
@change="(v: string | number | boolean) => values['oauth_github_enabled'] = v"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
@@ -51,8 +51,8 @@ const values = useFormValues()
<span class="switch-label">启用 Google 登录</span>
<span class="switch-desc"></span>
<ASwitch
:model-value="values['oauth_google_enabled'] === 'true' || values['oauth_google_enabled'] === '1'"
@change="(v: string | number | boolean) => values['oauth_google_enabled'] = String(v)"
:model-value="!!values['oauth_google_enabled']"
@change="(v: string | number | boolean) => values['oauth_google_enabled'] = v"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
@@ -65,8 +65,8 @@ function set(key: string, v: number | undefined): void {
<div class="compact-row">
<label>启用缓存</label>
<ASwitch
:model-value="values['cache_enabled'] === 'true' || values['cache_enabled'] === '1'"
@change="(v: string | number | boolean) => values['cache_enabled'] = String(v)"
:model-value="!!values['cache_enabled']"
@change="(v: string | number | boolean) => values['cache_enabled'] = v"
/>
</div>
<div class="compact-row">
@@ -107,8 +107,8 @@ function set(key: string, v: number | undefined): void {
<div class="compact-row">
<label>启用沙箱</label>
<ASwitch
:model-value="values['sandbox_enabled'] === 'true' || values['sandbox_enabled'] === '1'"
@change="(v: string | number | boolean) => values['sandbox_enabled'] = String(v)"
:model-value="!!values['sandbox_enabled']"
@change="(v: string | number | boolean) => values['sandbox_enabled'] = v"
/>
</div>
<div class="compact-row">
@@ -89,7 +89,7 @@ const values = useFormValues()
</AFormItem>
<AFormItem label="新设备登录通知">
<ASwitch
:model-value="values['new_device_notification'] !== false"
:model-value="!!values['new_device_notification']"
@change="(v: string | number | boolean) => values['new_device_notification'] = v"
/>
</AFormItem>
@@ -47,8 +47,8 @@ const values = useFormValues()
<div class="switch-row" style="margin-top: 8px">
<span class="switch-label">启用 SSL</span>
<ASwitch
:model-value="values['storage_use_ssl'] === 'true' || values['storage_use_ssl'] === '1'"
@change="(v: string | number | boolean) => values['storage_use_ssl'] = String(v)"
:model-value="!!values['storage_use_ssl']"
@change="(v: string | number | boolean) => values['storage_use_ssl'] = v"
/>
</div>
</div>
@@ -19,12 +19,7 @@ export function useSettings(category: () => string) {
const items = res.data?.data?.list || []
const vals: Record<string, any> = {}
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)
+2 -2
View File
@@ -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',
+1 -1
View File
@@ -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', [
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
interface Props {
modelValue?: string
siteKey?: string
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact'
tabindex?: number
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
siteKey: '',
theme: 'auto',
size: 'normal',
tabindex: 0,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'verified', token: string): void
(e: 'error', error: any): void
}>()
const containerId = `turnstile-container-${Math.random().toString(36).substr(2, 9)}`
const isLoaded = ref(false)
const isVerifying = ref(false)
const turnstileScriptId = 'turnstile-script'
let widgetId: string | null = null
// Load Cloudflare Turnstile script
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (window.turnstile) {
isLoaded.value = true
resolve()
return
}
const script = document.createElement('script')
script.id = turnstileScriptId
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
script.async = true
script.defer = true
script.onload = () => {
isLoaded.value = true
resolve()
}
script.onerror = () => {
reject(new Error('Failed to load Turnstile script'))
}
document.head.appendChild(script)
})
}
// Render Turnstile widget
function renderWidget() {
if (!window.turnstile || !props.siteKey) return
const container = document.getElementById(containerId)
if (!container) return
// Clear existing widget
if (widgetId !== null) {
window.turnstile.remove(widgetId)
}
// Render new widget
widgetId = window.turnstile.render(container, {
sitekey: props.siteKey,
theme: props.theme,
size: props.size,
tabindex: props.tabindex,
callback: (token: string) => {
isVerifying.value = false
emit('update:modelValue', token)
emit('verified', token)
},
'error-callback': (error: any) => {
isVerifying.value = false
emit('error', error)
},
'expired-callback': () => {
widgetId = null
emit('update:modelValue', '')
},
})
}
// Reset widget
function reset() {
if (widgetId !== null && window.turnstile) {
window.turnstile.reset(widgetId)
emit('update:modelValue', '')
}
}
// Expose reset method
defineExpose({
reset,
})
onMounted(async () => {
try {
await loadTurnstileScript()
renderWidget()
} catch (error) {
console.error('Failed to initialize Turnstile:', error)
emit('error', error)
}
})
onUnmounted(() => {
if (widgetId !== null && window.turnstile) {
window.turnstile.remove(widgetId)
}
})
// Watch for siteKey changes
watch(() => props.siteKey, () => {
if (isLoaded.value) {
renderWidget()
}
})
// Watch for modelValue changes from parent
watch(() => props.modelValue, (newValue) => {
if (!newValue && widgetId !== null && window.turnstile) {
window.turnstile.reset(widgetId)
}
})
</script>
<template>
<div
:id="containerId"
class="turnstile-widget"
:class="{ 'turnstile-loading': isVerifying }"
/>
</template>
<style scoped>
.turnstile-widget {
min-height: 65px;
display: flex;
align-items: center;
justify-content: center;
}
.turnstile-loading {
opacity: 0.6;
}
</style>
@@ -29,10 +29,10 @@
<!-- Logo/Brand -->
<div class="mb-8 text-center">
<div class="flex items-center gap-3 justify-center">
<img src="/favicon.png" alt="Team-API" class="h-10 w-10 rounded-lg object-contain" />
<h1 class="text-gradient text-2xl font-bold leading-tight">Team API</h1>
<img src="/favicon.png" :alt="siteName" class="h-10 w-10 rounded-lg object-contain" />
<h1 class="text-gradient text-2xl font-bold leading-tight">{{ siteName }}</h1>
</div>
<p class="mt-3 text-sm text-gray-500">开源自托管的多租户 大模型 API 网关平台</p>
<p class="mt-3 text-sm text-gray-500">{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}</p>
</div>
<!-- Card Container -->
@@ -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<any[]>([])
let timer: ReturnType<typeof setInterval> | null = null
@@ -24,6 +24,8 @@ const settings = ref<PublicSettings>({
site_description: '',
demo_mode: false,
demo_message: '',
turnstile_enabled: false,
turnstile_site_key: '',
})
let fetchPromise: Promise<void> | null = null
+4 -2
View File
@@ -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',
+4 -1
View File
@@ -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
+3
View File
@@ -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)
+51 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import { usePublicSettings } from '@/composables/usePublicSettings'
@@ -8,6 +8,7 @@ import request from '@/utils/request'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import Icon from '@/components/common/Icon.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue'
const router = useRouter()
const route = useRoute()
@@ -35,6 +36,7 @@ const totpLoading = ref(false)
// Captcha state — always required
const captcha = reactive({ captchaKey: '', captchaX: 0 })
const turnstileToken = ref('')
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
const emailInput = ref<HTMLInputElement | null>(null)
@@ -181,6 +183,23 @@ function clearRamError(field: string) {
delete ramErrors[field]
if (errorMsg.value) errorMsg.value = ''
}
const hasOAuthProviders = computed(() => {
return settings.value['oauth_github_enabled'] || settings.value['oauth_google_enabled']
})
async function handleOAuthLogin(provider: string) {
try {
const res = await request.get('/tenant/oauth/authorize', { params: { provider } })
const { authorize_url } = res.data?.data || {}
if (authorize_url) {
window.location.href = authorize_url
}
} catch (e: any) {
const apiErr = extractApiError(e)
errorMsg.value = apiErr?.message || '第三方登录失败'
}
}
</script>
<template>
@@ -417,6 +436,37 @@ function clearRamError(field: string) {
</button>
</form>
<!-- OAuth Login -->
<div v-if="hasOAuthProviders" class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center text-xs">
<span class="bg-white px-3 text-gray-400">或使用第三方账号登录</span>
</div>
</div>
<div class="mt-4 flex gap-3">
<button
v-if="settings['oauth_github_enabled']"
type="button"
class="btn btn-secondary flex-1"
@click="handleOAuthLogin('github')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</button>
<button
v-if="settings['oauth_google_enabled']"
type="button"
class="btn btn-secondary flex-1"
@click="handleOAuthLogin('google')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Google
</button>
</div>
</div>
</div>
<template v-if="settings.register_enabled" #footer>
@@ -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<typeof setInterval> | null = null
const orgForm = reactive({ orgName: '', orgCode: '' })
+15 -10
View File
@@ -5,8 +5,8 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-2.5">
<img src="/favicon.png" alt="Team-API" class="h-8 w-8 rounded-lg" />
<span class="text-lg font-bold tracking-tight" :class="isScrolled ? 'text-gray-900' : 'text-white'">Team-API</span>
<img src="/favicon.png" :alt="siteName" class="h-8 w-8 rounded-lg" />
<span class="text-lg font-bold tracking-tight" :class="isScrolled ? 'text-gray-900' : 'text-white'">{{ siteName }}</span>
</div>
<div class="flex items-center gap-2">
<a href="https://github.com/qianfree/team-api" target="_blank" rel="noopener noreferrer"
@@ -240,7 +240,7 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2">
<img src="/favicon.png" alt="Team-API" class="h-5 w-5 rounded" />
<img src="/favicon.png" :alt="siteName" class="h-5 w-5 rounded" />
<span class="text-xs text-gray-600">&copy; 2026 qianfree. Released under AGPL-3.0.</span>
</div>
<a href="https://github.com/qianfree/team-api" target="_blank" rel="noopener noreferrer"
@@ -257,6 +257,10 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useSeo } from '@/composables/useSeo'
import { useHead } from '@unhead/vue'
import { usePublicSettings } from '@/composables/usePublicSettings'
const { settings: publicSettings } = usePublicSettings()
const siteName = publicSettings.value.site_name || 'Team-API'
// Nav scroll state
const isScrolled = ref(false)
@@ -275,8 +279,9 @@ const activeScenario = ref(0)
// SEO
useSeo({
title: 'Team-API — 一个 API 接入所有大模型 | 企业级多租户 AI 网关',
description: 'Team-API 是开源自托管的企业级多租户大模型 API 网关平台。聚合 OpenAI、Claude、Gemini、DeepSeek 等 40+ 供应商,内置计费引擎、团队管理、用量审计与智能渠道调度。完全兼容 OpenAI SDK,只需修改 base_url 即可接入。',
title: `${siteName} — 一个 API 接入所有大模型 | 企业级多租户 AI 网关`,
description: publicSettings.value.site_description || `${siteName} 是开源自托管的企业级多租户大模型 API 网关平台。聚合 OpenAI、Claude、Gemini、DeepSeek 等 40+ 供应商,内置计费引擎、团队管理、用量审计与智能渠道调度。完全兼容 OpenAI SDK,只需修改 base_url 即可接入。`,
siteName,
keywords: 'Team-API, 大模型网关, API Gateway, 多租户, OpenAI, Claude, Gemini, DeepSeek, 阿里云百炼, 百度文心, 腾讯混元, 智谱AI, AI代理, LLM Gateway, 开源, 自托管, 计费引擎, 团队管理, API管理, SSE流式, 渠道调度',
canonicalUrl: 'https://team-api.net/',
})
@@ -288,8 +293,8 @@ useHead({
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Team-API',
description: '企业级多租户大模型 API 网关平台,聚合 40+ 大模型供应商,内置计费引擎、团队管理、用量审计与渠道调度。',
name: siteName,
description: publicSettings.value.site_description || '企业级多租户大模型 API 网关平台,聚合 40+ 大模型供应商,内置计费引擎、团队管理、用量审计与渠道调度。',
url: 'https://github.com/qianfree/team-api',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Linux, macOS, Windows',
@@ -311,7 +316,7 @@ useHead({
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Team-API',
name: siteName,
url: 'https://github.com/qianfree/team-api',
logo: 'https://team-api.net/favicon.png',
sameAs: ['https://github.com/qianfree/team-api'],
@@ -322,9 +327,9 @@ useHead({
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Team-API',
name: siteName,
url: 'https://team-api.net/',
description: '企业级多租户大模型 API 网关平台',
description: publicSettings.value.site_description || '企业级多租户大模型 API 网关平台',
potentialAction: {
'@type': 'SearchAction',
target: 'https://github.com/qianfree/team-api/search?q={search_term_string}',
+40 -19
View File
@@ -5,6 +5,7 @@ import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
interface TaskItem {
id: number
@@ -40,6 +41,15 @@ const showDetail = ref(false)
const detailLoading = ref(false)
const detailTask = ref<TaskItem | null>(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<string, string> = {
NOT_START: 'bg-gray-100 text-gray-800',
SUBMITTED: 'bg-blue-100 text-blue-800',
@@ -63,6 +73,8 @@ const platformLabel: Record<string, string> = {
kling: 'Kling',
midjourney: 'Midjourney',
suno: 'Suno',
volcengine: '火山引擎',
ali: '阿里',
}
function formatCost(n: number | undefined): string {
@@ -153,11 +165,20 @@ onMounted(() => {
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="page-header">
<div class="page-header flex items-center justify-between">
<div>
<h1 class="page-title">任务日志</h1>
<p class="page-description">查看异步生成任务(视频/图片/音乐)的执行记录</p>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="exportFile('csv')">
<Icon v-if="exporting" name="refresh" size="xs" class="animate-spin" />
{{ exporting ? '导出中...' : '导出 CSV' }}
</button>
<button class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="exportFile('xlsx')">
{{ exporting ? '导出中...' : '导出 Excel' }}
</button>
</div>
</div>
<!-- Filters -->
@@ -174,7 +195,7 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">平台</label>
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'}]" container-class="w-[120px]" />
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'}]" container-class="w-[120px]" />
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn btn-primary btn-sm" @click="applyFilters">搜索</button>
@@ -199,25 +220,25 @@ onMounted(() => {
<div v-else>
<div class="table-container">
<table class="table">
<table class="table table-fixed w-full">
<thead>
<tr>
<th>任务 ID</th>
<th>平台</th>
<th>状态</th>
<th>模型</th>
<th>费用</th>
<th>提交时间</th>
<th>完成时间</th>
<th></th>
<th class="w-52">任务 ID</th>
<th class="w-24">平台</th>
<th class="w-20">状态</th>
<th class="w-44">模型</th>
<th class="w-25">费用</th>
<th class="w-38">提交时间</th>
<th class="w-38">完成时间</th>
<th class="w-15">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="task in tasks" :key="task.id">
<td>
<td class="truncate" :title="task.public_task_id">
<span class="font-mono text-xs text-gray-600">{{ task.public_task_id }}</span>
</td>
<td>
<td class="truncate">
<span class="text-sm font-medium text-gray-700">{{ platformLabel[task.platform] || task.platform }}</span>
</td>
<td>
@@ -225,19 +246,19 @@ onMounted(() => {
{{ statusLabel[task.status] || task.status }}
</span>
</td>
<td>
<td class="truncate" :title="task.model_name">
<span class="text-sm text-gray-700">{{ task.model_name || '-' }}</span>
</td>
<td>
<td class="truncate">
<span v-if="task.billing_settled && task.actual_cost > 0" class="text-sm font-medium text-emerald-600">{{ formatCost(task.actual_cost) }}</span>
<span v-else-if="task.pre_deduct_amount > 0" class="text-sm text-gray-500">{{ formatCost(task.pre_deduct_amount) }} <span class="text-xs text-gray-400">(预扣)</span></span>
<span v-else class="text-sm text-gray-400">-</span>
</td>
<td>
<span class="text-xs text-gray-500 whitespace-nowrap">{{ formatTime(task.submit_time) }}</span>
<td class="truncate">
<span class="text-xs text-gray-500">{{ formatTime(task.submit_time) }}</span>
</td>
<td>
<span class="text-xs text-gray-500 whitespace-nowrap">{{ formatTime(task.finish_time) }}</span>
<td class="truncate">
<span class="text-xs text-gray-500">{{ formatTime(task.finish_time) }}</span>
</td>
<td>
<button class="btn btn-ghost btn-sm p-1.5" title="查看详情" @click="openDetail(task)">
+131 -1
View File
@@ -1,12 +1,22 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import request from '@/utils/request'
import { usePublicSettings } from '@/composables/usePublicSettings'
const loading = ref(false)
const userInfo = ref<any>(null)
const sessions = ref<any[]>([])
const { settings } = usePublicSettings()
// OAuth state
const oauthProviders = ref<any[]>([])
const oauthLoading = ref(false)
const linkProvider = ref('')
const linkCode = ref('')
const linkLoading = ref(false)
const linkError = ref('')
const editingDisplayName = ref(false)
const displayNameForm = reactive({
@@ -139,7 +149,88 @@ function parseDeviceInfo(deviceInfo: string): string {
onMounted(() => {
fetchProfile()
fetchSessions()
fetchOAuthProviders()
})
async function fetchOAuthProviders() {
oauthLoading.value = true
try {
const res: any = await request.get('/tenant/oauth/providers')
oauthProviders.value = res.data?.data?.list || []
} catch {
oauthProviders.value = []
} finally {
oauthLoading.value = false
}
}
const hasOAuthEnabled = computed(() => {
return settings.value['oauth_github_enabled'] || settings.value['oauth_google_enabled']
})
function isProviderLinked(provider: string): boolean {
return oauthProviders.value.some((p: any) => p.provider === provider)
}
function getLinkedProvider(provider: string) {
return oauthProviders.value.find((p: any) => p.provider === provider)
}
async function handleOAuthLink(provider: string) {
linkLoading.value = true
linkError.value = ''
try {
const res: any = await request.get('/tenant/oauth/authorize', { params: { provider } })
const { authorize_url, state } = res.data?.data || {}
if (authorize_url) {
// Open OAuth in popup window
const width = 600
const height = 700
const left = (window.innerWidth - width) / 2
const top = (window.innerHeight - height) / 2
const popup = window.open(
authorize_url,
'oauth',
`width=${width},height=${height},left=${left},top=${top}`
)
// Listen for callback message
const handleMessage = async (event: MessageEvent) => {
if (event.data?.type === 'oauth-callback') {
window.removeEventListener('message', handleMessage)
if (event.data.code) {
try {
await request.post('/tenant/oauth/link', {
provider,
code: event.data.code,
})
await fetchOAuthProviders()
} catch (e: any) {
const res = e?.response?.data
linkError.value = res?.message || '绑定失败'
}
}
popup?.close()
linkLoading.value = false
}
}
window.addEventListener('message', handleMessage)
}
} catch (e: any) {
const res = e?.response?.data
linkError.value = res?.message || '绑定失败'
} finally {
linkLoading.value = false
}
}
async function handleOAuthUnlink(provider: string) {
try {
await request.post('/tenant/oauth/unlink', { provider })
await fetchOAuthProviders()
} catch {
// error toast shown by interceptor
}
}
</script>
<template>
@@ -259,6 +350,45 @@ onMounted(() => {
</div>
</div>
<!-- OAuth Linked Accounts Card -->
<div v-if="hasOAuthEnabled" class="card">
<div class="card-header">
<h2 class="font-semibold text-gray-900">第三方账号</h2>
</div>
<div class="divide-y divide-gray-100">
<div v-if="settings['oauth_github_enabled']" class="px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-xl bg-gray-100 flex items-center justify-center">
<svg class="h-5 w-5 text-gray-700" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">GitHub</p>
<p v-if="isProviderLinked('github')" class="text-xs text-gray-500 mt-0.5">已绑定:{{ getLinkedProvider('github')?.provider_username || getLinkedProvider('github')?.provider_user_id }}</p>
<p v-else class="text-xs text-gray-500 mt-0.5">未绑定</p>
</div>
</div>
<button v-if="isProviderLinked('github')" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" @click="handleOAuthUnlink('github')">解绑</button>
<button v-else class="btn btn-secondary btn-sm" :disabled="linkLoading" @click="handleOAuthLink('github')">绑定</button>
</div>
<div v-if="settings['oauth_google_enabled']" class="px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-xl bg-gray-100 flex items-center justify-center">
<svg class="h-5 w-5" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Google</p>
<p v-if="isProviderLinked('google')" class="text-xs text-gray-500 mt-0.5">已绑定:{{ getLinkedProvider('google')?.provider_username || getLinkedProvider('google')?.provider_user_id }}</p>
<p v-else class="text-xs text-gray-500 mt-0.5">未绑定</p>
</div>
</div>
<button v-if="isProviderLinked('google')" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" @click="handleOAuthUnlink('google')">解绑</button>
<button v-else class="btn btn-secondary btn-sm" :disabled="linkLoading" @click="handleOAuthLink('google')">绑定</button>
</div>
</div>
<p v-if="linkError" class="px-6 pb-4 text-xs text-red-500">{{ linkError }}</p>
</div>
<!-- Active Sessions Card -->
<div class="card">
<div class="card-header">