From 573e9ad21b3561861da1edec797a426051e720dc Mon Sep 17 00:00:00 2001 From: qian Date: Wed, 3 Jun 2026 08:48:26 +0800 Subject: [PATCH] =?UTF-8?q?fi=EF=BC=9A=E4=BF=AE=E5=A4=8D=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/common/audit.go | 4 - internal/logic/common/batch_writer.go | 7 +- internal/logic/common/config.go | 17 +- internal/logic/common/feature_flag.go | 10 +- internal/logic/common/jwt.go | 4 +- internal/logic/common/oauth/client.go | 27 +++- internal/logic/common/oauth/gemini.go | 5 +- internal/logic/common/oauth/openai.go | 9 +- internal/logic/common/partition.go | 3 + internal/logic/common/pricing_source.go | 4 +- internal/logic/common/security.go | 28 +++- internal/logic/common/storage.go | 14 +- internal/logic/common/verify_code.go | 19 +-- internal/logic/tenant/audit_config.go | 24 ++- internal/logic/tenant/dashboard.go | 80 +++++++--- internal/logic/tenant/email.go | 2 +- internal/logic/tenant/feedback.go | 14 +- internal/logic/tenant/model.go | 168 ++++++++++++-------- internal/logic/tenant/model_comparison.go | 84 +++++----- internal/logic/tenant/notification.go | 62 +++++--- internal/logic/tenant/personal_dashboard.go | 11 +- internal/logic/tenant/playground.go | 8 +- internal/logic/tenant/task.go | 3 +- 23 files changed, 376 insertions(+), 231 deletions(-) diff --git a/internal/logic/common/audit.go b/internal/logic/common/audit.go index 51f5138..7f43873 100644 --- a/internal/logic/common/audit.go +++ b/internal/logic/common/audit.go @@ -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 diff --git a/internal/logic/common/batch_writer.go b/internal/logic/common/batch_writer.go index d444a45..e2c264f 100644 --- a/internal/logic/common/batch_writer.go +++ b/internal/logic/common/batch_writer.go @@ -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) } diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go index e154615..9a92eab 100644 --- a/internal/logic/common/config.go +++ b/internal/logic/common/config.go @@ -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. diff --git a/internal/logic/common/feature_flag.go b/internal/logic/common/feature_flag.go index 81ea1a5..b74585c 100644 --- a/internal/logic/common/feature_flag.go +++ b/internal/logic/common/feature_flag.go @@ -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 { diff --git a/internal/logic/common/jwt.go b/internal/logic/common/jwt.go index 6e73af5..eee9f3a 100644 --- a/internal/logic/common/jwt.go +++ b/internal/logic/common/jwt.go @@ -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 } diff --git a/internal/logic/common/oauth/client.go b/internal/logic/common/oauth/client.go index ad00f01..3b933cb 100644 --- a/internal/logic/common/oauth/client.go +++ b/internal/logic/common/oauth/client.go @@ -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() diff --git a/internal/logic/common/oauth/gemini.go b/internal/logic/common/oauth/gemini.go index 1c4a362..98d3e27 100644 --- a/internal/logic/common/oauth/gemini.go +++ b/internal/logic/common/oauth/gemini.go @@ -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) } // 计算过期时间,带安全窗口 diff --git a/internal/logic/common/oauth/openai.go b/internal/logic/common/oauth/openai.go index 22dbf6b..e6cefb8 100644 --- a/internal/logic/common/oauth/openai.go +++ b/internal/logic/common/oauth/openai.go @@ -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{ diff --git a/internal/logic/common/partition.go b/internal/logic/common/partition.go index cab09d1..af26642 100644 --- a/internal/logic/common/partition.go +++ b/internal/logic/common/partition.go @@ -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, diff --git a/internal/logic/common/pricing_source.go b/internal/logic/common/pricing_source.go index 11bb820..85ff479 100644 --- a/internal/logic/common/pricing_source.go +++ b/internal/logic/common/pricing_source.go @@ -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)) diff --git a/internal/logic/common/security.go b/internal/logic/common/security.go index 9860201..7c2e31f 100644 --- a/internal/logic/common/security.go +++ b/internal/logic/common/security.go @@ -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) } diff --git a/internal/logic/common/storage.go b/internal/logic/common/storage.go index 50e2bb1..ea5ed88 100644 --- a/internal/logic/common/storage.go +++ b/internal/logic/common/storage.go @@ -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) } diff --git a/internal/logic/common/verify_code.go b/internal/logic/common/verify_code.go index 17ae1ae..6a8ce27 100644 --- a/internal/logic/common/verify_code.go +++ b/internal/logic/common/verify_code.go @@ -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, "验证码错误") } diff --git a/internal/logic/tenant/audit_config.go b/internal/logic/tenant/audit_config.go index 5c9d1be..672e064 100644 --- a/internal/logic/tenant/audit_config.go +++ b/internal/logic/tenant/audit_config.go @@ -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 } diff --git a/internal/logic/tenant/dashboard.go b/internal/logic/tenant/dashboard.go index c39de0e..3366938 100644 --- a/internal/logic/tenant/dashboard.go +++ b/internal/logic/tenant/dashboard.go @@ -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), }) } diff --git a/internal/logic/tenant/email.go b/internal/logic/tenant/email.go index 874858a..f56eff2 100644 --- a/internal/logic/tenant/email.go +++ b/internal/logic/tenant/email.go @@ -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 } diff --git a/internal/logic/tenant/feedback.go b/internal/logic/tenant/feedback.go index 21bda28..cf59cdb 100644 --- a/internal/logic/tenant/feedback.go +++ b/internal/logic/tenant/feedback.go @@ -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 } diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index d59a65b..4aa9a8f 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -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 } diff --git a/internal/logic/tenant/model_comparison.go b/internal/logic/tenant/model_comparison.go index a4a6abf..7605b26 100644 --- a/internal/logic/tenant/model_comparison.go +++ b/internal/logic/tenant/model_comparison.go @@ -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 diff --git a/internal/logic/tenant/notification.go b/internal/logic/tenant/notification.go index 6f1d8a9..cdcc712 100644 --- a/internal/logic/tenant/notification.go +++ b/internal/logic/tenant/notification.go @@ -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 diff --git a/internal/logic/tenant/personal_dashboard.go b/internal/logic/tenant/personal_dashboard.go index a3a0fc3..026bab8 100644 --- a/internal/logic/tenant/personal_dashboard.go +++ b/internal/logic/tenant/personal_dashboard.go @@ -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, diff --git a/internal/logic/tenant/playground.go b/internal/logic/tenant/playground.go index 27e0195..3db139c 100644 --- a/internal/logic/tenant/playground.go +++ b/internal/logic/tenant/playground.go @@ -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 "" } diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index 95946cb..28e1600 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -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 }