fi:修复公共模块问题
This commit is contained in:
1 parent
1565416899
commit
573e9ad21b
23 files changed
+376
-231
No files matched your search
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -314,6 +313,3 @@ func extractClaudeNonStreamText(respBody string) string {
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// init 确保 bufio 和 bytes 被 import 使用
|
||||
var _ = io.EOF
|
||||
@@ -124,7 +124,12 @@ func (bw *BatchWriter) autoFlush() {
|
||||
|
||||
_ = bw.Flush(gctx.New())
|
||||
|
||||
// Reset timer
|
||||
if bw.closed {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop before Reset per Go docs recommendation
|
||||
bw.flushTimer.Stop()
|
||||
bw.flushTimer.Reset(5 * time.Second)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,11 +60,18 @@ func (s *ConfigService) GetOption(ctx context.Context, key string) string {
|
||||
return gconv.String(val)
|
||||
}
|
||||
|
||||
var option Option
|
||||
var option *Option
|
||||
err := dao.SysOptions.Ctx(ctx).
|
||||
Where("key", key).
|
||||
Scan(&option)
|
||||
if err != nil || option.Key == "" {
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[Config] DB error reading option %s: %v", key, err)
|
||||
if def := GetSettingDef(key); def != nil {
|
||||
return def.Default
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if option == nil {
|
||||
if def := GetSettingDef(key); def != nil {
|
||||
return def.Default
|
||||
}
|
||||
@@ -220,7 +227,7 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error
|
||||
s.cache.Delete(ctx, key)
|
||||
s.cache.Delete(ctx, "public_options")
|
||||
s.cache.Delete(ctx, "category:"+getCategoryForKey(key))
|
||||
s.publishChange(key)
|
||||
s.publishChange(ctx, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -425,8 +432,8 @@ func getCategoryForKey(key string) string {
|
||||
}
|
||||
|
||||
// publishChange notifies other instances via Redis Pub/Sub to invalidate their local cache.
|
||||
func (s *ConfigService) publishChange(key string) {
|
||||
_, _ = g.Redis().Publish(context.Background(), "settings:changed", key)
|
||||
func (s *ConfigService) publishChange(ctx context.Context, key string) {
|
||||
_, _ = g.Redis().Publish(ctx, "settings:changed", key)
|
||||
}
|
||||
|
||||
// TypedValue converts a string value to its proper Go type based on SettingType.
|
||||
|
||||
@@ -25,19 +25,21 @@ func IsFeatureEnabled(ctx context.Context, tenantID int64, featureKey string) bo
|
||||
|
||||
// 2. 查租户当前套餐
|
||||
if tenantID > 0 {
|
||||
var planID int64
|
||||
var planRow *struct {
|
||||
PlanID int64
|
||||
}
|
||||
err := dao.PlnTenantPlans.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Fields("plan_id").
|
||||
Limit(1).
|
||||
Scan(&planID)
|
||||
if err == nil && planID > 0 {
|
||||
Scan(&planRow)
|
||||
if err == nil && planRow != nil && planRow.PlanID > 0 {
|
||||
var flag *struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
err = dao.PlnFeatureFlags.Ctx(ctx).
|
||||
Where("plan_id", planID).
|
||||
Where("plan_id", planRow.PlanID).
|
||||
Where("feature_key", featureKey).
|
||||
Scan(&flag)
|
||||
if err == nil && flag != nil && flag.Enabled {
|
||||
|
||||
@@ -37,7 +37,7 @@ var jwtSecret []byte
|
||||
func InitJWTSecret(ctx context.Context) {
|
||||
secret := g.Cfg().MustGet(ctx, "jwt.secret").String()
|
||||
if secret == "" {
|
||||
secret = "team-api-default-jwt-secret-change-me-in-production"
|
||||
panic("jwt.secret is not configured — refusing to start with weak key")
|
||||
}
|
||||
jwtSecret = []byte(secret)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func InitJWTSecret(ctx context.Context) {
|
||||
// GetJWTSecret returns the cached JWT signing secret.
|
||||
func GetJWTSecret() []byte {
|
||||
if jwtSecret == nil {
|
||||
jwtSecret = []byte("team-api-default-jwt-secret-change-me-in-production")
|
||||
panic("JWT secret not initialized — call InitJWTSecret first")
|
||||
}
|
||||
return jwtSecret
|
||||
}
|
||||
|
||||
@@ -12,18 +12,37 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
proxyClientOnce sync.Once
|
||||
proxyClientMu sync.RWMutex
|
||||
proxyClientValue *http.Client
|
||||
)
|
||||
|
||||
// GetHTTPClient returns an HTTP client configured with proxy if channel_proxy_url is set in system settings.
|
||||
// Rebuilds on each call so runtime config changes take effect.
|
||||
func GetHTTPClient() *http.Client {
|
||||
proxyClientOnce.Do(func() {
|
||||
proxyClientValue = buildProxyClient()
|
||||
})
|
||||
proxyClientMu.RLock()
|
||||
client := proxyClientValue
|
||||
proxyClientMu.RUnlock()
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
|
||||
proxyClientMu.Lock()
|
||||
defer proxyClientMu.Unlock()
|
||||
// Double-check after acquiring write lock
|
||||
if proxyClientValue != nil {
|
||||
return proxyClientValue
|
||||
}
|
||||
proxyClientValue = buildProxyClient()
|
||||
return proxyClientValue
|
||||
}
|
||||
|
||||
// ResetHTTPClient forces rebuilding the HTTP client on next GetHTTPClient call.
|
||||
func ResetHTTPClient() {
|
||||
proxyClientMu.Lock()
|
||||
defer proxyClientMu.Unlock()
|
||||
proxyClientValue = nil
|
||||
}
|
||||
|
||||
func buildProxyClient() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -163,7 +164,7 @@ func GeminiExchangeCode(sessionID, code string) (*OAuthKeyData, error) {
|
||||
if oauthType == "code_assist" {
|
||||
projectID, err = geminiLoadCodeAssist(tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
g.Log().Warningf(nil, "Gemini loadCodeAssist 失败: %v", err)
|
||||
g.Log().Warningf(gctx.New(), "Gemini loadCodeAssist 失败: %v", err)
|
||||
// 不阻断流程,projectID 留空
|
||||
}
|
||||
}
|
||||
@@ -171,7 +172,7 @@ func GeminiExchangeCode(sessionID, code string) (*OAuthKeyData, error) {
|
||||
// 获取用户邮箱
|
||||
email, err := geminiGetUserInfo(tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
g.Log().Warningf(nil, "Gemini 获取用户信息失败: %v", err)
|
||||
g.Log().Warningf(gctx.New(), "Gemini 获取用户信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 计算过期时间,带安全窗口
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -128,7 +129,7 @@ func OpenAIExchangeCode(sessionID, code, state string) (*OAuthKeyData, error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
g.Log().Warningf(nil, "OpenAI token 交换失败, status=%d, body=%s", resp.StatusCode, string(body))
|
||||
g.Log().Warningf(gctx.New(), "OpenAI token 交换失败, status=%d, body=%s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("token 交换失败 (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
@@ -140,7 +141,7 @@ func OpenAIExchangeCode(sessionID, code, state string) (*OAuthKeyData, error) {
|
||||
// 解析 ID token JWT payload 提取用户信息
|
||||
claims, err := parseOpenAIIDToken(tokenResp.IDToken)
|
||||
if err != nil {
|
||||
g.Log().Warningf(nil, "解析 OpenAI ID token 失败: %v", err)
|
||||
g.Log().Warningf(gctx.New(), "解析 OpenAI ID token 失败: %v", err)
|
||||
// ID token 解析失败不阻断流程,继续返回基本 token 数据
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ func OpenAIRefreshToken(refreshToken string) (*OAuthKeyData, error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
g.Log().Warningf(nil, "OpenAI token 刷新失败, status=%d, body=%s", resp.StatusCode, string(body))
|
||||
g.Log().Warningf(gctx.New(), "OpenAI token 刷新失败, status=%d, body=%s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("token 刷新失败 (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
@@ -203,7 +204,7 @@ func OpenAIRefreshToken(refreshToken string) (*OAuthKeyData, error) {
|
||||
// 刷新响应中可能包含新的 ID token
|
||||
claims, err := parseOpenAIIDToken(tokenResp.IDToken)
|
||||
if err != nil {
|
||||
g.Log().Warningf(nil, "解析 OpenAI ID token (refresh) 失败: %v", err)
|
||||
g.Log().Warningf(gctx.New(), "解析 OpenAI ID token (refresh) 失败: %v", err)
|
||||
}
|
||||
|
||||
keyData := &OAuthKeyData{
|
||||
|
||||
@@ -68,6 +68,9 @@ func EnsurePartitions(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// DDL statements cannot use parameterized placeholders (?/) — partition/table names
|
||||
// and DATE literals in FOR VALUES are not bindable. Names are validated by safeNameRe
|
||||
// and dates are generated by time.Format (fixed format), so no injection risk.
|
||||
sql := fmt.Sprintf(
|
||||
`CREATE TABLE IF NOT EXISTS %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')`,
|
||||
partitionName, pt.table, from, to,
|
||||
|
||||
@@ -95,7 +95,7 @@ func FetchLiteLLMPricing(ctx context.Context) (map[string]*LiteLLMModelEntry, er
|
||||
}
|
||||
|
||||
if err := litellmCache.Set(ctx, litellmCacheKey, raw, litellmCacheTTL); err != nil {
|
||||
g.Log().Warning(ctx, "[PricingSource] failed to cache pricing data: %v", err)
|
||||
g.Log().Warningf(ctx, "[PricingSource] failed to cache pricing data: %v", err)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[PricingSource] loaded %d models from LiteLLM", len(raw))
|
||||
@@ -242,7 +242,7 @@ func FetchModelsDevPricing(ctx context.Context) (map[string]*ModelsDevModelEntry
|
||||
}
|
||||
|
||||
if err := modelsDevCache.Set(ctx, modelsDevCacheKey, result, modelsDevCacheTTL); err != nil {
|
||||
g.Log().Warning(ctx, "[PricingSource] failed to cache models.dev data: %v", err)
|
||||
g.Log().Warningf(ctx, "[PricingSource] failed to cache models.dev data: %v", err)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[PricingSource] loaded %d models from models.dev", len(result))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
@@ -188,10 +189,16 @@ func Enable2FA(ctx context.Context, userType string, userID int64, secret, code,
|
||||
// Hash backup codes for storage
|
||||
hashedCodes := make([]string, len(plainCodes))
|
||||
for i, code := range plainCodes {
|
||||
hash, _ := crypto.HashPassword(code)
|
||||
hash, herr := crypto.HashPassword(code)
|
||||
if herr != nil {
|
||||
return nil, gerror.Wrapf(herr, "hash backup code failed")
|
||||
}
|
||||
hashedCodes[i] = hash
|
||||
}
|
||||
codesJSON, _ := json.Marshal(hashedCodes)
|
||||
codesJSON, jerr := json.Marshal(hashedCodes)
|
||||
if jerr != nil {
|
||||
return nil, gerror.Wrapf(jerr, "marshal backup codes failed")
|
||||
}
|
||||
|
||||
// Encrypt TOTP secret
|
||||
encKey := getEncryptionKey(ctx)
|
||||
@@ -341,10 +348,16 @@ func RegenerateBackupCodes(ctx context.Context, userType string, userID int64, c
|
||||
|
||||
hashedCodes := make([]string, len(plainCodes))
|
||||
for i, c := range plainCodes {
|
||||
hash, _ := crypto.HashPassword(c)
|
||||
hash, herr := crypto.HashPassword(c)
|
||||
if herr != nil {
|
||||
return nil, gerror.Wrapf(herr, "hash backup code failed")
|
||||
}
|
||||
hashedCodes[i] = hash
|
||||
}
|
||||
codesJSON, _ := json.Marshal(hashedCodes)
|
||||
codesJSON, jerr := json.Marshal(hashedCodes)
|
||||
if jerr != nil {
|
||||
return nil, gerror.Wrapf(jerr, "marshal backup codes failed")
|
||||
}
|
||||
|
||||
if userType == "admin" {
|
||||
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Data(do.SysAdminUsers{
|
||||
@@ -458,7 +471,10 @@ func consumeBackupCode(ctx context.Context, userType string, userID int64, index
|
||||
newCodes = append(newCodes, c)
|
||||
}
|
||||
}
|
||||
codesJSON, _ := json.Marshal(newCodes)
|
||||
codesJSON, merr := json.Marshal(newCodes)
|
||||
if merr != nil {
|
||||
return gerror.Wrapf(merr, "marshal backup codes failed")
|
||||
}
|
||||
|
||||
var err error
|
||||
if userType == "admin" {
|
||||
@@ -476,7 +492,7 @@ func consumeBackupCode(ctx context.Context, userType string, userID int64, index
|
||||
func getEncryptionKey(ctx context.Context) []byte {
|
||||
hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
|
||||
if hexKey == "" {
|
||||
hexKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
panic("crypto.encryptionKey is not configured — refusing to start with weak key")
|
||||
}
|
||||
return crypto.MustGetEncryptionKey(hexKey)
|
||||
}
|
||||
|
||||
@@ -111,11 +111,14 @@ func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileReco
|
||||
|
||||
// GetDownloadURL generates a presigned URL for downloading a file.
|
||||
func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string, error) {
|
||||
var record FileRecord
|
||||
var record *FileRecord
|
||||
err := dao.FilFiles.Ctx(ctx).
|
||||
Where("id", fileID).
|
||||
Scan(&record)
|
||||
if err != nil || record.ID == 0 {
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "query file %d", fileID)
|
||||
}
|
||||
if record == nil {
|
||||
return "", gerror.Newf("file not found: %d", fileID)
|
||||
}
|
||||
|
||||
@@ -124,11 +127,14 @@ func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string,
|
||||
|
||||
// Delete deletes a file from storage and marks it as deleted.
|
||||
func (s *FileService) Delete(ctx context.Context, fileID int64) error {
|
||||
var record FileRecord
|
||||
var record *FileRecord
|
||||
err := dao.FilFiles.Ctx(ctx).
|
||||
Where("id", fileID).
|
||||
Scan(&record)
|
||||
if err != nil || record.ID == 0 {
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "query file %d", fileID)
|
||||
}
|
||||
if record == nil {
|
||||
return gerror.Newf("file not found: %d", fileID)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
@@ -51,9 +50,9 @@ func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) er
|
||||
if err == nil && hourlyCount.Int() > 5 {
|
||||
return NewBusinessError(consts.CodeRateLimitExceeded, "每小时最多发送5次验证码")
|
||||
}
|
||||
if hourlyCount.Int() == 1 {
|
||||
// Set expiry on first increment
|
||||
_, _ = g.Redis().Do(ctx, "EXPIRE", hourlyKey, 3600)
|
||||
// Always ensure TTL is set (idempotent: EXPIRE resets existing TTL or sets missing one)
|
||||
if _, err := g.Redis().Do(ctx, "EXPIRE", hourlyKey, 3600); err != nil {
|
||||
g.Log().Warningf(ctx, "failed to set hourly rate limit TTL: %v", err)
|
||||
}
|
||||
|
||||
// Generate 6-digit code
|
||||
@@ -74,6 +73,11 @@ func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) er
|
||||
}
|
||||
|
||||
// Send email (fire and forget, log error but don't fail)
|
||||
// Set cooldown before async send (prevents duplicate sends within 60s)
|
||||
if _, err := g.Redis().Do(ctx, "SETEX", cooldownKey, 60, "1"); err != nil {
|
||||
g.Log().Warningf(ctx, "failed to set verify code cooldown: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
emailCfg, err := EmailConfigFromOptions(bgCtx)
|
||||
@@ -100,9 +104,6 @@ func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) er
|
||||
}
|
||||
}()
|
||||
|
||||
// Set cooldown
|
||||
_, _ = g.Redis().Do(ctx, "SETEX", cooldownKey, 60, "1")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -129,11 +130,11 @@ func VerifyCode(ctx context.Context, email, code, purpose string) error {
|
||||
OrderDesc("created_at").
|
||||
Limit(1).
|
||||
Scan(&record)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "query verify code")
|
||||
}
|
||||
|
||||
if record.ID == 0 {
|
||||
if record == nil {
|
||||
return NewBusinessError(consts.CodeVerifyCodeInvalid, "验证码错误")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package tenant
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -105,17 +104,14 @@ func (s *sTenant) AuditLogs(ctx context.Context, req *v1.TenantAuditLogsReq) (*v
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
// 使用原生 SQL 查询,绕过 GoFrame ScanAndCount 对 map[string]any 的 bug
|
||||
dataSQL := fmt.Sprintf(
|
||||
`SELECT id, tenant_id, user_id, user_type, action, resource_type, resource_id, detail, changes_json, ip_address, created_at
|
||||
FROM aud_operation_logs WHERE tenant_id = ? ORDER BY created_at DESC LIMIT %d OFFSET %d`,
|
||||
pageSize, (page-1)*pageSize,
|
||||
)
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, tenantID)
|
||||
dataSQL := `SELECT id, tenant_id, user_id, user_type, action, resource_type, resource_id, detail, changes_json, ip_address, created_at
|
||||
FROM aud_operation_logs WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
result, err := g.DB().Query(ctx, dataSQL, tenantID, pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
countResult, err := g.DB().Ctx(ctx).Query(ctx, "SELECT COUNT(*) AS total FROM aud_operation_logs WHERE tenant_id = ?", tenantID)
|
||||
countResult, err := g.DB().Query(ctx, "SELECT COUNT(*) AS total FROM aud_operation_logs WHERE tenant_id = ?", tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,7 +203,7 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ
|
||||
fromClause := "aud_request_logs a LEFT JOIN tnt_users t ON a.user_id = t.id AND a.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON a.project_id = p.id"
|
||||
|
||||
countSQL := "SELECT COUNT(*) AS total FROM " + fromClause + " WHERE " + where
|
||||
countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...)
|
||||
countResult, err := g.DB().Query(ctx, countSQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -216,12 +212,10 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ
|
||||
total = countResult[0]["total"].Int()
|
||||
}
|
||||
|
||||
dataSQL := fmt.Sprintf(
|
||||
`SELECT a.id, a.request_id, COALESCE(t.username, '') AS username, a.project_id, COALESCE(p.name, '') AS project_name, a.user_id, a.method, a.path, a.query_params, a.status_code, a.client_ip, a.user_agent, a.latency_ms, a.first_token_ms, a.tenant_audit_level AS audit_level, a.task_id, a.task_status, a.task_completed_at, a.created_at
|
||||
FROM %s WHERE %s ORDER BY a.created_at DESC LIMIT %d OFFSET %d`,
|
||||
fromClause, where, pageSize, (page-1)*pageSize,
|
||||
)
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...)
|
||||
dataSQL := `SELECT a.id, a.request_id, COALESCE(t.username, '') AS username, a.project_id, COALESCE(p.name, '') AS project_name, a.user_id, a.method, a.path, a.query_params, a.status_code, a.client_ip, a.user_agent, a.latency_ms, a.first_token_ms, a.tenant_audit_level AS audit_level, a.task_id, a.task_status, a.task_completed_at, a.created_at
|
||||
FROM ` + fromClause + ` WHERE ` + where + ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`
|
||||
dataArgs := append(args, pageSize, (page-1)*pageSize)
|
||||
result, err := g.DB().Query(ctx, dataSQL, dataArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
)
|
||||
|
||||
// roundUSD 精确到小数点后 6 位,符合 CLAUDE.md 资金精度规范
|
||||
func roundUSD(v float64) float64 {
|
||||
return math.Round(v*1000000) / 1000000
|
||||
}
|
||||
|
||||
// Dashboard returns the tenant dashboard statistics.
|
||||
func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v1.TenantDashboardRes, error) {
|
||||
role := middleware.GetUserRole(ctx)
|
||||
@@ -20,8 +26,9 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
monthStart := time.Now().Format("2006-01") + "-01"
|
||||
now := gtime.Now()
|
||||
today := now.Format("Y-m-d")
|
||||
monthStart := now.Format("Y-m") + "-01"
|
||||
|
||||
// 今日统计
|
||||
type dayStats struct {
|
||||
@@ -31,19 +38,25 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
}
|
||||
var todayRow dayStats
|
||||
dao.BilUsageLogs.Ctx(ctx).
|
||||
err := dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("created_at >= ?", today+" 00:00:00").
|
||||
Fields("COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(total_cost), 0) as total_cost").
|
||||
Scan(&todayRow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 本月统计
|
||||
var monthRow dayStats
|
||||
dao.BilUsageLogs.Ctx(ctx).
|
||||
err = dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("created_at >= ?", monthStart+" 00:00:00").
|
||||
Fields("COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(total_cost), 0) as total_cost").
|
||||
Scan(&monthRow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 钱包余额
|
||||
var wallet *struct {
|
||||
@@ -51,10 +64,13 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
WarningThreshold float64 `json:"warning_threshold"`
|
||||
}
|
||||
dao.BilWallets.Ctx(ctx).
|
||||
err = dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("balance, frozen_balance, warning_threshold").
|
||||
Scan(&wallet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if wallet == nil {
|
||||
wallet = &struct {
|
||||
@@ -65,35 +81,41 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
|
||||
}
|
||||
|
||||
// 活跃Key数
|
||||
activeKeys, _ := dao.ApiKeys.Ctx(ctx).
|
||||
activeKeys, err := dao.ApiKeys.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 成员数
|
||||
memberCount, _ := dao.TntUsers.Ctx(ctx).
|
||||
memberCount, err := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.TenantDashboardRes{
|
||||
Today: map[string]any{
|
||||
"requests": todayRow.Requests,
|
||||
"input_tokens": todayRow.InputTokens,
|
||||
"output_tokens": todayRow.OutputTokens,
|
||||
"total_cost": todayRow.TotalCost,
|
||||
"total_cost": roundUSD(todayRow.TotalCost),
|
||||
},
|
||||
Month: map[string]any{
|
||||
"requests": monthRow.Requests,
|
||||
"input_tokens": monthRow.InputTokens,
|
||||
"output_tokens": monthRow.OutputTokens,
|
||||
"total_cost": monthRow.TotalCost,
|
||||
"total_cost": roundUSD(monthRow.TotalCost),
|
||||
},
|
||||
Wallet: map[string]any{
|
||||
"balance": wallet.Balance,
|
||||
"frozen_balance": wallet.FrozenBalance,
|
||||
"available": wallet.Balance - wallet.FrozenBalance,
|
||||
"warning_threshold": wallet.WarningThreshold,
|
||||
"balance": roundUSD(wallet.Balance),
|
||||
"frozen_balance": roundUSD(wallet.FrozenBalance),
|
||||
"available": roundUSD(wallet.Balance - wallet.FrozenBalance),
|
||||
"warning_threshold": roundUSD(wallet.WarningThreshold),
|
||||
},
|
||||
ActiveKeys: activeKeys,
|
||||
MemberCount: memberCount,
|
||||
@@ -112,7 +134,7 @@ func (s *sTenant) TokenTrends(ctx context.Context, req *v1.TenantTokenTrendsReq)
|
||||
days = 30
|
||||
}
|
||||
|
||||
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
startDate := gtime.Now().AddDate(0, 0, -days).Format("Y-m-d")
|
||||
|
||||
type tokenTrendRow struct {
|
||||
Date string `json:"date"`
|
||||
@@ -149,7 +171,7 @@ func (s *sTenant) TokenTrends(ctx context.Context, req *v1.TenantTokenTrendsReq)
|
||||
"input_tokens": r.InputTokens,
|
||||
"output_tokens": r.OutputTokens,
|
||||
"requests": r.Requests,
|
||||
"total_cost": r.TotalCost,
|
||||
"total_cost": roundUSD(r.TotalCost),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,7 +192,7 @@ func (s *sTenant) ModelDistribution(ctx context.Context, req *v1.TenantModelDist
|
||||
days = 30
|
||||
}
|
||||
|
||||
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
startDate := gtime.Now().AddDate(0, 0, -days).Format("Y-m-d")
|
||||
|
||||
type modelDistRow struct {
|
||||
ModelName string `json:"model_name"`
|
||||
@@ -208,7 +230,7 @@ func (s *sTenant) ModelDistribution(ctx context.Context, req *v1.TenantModelDist
|
||||
"requests": r.Requests,
|
||||
"input_tokens": r.InputTokens,
|
||||
"output_tokens": r.OutputTokens,
|
||||
"total_cost": r.TotalCost,
|
||||
"total_cost": roundUSD(r.TotalCost),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,27 +247,33 @@ func (s *sTenant) BalancePrediction(ctx context.Context, req *v1.TenantBalancePr
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
sevenDaysAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
|
||||
sevenDaysAgo := gtime.Now().AddDate(0, 0, -7).Format("Y-m-d")
|
||||
|
||||
var stats struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
}
|
||||
dao.BilUsageLogs.Ctx(ctx).
|
||||
err := dao.BilUsageLogs.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("created_at >= ?", sevenDaysAgo+" 00:00:00").
|
||||
Fields("COALESCE(SUM(total_cost), 0) as total_cost").
|
||||
Scan(&stats)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dailyAvg := stats.TotalCost / 7.0
|
||||
dailyAvg := roundUSD(stats.TotalCost / 7.0)
|
||||
|
||||
var wallet *struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
}
|
||||
dao.BilWallets.Ctx(ctx).
|
||||
err = dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("balance, frozen_balance").
|
||||
Scan(&wallet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet == nil {
|
||||
wallet = &struct {
|
||||
Balance float64 `json:"balance"`
|
||||
@@ -253,7 +281,7 @@ func (s *sTenant) BalancePrediction(ctx context.Context, req *v1.TenantBalancePr
|
||||
}{Balance: 0, FrozenBalance: 0}
|
||||
}
|
||||
|
||||
available := wallet.Balance - wallet.FrozenBalance
|
||||
available := roundUSD(wallet.Balance - wallet.FrozenBalance)
|
||||
res := &v1.TenantBalancePredictionRes{
|
||||
DailyAvgCost: dailyAvg,
|
||||
AvailableBalance: available,
|
||||
@@ -308,7 +336,7 @@ func (s *sTenant) GetMemberUsageRanking(ctx context.Context, req *v1.TenantMembe
|
||||
limit = 10
|
||||
}
|
||||
|
||||
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
startDate := gtime.Now().AddDate(0, 0, -days).Format("Y-m-d")
|
||||
|
||||
type memberUsageRow struct {
|
||||
UserId int64 `json:"user_id"`
|
||||
@@ -353,7 +381,7 @@ func (s *sTenant) GetMemberUsageRanking(ctx context.Context, req *v1.TenantMembe
|
||||
"requests": r.Requests,
|
||||
"input_tokens": r.InputTokens,
|
||||
"output_tokens": r.OutputTokens,
|
||||
"total_cost": r.TotalCost,
|
||||
"total_cost": roundUSD(r.TotalCost),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
|
||||
// Get old email for notification
|
||||
var user *entity.TntUsers
|
||||
err = dao.TntUsers.Ctx(ctx).
|
||||
Where("id", userID).Scan(&user)
|
||||
Where("id", userID).Where("tenant_id", tenantID).Scan(&user)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *sTenant) CreateFeedback(ctx context.Context, req *v1.FeedbackCreateReq)
|
||||
func (s *sTenant) ListFeedbacks(ctx context.Context, req *v1.FeedbackListReq) (*v1.FeedbackListRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
page, pageSize := normalizePagination(req.Page, req.PageSize)
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
query := dao.SptFeedbacks.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
@@ -78,22 +78,18 @@ func (s *sTenant) GetFeedback(ctx context.Context, req *v1.FeedbackGetReq) (*v1.
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
|
||||
var row v1.FeedbackGetRes
|
||||
var row *v1.FeedbackGetRes
|
||||
err := dao.SptFeedbacks.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("user_id", userID).
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Id == 0 {
|
||||
if row == nil {
|
||||
return nil, common.NewBusinessError(10063, "反馈不存在")
|
||||
}
|
||||
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func normalizePagination(page, pageSize int) (int, int) {
|
||||
return common.NormalizePagination(page, pageSize)
|
||||
return row, nil
|
||||
}
|
||||
+104
-64
@@ -19,6 +19,78 @@ type pricingTierRow struct {
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
}
|
||||
|
||||
// memberModelScopeNoAccess 表示成员无权访问任何模型的哨兵值
|
||||
const memberModelScopeNoAccess = -1
|
||||
|
||||
// tenantModelPriceRow 显式分配模型的价格查询结果
|
||||
type tenantModelPriceRow struct {
|
||||
ModelDBID int64 `json:"model_db_id"`
|
||||
ID int64 `json:"id"`
|
||||
BillingMode *string `json:"billing_mode"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
DiscountRatio *float64 `json:"discount_ratio"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
|
||||
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
CustomInputPrice *float64 `json:"custom_input_price"`
|
||||
CustomOutputPrice *float64 `json:"custom_output_price"`
|
||||
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
|
||||
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
|
||||
CustomPricingTiers string `json:"custom_pricing_tiers"`
|
||||
}
|
||||
|
||||
// groupPriceRow 分组模型的 base 价格查询结果
|
||||
type groupPriceRow struct {
|
||||
ModelID int64 `json:"model_id"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
}
|
||||
|
||||
// baseTierRow 阶梯定价查询结果
|
||||
type baseTierRow struct {
|
||||
ModelId int64 `json:"model_id"`
|
||||
MinTokens int64 `json:"min_tokens"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
}
|
||||
|
||||
// memberScopeRow 成员模型范围查询结果
|
||||
type memberScopeRow struct {
|
||||
ModelID int64 `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
|
||||
// priceInfo 显式模型的完整价格信息
|
||||
type priceInfo struct {
|
||||
ID int64
|
||||
BillingMode *string
|
||||
PerRequestPrice *float64
|
||||
DiscountRatio *float64
|
||||
MaxConcurrency *int
|
||||
BaseInputPrice float64
|
||||
BaseOutputPrice float64
|
||||
BaseCacheReadPrice float64
|
||||
BaseCacheCreationPrice float64
|
||||
BaseBillingMode string
|
||||
CustomInputPrice *float64
|
||||
CustomOutputPrice *float64
|
||||
CustomCacheReadPrice *float64
|
||||
CustomCacheCreationPrice *float64
|
||||
CustomPricingTiers string
|
||||
}
|
||||
|
||||
// groupPriceInfo 分组模型的价格信息
|
||||
type groupPriceInfo struct {
|
||||
BaseBillingMode string
|
||||
BaseInputPrice float64
|
||||
BaseOutputPrice float64
|
||||
}
|
||||
|
||||
// ListAvailableModels 获取租户可用的模型列表
|
||||
func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
@@ -33,24 +105,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
}
|
||||
|
||||
// 查询显式分配模型的完整价格信息
|
||||
var priceResults []struct {
|
||||
ModelDBID int64 `json:"model_db_id"`
|
||||
ID int64 `json:"id"`
|
||||
BillingMode *string `json:"billing_mode"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
DiscountRatio *float64 `json:"discount_ratio"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
|
||||
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
CustomInputPrice *float64 `json:"custom_input_price"`
|
||||
CustomOutputPrice *float64 `json:"custom_output_price"`
|
||||
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
|
||||
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
|
||||
CustomPricingTiers string `json:"custom_pricing_tiers"`
|
||||
}
|
||||
var priceResults []tenantModelPriceRow
|
||||
|
||||
explicitDBIDs := make([]int64, 0)
|
||||
for _, m := range models {
|
||||
@@ -60,32 +115,18 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
}
|
||||
|
||||
if len(explicitDBIDs) > 0 {
|
||||
_ = dao.MdlTenantModels.Ctx(ctx).
|
||||
err = dao.MdlTenantModels.Ctx(ctx).
|
||||
LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0").
|
||||
Where("mdl_tenant_models.tenant_id", tenantID).
|
||||
WhereIn("mdl_tenant_models.model_id", explicitDBIDs).
|
||||
Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers").
|
||||
Scan(&priceResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 构建显式模型价格映射
|
||||
type priceInfo struct {
|
||||
ID int64
|
||||
BillingMode *string
|
||||
PerRequestPrice *float64
|
||||
DiscountRatio *float64
|
||||
MaxConcurrency *int
|
||||
BaseInputPrice float64
|
||||
BaseOutputPrice float64
|
||||
BaseCacheReadPrice float64
|
||||
BaseCacheCreationPrice float64
|
||||
BaseBillingMode string
|
||||
CustomInputPrice *float64
|
||||
CustomOutputPrice *float64
|
||||
CustomCacheReadPrice *float64
|
||||
CustomCacheCreationPrice *float64
|
||||
CustomPricingTiers string
|
||||
}
|
||||
priceMap := make(map[int64]*priceInfo, len(priceResults))
|
||||
for _, r := range priceResults {
|
||||
priceMap[r.ModelDBID] = &priceInfo{
|
||||
@@ -115,24 +156,17 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
}
|
||||
}
|
||||
|
||||
type groupPriceInfo struct {
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
}
|
||||
groupPriceMap := make(map[int64]*groupPriceInfo)
|
||||
if len(groupDBIDs) > 0 {
|
||||
var groupPrices []struct {
|
||||
ModelID int64 `json:"model_id"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
}
|
||||
_ = dao.MdlPricing.Ctx(ctx).
|
||||
var groupPrices []groupPriceRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
WhereIn("model_id", groupDBIDs).
|
||||
Where("min_tokens", 0).
|
||||
Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price").
|
||||
Scan(&groupPrices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, gp := range groupPrices {
|
||||
groupPriceMap[gp.ModelID] = &groupPriceInfo{
|
||||
@@ -158,14 +192,8 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
|
||||
baseTiersMap := make(map[int64][]v1.PricingTierItem)
|
||||
if len(tieredModelDBIDs) > 0 {
|
||||
var baseTiers []struct {
|
||||
ModelId int64 `json:"model_id"`
|
||||
MinTokens int64 `json:"min_tokens"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
}
|
||||
_ = dao.MdlPricing.Ctx(ctx).
|
||||
var baseTiers []baseTierRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
WhereIn("model_id", tieredModelDBIDs).
|
||||
Where("billing_mode", "tiered").
|
||||
Where("min_tokens > 0").
|
||||
@@ -173,6 +201,9 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
OrderAsc("model_id").
|
||||
OrderAsc("min_tokens").
|
||||
Scan(&baseTiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, t := range baseTiers {
|
||||
baseTiersMap[t.ModelId] = append(baseTiersMap[t.ModelId], v1.PricingTierItem{
|
||||
@@ -288,23 +319,32 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
// 按成员模型范围过滤
|
||||
userID := middleware.GetUserID(ctx)
|
||||
if userID > 0 {
|
||||
var memberScopes []struct {
|
||||
ModelID int64 `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
_ = g.DB().Model("tnt_member_model_scopes ms").Ctx(ctx).
|
||||
var memberScopes []memberScopeRow
|
||||
err = g.DB().Model("tnt_member_model_scopes ms").Ctx(ctx).
|
||||
LeftJoin("mdl_models m ON ms.model_id = m.id").
|
||||
Where("ms.tenant_id", tenantID).
|
||||
Where("ms.user_id", userID).
|
||||
Fields("ms.model_id, m.model_id as model_name").
|
||||
Scan(&memberScopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(memberScopes) > 0 {
|
||||
// 检查是否存在"无权访问任何模型"的哨兵值
|
||||
hasNoAccess := false
|
||||
for _, s := range memberScopes {
|
||||
if s.ModelID == memberModelScopeNoAccess {
|
||||
hasNoAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasNoAccess {
|
||||
return &v1.TenantAvailableModelsRes{List: nil}, nil
|
||||
}
|
||||
|
||||
allowed := make(map[string]bool, len(memberScopes))
|
||||
for _, s := range memberScopes {
|
||||
if s.ModelID == -1 {
|
||||
return &v1.TenantAvailableModelsRes{List: nil}, nil
|
||||
}
|
||||
if s.ModelName != "" {
|
||||
allowed[s.ModelName] = true
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -25,35 +27,33 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
models[i] = strings.TrimSpace(m)
|
||||
}
|
||||
if len(models) < 2 || len(models) > 4 {
|
||||
return nil, fmt.Errorf("请选择 2-4 个模型进行对比")
|
||||
return nil, common.NewBadRequestError("请选择 2-4 个模型进行对比")
|
||||
}
|
||||
|
||||
since := gtime.Now().AddDate(0, 0, -req.Days).Format("Y-m-d")
|
||||
|
||||
// Build model placeholders
|
||||
placeholders := make([]string, len(models))
|
||||
// Build args for parameterized query
|
||||
args := []any{tenantID, since}
|
||||
for i, m := range models {
|
||||
placeholders[i] = "?"
|
||||
for _, m := range models {
|
||||
args = append(args, m)
|
||||
}
|
||||
modelIn := strings.Join(placeholders, ",")
|
||||
|
||||
// Aggregate per-model stats
|
||||
aggQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
model_name,
|
||||
COUNT(*) as requests,
|
||||
COUNT(*) FILTER (WHERE status = 'success') as success_count,
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as avg_latency,
|
||||
COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) FILTER (WHERE status = 'success'), 0) as p95_latency,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (%s)
|
||||
GROUP BY model_name
|
||||
`, modelIn)
|
||||
// Aggregate per-model stats (parameterized IN clause, no fmt.Sprintf)
|
||||
inClause := strings.Repeat("?,", len(models)-1) + "?"
|
||||
aggQuery := `
|
||||
SELECT
|
||||
model_name,
|
||||
COUNT(*) as requests,
|
||||
COUNT(*) FILTER (WHERE status = 'success') as success_count,
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as avg_latency,
|
||||
COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) FILTER (WHERE status = 'success'), 0) as p95_latency,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
|
||||
GROUP BY model_name
|
||||
`
|
||||
|
||||
records, err := g.DB().Raw(aggQuery, args...).All()
|
||||
if err != nil {
|
||||
@@ -133,7 +133,7 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
}
|
||||
|
||||
// Daily trends
|
||||
trends := fetchTrends(ctx, tenantID, since, models, modelIn, args)
|
||||
trends := fetchTrends(ctx, tenantID, since, models, args)
|
||||
|
||||
return &v1.ModelComparisonRes{
|
||||
Summary: v1.ModelComparisonSummary{
|
||||
@@ -209,19 +209,20 @@ func recommendBest(items []v1.ModelComparisonItem) (string, string) {
|
||||
return best, fmt.Sprintf("综合评分 %.1f(费用40%% + 延迟30%% + 成功率30%%)", bestScore)
|
||||
}
|
||||
|
||||
func fetchTrends(ctx context.Context, tenantID int64, since string, models []string, modelIn string, baseArgs []any) []v1.ModelTrendDay {
|
||||
trendQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
DATE(created_at) as day,
|
||||
model_name,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as latency
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (%s)
|
||||
GROUP BY DATE(created_at), model_name
|
||||
ORDER BY day
|
||||
`, modelIn)
|
||||
func fetchTrends(ctx context.Context, tenantID int64, since string, models []string, baseArgs []any) []v1.ModelTrendDay {
|
||||
inClause := strings.Repeat("?,", len(models)-1) + "?"
|
||||
trendQuery := `
|
||||
SELECT
|
||||
DATE(created_at) as day,
|
||||
model_name,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as latency
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
|
||||
GROUP BY DATE(created_at), model_name
|
||||
ORDER BY day
|
||||
`
|
||||
|
||||
records, err := g.DB().Raw(trendQuery, baseArgs...).All()
|
||||
if err != nil {
|
||||
@@ -240,11 +241,18 @@ func fetchTrends(ctx context.Context, tenantID int64, since string, models []str
|
||||
})
|
||||
}
|
||||
|
||||
var trends []v1.ModelTrendDay
|
||||
for day, details := range dayMap {
|
||||
// Sort days for deterministic output
|
||||
days := make([]string, 0, len(dayMap))
|
||||
for day := range dayMap {
|
||||
days = append(days, day)
|
||||
}
|
||||
sort.Strings(days)
|
||||
|
||||
trends := make([]v1.ModelTrendDay, 0, len(days))
|
||||
for _, day := range days {
|
||||
trends = append(trends, v1.ModelTrendDay{
|
||||
Date: day,
|
||||
Details: details,
|
||||
Details: dayMap[day],
|
||||
})
|
||||
}
|
||||
return trends
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -44,17 +43,36 @@ func (s *sTenant) Notifications(ctx context.Context, req *v1.TenantNotifications
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Enrich items with read status for broadcasts
|
||||
for i, item := range items {
|
||||
// Enrich items with read status for broadcasts (batch query to avoid N+1)
|
||||
broadcastIDs := make([]int64, 0)
|
||||
for _, item := range items {
|
||||
if item.IsBroadcast == 1 {
|
||||
readCount, _ := dao.NtfReadStatus.Ctx(ctx).
|
||||
Where("message_id", item.Id).
|
||||
Where("user_id", userID).
|
||||
Count()
|
||||
if readCount > 0 {
|
||||
items[i].IsRead = 1
|
||||
} else {
|
||||
items[i].IsRead = 0
|
||||
broadcastIDs = append(broadcastIDs, item.Id)
|
||||
}
|
||||
}
|
||||
if len(broadcastIDs) > 0 {
|
||||
readStatuses := make([]struct {
|
||||
MessageId int64 `json:"message_id"`
|
||||
}, 0)
|
||||
err = dao.NtfReadStatus.Ctx(ctx).
|
||||
Where("user_id", userID).
|
||||
WhereIn("message_id", broadcastIDs).
|
||||
Fields("message_id").
|
||||
Scan(&readStatuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readMap := make(map[int64]bool, len(readStatuses))
|
||||
for _, rs := range readStatuses {
|
||||
readMap[rs.MessageId] = true
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.IsBroadcast == 1 {
|
||||
if readMap[item.Id] {
|
||||
items[i].IsRead = 1
|
||||
} else {
|
||||
items[i].IsRead = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +114,7 @@ func (s *sTenant) UnreadCount(ctx context.Context, req *v1.TenantUnreadCountReq)
|
||||
).
|
||||
Count()
|
||||
if err != nil {
|
||||
broadcastUnread = 0
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalUnread := personalUnread + broadcastUnread
|
||||
@@ -213,13 +231,12 @@ func (s *sTenant) MarkAllRead(ctx context.Context, req *v1.TenantMarkAllReadReq)
|
||||
|
||||
// Batch insert read_status records
|
||||
if len(broadcastIDs) > 0 {
|
||||
now := time.Now()
|
||||
batch := make([]do.NtfReadStatus, 0, len(broadcastIDs))
|
||||
for _, b := range broadcastIDs {
|
||||
batch = append(batch, do.NtfReadStatus{
|
||||
MessageId: b.ID,
|
||||
UserId: userID,
|
||||
ReadAt: gtime.NewFromTime(now),
|
||||
ReadAt: gtime.Now(),
|
||||
})
|
||||
}
|
||||
_, err = dao.NtfReadStatus.Ctx(ctx).Batch(len(batch)).Data(batch).Insert()
|
||||
@@ -320,7 +337,10 @@ func (s *sTenant) NotificationPreferencesUpdate(ctx context.Context, req *v1.Ten
|
||||
} else {
|
||||
query = query.Where("user_id IS NULL")
|
||||
}
|
||||
query.Fields("id").Scan(&existing)
|
||||
err = query.Fields("id").Scan(&existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existing != nil && existing.ID > 0 {
|
||||
// Update existing
|
||||
@@ -377,7 +397,7 @@ func loadPreferences(ctx context.Context, tenantID, userID int64, scope string)
|
||||
type prefRow struct {
|
||||
Preferences string `json:"preferences"`
|
||||
}
|
||||
var row prefRow
|
||||
var row *prefRow
|
||||
query := dao.NtfPreferences.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("scope", scope)
|
||||
@@ -386,9 +406,11 @@ func loadPreferences(ctx context.Context, tenantID, userID int64, scope string)
|
||||
} else {
|
||||
query = query.Where("user_id IS NULL")
|
||||
}
|
||||
query.Fields("preferences").Scan(&row)
|
||||
if err := query.Fields("preferences").Scan(&row); err != nil {
|
||||
return getDefaultPreferences()
|
||||
}
|
||||
|
||||
if row.Preferences == "" || row.Preferences == "{}" {
|
||||
if row == nil || row.Preferences == "" || row.Preferences == "{}" {
|
||||
return getDefaultPreferences()
|
||||
}
|
||||
|
||||
@@ -438,8 +460,8 @@ func mergePreferences(org, user map[string]any) map[string]any {
|
||||
if orgMap, ok := orgVal.(map[string]any); ok {
|
||||
// Merge the nested maps
|
||||
combined := make(map[string]any)
|
||||
for ok, ov := range orgMap {
|
||||
combined[ok] = ov
|
||||
for orgKey, orgValItem := range orgMap {
|
||||
combined[orgKey] = orgValItem
|
||||
}
|
||||
for uk, uv := range userMap {
|
||||
combined[uk] = uv
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
@@ -164,12 +165,10 @@ func (s *sTenant) PersonalDashboard(ctx context.Context, req *v1.PersonalDashboa
|
||||
QuotaResetAt string `json:"quota_reset_at"`
|
||||
}
|
||||
var qRow *quotaRow
|
||||
err = g.DB().Ctx(ctx).Raw(`
|
||||
SELECT quota_type, COALESCE(quota_limit, 0) as quota_limit,
|
||||
COALESCE(quota_used, 0) as quota_used, quota_period,
|
||||
COALESCE(TO_CHAR(quota_reset_at, 'YYYY-MM-DD HH24:MI:SS'), '') as quota_reset_at
|
||||
FROM tnt_users WHERE id = ? AND tenant_id = ?
|
||||
`, userID, tenantID).Scan(&qRow)
|
||||
err = dao.TntUsers.Ctx(ctx).
|
||||
Where("id", userID).Where("tenant_id", tenantID).
|
||||
Fields("quota_type, COALESCE(quota_limit, 0) as quota_limit, COALESCE(quota_used, 0) as quota_used, quota_period, COALESCE(TO_CHAR(quota_reset_at, 'YYYY-MM-DD HH24:MI:SS'), '') as quota_reset_at").
|
||||
Scan(&qRow)
|
||||
if err == nil && qRow != nil && qRow.QuotaType != "" && qRow.QuotaType != "none" {
|
||||
q := &v1.PersonalQuotaStatus{
|
||||
QuotaType: qRow.QuotaType,
|
||||
|
||||
@@ -54,8 +54,8 @@ func findActiveApiKey(ctx context.Context) (*playgroundApiKey, error) {
|
||||
|
||||
func buildPlaygroundRelayContext(ctx context.Context, key *playgroundApiKey, recorder http.ResponseWriter) *handler.RelayContext {
|
||||
requestID := ""
|
||||
if v := ctx.Value("requestId"); v != nil {
|
||||
requestID = v.(string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
requestID = r.GetCtxVar("RequestId").String()
|
||||
}
|
||||
return &handler.RelayContext{
|
||||
TenantID: middleware.GetTenantID(ctx),
|
||||
@@ -106,8 +106,8 @@ func convertRelayError(err error) error {
|
||||
}
|
||||
|
||||
func getRequestID(ctx context.Context) string {
|
||||
if v := ctx.Value("requestId"); v != nil {
|
||||
return v.(string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
return r.GetCtxVar("RequestId").String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ func (s *sTenant) TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq)
|
||||
}
|
||||
|
||||
var total int
|
||||
err := m.Page(req.Page, req.PageSize).ScanAndCount(&tasks, &total, false)
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
err := m.Page(page, pageSize).ScanAndCount(&tasks, &total, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user