From e9ead5dd8a32f2e8c5bbd3c1e9aefff87397ad00 Mon Sep 17 00:00:00 2001 From: qian Date: Wed, 3 Jun 2026 00:43:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(tenant):=E4=BF=AE=E5=A4=8D=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E7=9B=B8=E5=85=B3bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/tenant/auth.go | 42 +++++--- internal/logic/tenant/invitation.go | 11 ++- internal/logic/tenant/lifecycle.go | 12 ++- internal/logic/tenant/member.go | 23 ++++- internal/logic/tenant/member_import.go | 100 ++++++++++++++------ internal/logic/tenant/member_model_scope.go | 34 ++++++- internal/logic/tenant/member_quota.go | 23 ++--- internal/logic/tenant/oauth.go | 100 ++++++++++++++------ internal/logic/tenant/organization.go | 39 +++++--- internal/logic/tenant/project.go | 51 +++++++--- 10 files changed, 315 insertions(+), 120 deletions(-) diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 6656bbd..bf4f6f7 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - do "github.com/qianfree/team-api/internal/model/do" "strings" "time" @@ -20,6 +19,8 @@ import ( "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/model/entity" "github.com/qianfree/team-api/internal/utility/crypto" + + do "github.com/qianfree/team-api/internal/model/do" ) // Register handles tenant registration. @@ -279,35 +280,44 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant if !crypto.VerifyPassword(req.Password, user.PasswordHash) { _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "密码错误") - // Increment failed attempts + // 原子递增失败次数 + updateData := do.TntUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")} failedAttempts := user.FailedAttempts + 1 - updateData := do.TntUsers{FailedAttempts: failedAttempts} if failedAttempts >= 5 { lockedUntil := time.Now().Add(30 * time.Minute) updateData.LockedUntil = gtime.NewFromTime(lockedUntil) - dao.TntUsers.Ctx(ctx). + _, err := dao.TntUsers.Ctx(ctx). Where("id", user.Id). Data(updateData).Update() + if err != nil { + g.Log().Errorf(ctx, "更新账号锁定状态失败: %v", err) + } return nil, common.NewBusinessError(consts.CodeAccountLocked, "连续 5 次密码错误,账号已锁定 30 分钟") } - dao.TntUsers.Ctx(ctx). + _, err := dao.TntUsers.Ctx(ctx). Where("id", user.Id). Data(updateData).Update() + if err != nil { + g.Log().Errorf(ctx, "更新登录失败次数失败: %v", err) + } return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } // Reset failed attempts on successful login if user.FailedAttempts > 0 { - dao.TntUsers.Ctx(ctx). + _, err := dao.TntUsers.Ctx(ctx). Where("id", user.Id). Data(do.TntUsers{ FailedAttempts: 0, LockedUntil: nil, }).Update() + if err != nil { + g.Log().Errorf(ctx, "重置登录失败次数失败: %v", err) + } } // Check if 2FA is enabled → return provisional token @@ -353,12 +363,15 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant } // Update last login - dao.TntUsers.Ctx(ctx). + _, err = dao.TntUsers.Ctx(ctx). Where("id", user.Id). Data(do.TntUsers{ LastLoginAt: gtime.Now(), LastLoginIp: ipAddress, }).Update() + if err != nil { + g.Log().Errorf(ctx, "更新最后登录时间失败: %v", err) + } _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, true, "") @@ -521,16 +534,23 @@ func (s *sTenant) ListSessions(ctx context.Context, req *v1.TenantSessionListReq return &v1.TenantSessionListRes{List: items}, nil } -// RevokeSession revokes a specific session. +// RevokeSession revokes a specific session (only own sessions). func (s *sTenant) RevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (*v1.TenantRevokeSessionRes, error) { - // Look up session to get jti for Redis revocation + userID := middleware.GetUserID(ctx) + + // 查找 session 并验证归属 sess, err := common.GetSessionByID(ctx, req.Id) if err != nil { return nil, err } - if sess != nil { - common.MarkSessionRevoked(ctx, sess.Jti) + if sess == nil { + return nil, common.NewNotFoundError("会话") } + if sess.UserId != userID { + return nil, common.NewForbiddenError("只能撤销自己的会话") + } + + common.MarkSessionRevoked(ctx, sess.Jti) err = common.RevokeSession(ctx, req.Id) if err != nil { return nil, err diff --git a/internal/logic/tenant/invitation.go b/internal/logic/tenant/invitation.go index 7cf65aa..e95848b 100644 --- a/internal/logic/tenant/invitation.go +++ b/internal/logic/tenant/invitation.go @@ -10,6 +10,8 @@ import ( "github.com/qianfree/team-api/internal/middleware" "github.com/gogf/gf/v2/os/gtime" + + do "github.com/qianfree/team-api/internal/model/do" ) // InvitationList returns a paginated list of invitation records for the tenant. @@ -103,6 +105,11 @@ func (s *sTenant) InvitationList(ctx context.Context, req *v1.TenantInvitationLi // RevokeInvitation revokes a pending invitation by setting used_by_user_id = -1. func (s *sTenant) RevokeInvitation(ctx context.Context, req *v1.TenantInvitationRevokeReq) (*v1.TenantInvitationRevokeRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) var inv *struct { @@ -126,7 +133,9 @@ func (s *sTenant) RevokeInvitation(ctx context.Context, req *v1.TenantInvitation _, err = dao.TntInvitations.Ctx(ctx). Where("id", req.Id). Where("tenant_id", tenantID). - Data("used_by_user_id", -1). + Data(do.TntInvitations{ + UsedByUserId: -1, + }). Update() if err != nil { return nil, err diff --git a/internal/logic/tenant/lifecycle.go b/internal/logic/tenant/lifecycle.go index fb08b21..45d2360 100644 --- a/internal/logic/tenant/lifecycle.go +++ b/internal/logic/tenant/lifecycle.go @@ -2,7 +2,6 @@ package tenant import ( "context" - "github.com/qianfree/team-api/internal/dao" "time" do "github.com/qianfree/team-api/internal/model/do" @@ -11,6 +10,7 @@ import ( "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/middleware" ) @@ -74,8 +74,11 @@ func TransitionTenantStatus(ctx context.Context, tenantID int64, newStatus strin return err } -// RequestClosure 申请关户 +// RequestClosure 申请关户(仅 owner) func (s *sTenant) RequestClosure(ctx context.Context, req *v1.TenantRequestClosureReq) (*v1.TenantRequestClosureRes, error) { + if err := ownerOnly(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := TransitionTenantStatus(ctx, tenantID, "closing"); err != nil { return nil, err @@ -83,8 +86,11 @@ func (s *sTenant) RequestClosure(ctx context.Context, req *v1.TenantRequestClosu return &v1.TenantRequestClosureRes{}, nil } -// CancelClosure 取消关户 +// CancelClosure 取消关户(仅 owner) func (s *sTenant) CancelClosure(ctx context.Context, req *v1.TenantCancelClosureReq) (*v1.TenantCancelClosureRes, error) { + if err := ownerOnly(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := TransitionTenantStatus(ctx, tenantID, "active"); err != nil { return nil, err diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 7963dce..a1a7cc4 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "encoding/hex" "fmt" - do "github.com/qianfree/team-api/internal/model/do" "strings" "time" @@ -22,6 +21,8 @@ import ( "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/utility/crypto" "github.com/qianfree/team-api/internal/utility/export" + + do "github.com/qianfree/team-api/internal/model/do" ) // ListMembers returns a paginated list of tenant members. @@ -506,18 +507,24 @@ func (s *sTenant) RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveRe common.RevokeAllSessions(ctx, "tenant", memberID) // Revoke all API keys for this user - dao.ApiKeys.Ctx(ctx). + _, err = dao.ApiKeys.Ctx(ctx). Where("tenant_id", tenantID). Where("user_id", memberID). Data(do.ApiKeys{ Status: "revoked", }).Update() + if err != nil { + return nil, err + } // Remove member model scopes - dao.TntMemberModelScopes.Ctx(ctx). + _, err = dao.TntMemberModelScopes.Ctx(ctx). Where("tenant_id", tenantID). Where("user_id", memberID). Delete() + if err != nil { + return nil, err + } // Anonymize personal data _, err = dao.TntUsers.Ctx(ctx). @@ -560,13 +567,16 @@ func (s *sTenant) DisableMember(ctx context.Context, tenantID, userID int64) err } // Revoke all active API keys - dao.ApiKeys.Ctx(ctx). + _, err = dao.ApiKeys.Ctx(ctx). Where("tenant_id", tenantID). Where("user_id", userID). Where("status", "active"). Data(do.ApiKeys{ Status: "disabled", }).Update() + if err != nil { + return err + } _, err = dao.TntUsers.Ctx(ctx). Where("id", userID). @@ -598,13 +608,16 @@ func (s *sTenant) EnableMember(ctx context.Context, tenantID, userID int64) erro } // Restore disabled API keys - dao.ApiKeys.Ctx(ctx). + _, err = dao.ApiKeys.Ctx(ctx). Where("tenant_id", tenantID). Where("user_id", userID). Where("status", "disabled"). Data(do.ApiKeys{ Status: "active", }).Update() + if err != nil { + return err + } _, err = dao.TntUsers.Ctx(ctx). Where("id", userID). diff --git a/internal/logic/tenant/member_import.go b/internal/logic/tenant/member_import.go index e157272..621d8dc 100644 --- a/internal/logic/tenant/member_import.go +++ b/internal/logic/tenant/member_import.go @@ -6,8 +6,6 @@ import ( "encoding/csv" "encoding/hex" "encoding/json" - "github.com/qianfree/team-api/internal/dao" - do "github.com/qianfree/team-api/internal/model/do" "io" "strings" @@ -15,18 +13,22 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/utility/crypto" v1 "github.com/qianfree/team-api/api/tenant/v1" + do "github.com/qianfree/team-api/internal/model/do" ) // ImportResult represents the result of a single row import. type ImportResult struct { Row int `json:"row"` Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` Status string `json:"status"` // success, fail, skip Error string `json:"error,omitempty"` } @@ -263,36 +265,69 @@ func startImport(ctx context.Context, tenantID, creatorID int64, filename string result.Status = "pending" result.Username = username + result.Email = email + result.Role = role results = append(results, result) } - // Check against existing members + // Batch check existing members (avoid N+1) + pendingUsernames := make([]string, 0) + pendingEmails := make([]string, 0) + for _, r := range results { + if r.Status == "pending" { + pendingUsernames = append(pendingUsernames, r.Username) + if r.Email != "" { + pendingEmails = append(pendingEmails, strings.ToLower(r.Email)) + } + } + } + + existingUsernames := make(map[string]bool) + if len(pendingUsernames) > 0 { + var existing []struct { + Username string `json:"username"` + } + err := dao.TntUsers.Ctx(ctx). + Where("tenant_id", tenantID). + WhereIn("username", pendingUsernames). + Fields("username"). + Scan(&existing) + if err == nil { + for _, e := range existing { + existingUsernames[strings.ToLower(e.Username)] = true + } + } + } + + existingEmails := make(map[string]bool) + if len(pendingEmails) > 0 { + var existing []struct { + Email string `json:"email"` + } + err := dao.TntUsers.Ctx(ctx). + Where("tenant_id", tenantID). + WhereIn("email", pendingEmails). + Fields("email"). + Scan(&existing) + if err == nil { + for _, e := range existing { + existingEmails[strings.ToLower(e.Email)] = true + } + } + } + for i := range results { if results[i].Status != "pending" { continue } - username := results[i].Username - email := getCSVField(rows[results[i].Row-1], headerMap, "email") - - count, _ := dao.TntUsers.Ctx(ctx). - Where("tenant_id", tenantID). - Where("username", username). - Count() - if count > 0 { + if existingUsernames[strings.ToLower(results[i].Username)] { results[i].Status = "skip" results[i].Error = "用户名已存在" continue } - - if email != "" { - count, _ = dao.TntUsers.Ctx(ctx). - Where("tenant_id", tenantID). - Where("email", strings.ToLower(email)). - Count() - if count > 0 { - results[i].Status = "skip" - results[i].Error = "邮箱已存在" - } + if results[i].Email != "" && existingEmails[strings.ToLower(results[i].Email)] { + results[i].Status = "skip" + results[i].Error = "邮箱已存在" } } @@ -341,7 +376,7 @@ func startImport(ctx context.Context, tenantID, creatorID int64, filename string } // processImport executes the actual import for pending rows. -func processImport(ctx context.Context, importID int64) error { +func processImport(ctx context.Context, tenantID, importID int64) error { var record *struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` @@ -351,8 +386,9 @@ func processImport(ctx context.Context, importID int64) error { } err := dao.TntMemberImports.Ctx(ctx). Where("id", importID). + Where("tenant_id", tenantID). Scan(&record) - if err != nil || record.ID == 0 { + if err != nil || record == nil { return common.NewNotFoundError("导入记录") } if record.Status != "pending" { @@ -360,11 +396,14 @@ func processImport(ctx context.Context, importID int64) error { } // Mark as processing - dao.TntMemberImports.Ctx(ctx). + _, err = dao.TntMemberImports.Ctx(ctx). Where("id", importID). Data(do.TntMemberImports{ Status: "processing", }).Update() + if err != nil { + return gerror.Wrapf(err, "标记导入记录状态失败") + } var results []ImportResult if err := json.Unmarshal([]byte(record.ResultJSON), &results); err != nil { @@ -396,14 +435,18 @@ func processImport(ctx context.Context, importID int64) error { } displayName := results[i].Username + role := results[i].Role + if role == "" { + role = "member" + } _, err = dao.TntUsers.Ctx(ctx).Data(do.TntUsers{ TenantId: record.TenantID, Username: results[i].Username, - Email: "", + Email: results[i].Email, PasswordHash: passwordHash, DisplayName: displayName, - Role: "member", + Role: role, Status: "active", }).Insert() if err != nil { @@ -419,7 +462,7 @@ func processImport(ctx context.Context, importID int64) error { // Update import record updatedJSON, _ := json.Marshal(results) - dao.TntMemberImports.Ctx(ctx). + _, err = dao.TntMemberImports.Ctx(ctx). Where("id", importID). Data(do.TntMemberImports{ SuccessCount: successCount, @@ -428,6 +471,9 @@ func processImport(ctx context.Context, importID int64) error { Status: "completed", ResultJson: string(updatedJSON), }).Update() + if err != nil { + g.Log().Errorf(ctx, "更新导入记录最终结果失败: %v", err) + } return nil } diff --git a/internal/logic/tenant/member_model_scope.go b/internal/logic/tenant/member_model_scope.go index a6e61f2..807aeca 100644 --- a/internal/logic/tenant/member_model_scope.go +++ b/internal/logic/tenant/member_model_scope.go @@ -19,10 +19,25 @@ import ( func (s *sTenant) MemberModelScopes(ctx context.Context, req *v1.TenantMemberModelScopesReq) (*v1.TenantMemberModelScopesRes, error) { tenantID := middleware.GetTenantID(ctx) + // 验证用户属于当前租户 + var targetUser *struct { + Id int64 `json:"id"` + } + err := dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Scan(&targetUser) + if err != nil { + return nil, err + } + if targetUser == nil { + return nil, lcommon.NewNotFoundError("成员") + } + var rows []struct { ModelID int64 `json:"model_id"` } - err := dao.TntMemberModelScopes.Ctx(ctx). + err = dao.TntMemberModelScopes.Ctx(ctx). Where("tenant_id", tenantID). Where("user_id", req.Id). Scan(&rows) @@ -46,7 +61,22 @@ var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second) func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) { tenantID := middleware.GetTenantID(ctx) - err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 验证用户属于当前租户 + var targetUser *struct { + Id int64 `json:"id"` + } + err := dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Scan(&targetUser) + if err != nil { + return nil, err + } + if targetUser == nil { + return nil, lcommon.NewNotFoundError("成员") + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // Delete existing scopes _, err := tx.Model("tnt_member_model_scopes").Ctx(ctx). Where("tenant_id", tenantID). diff --git a/internal/logic/tenant/member_quota.go b/internal/logic/tenant/member_quota.go index bc76846..a8d5ddb 100644 --- a/internal/logic/tenant/member_quota.go +++ b/internal/logic/tenant/member_quota.go @@ -12,6 +12,8 @@ import ( "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" + + do "github.com/qianfree/team-api/internal/model/do" ) func (s *sTenant) MemberQuota(ctx context.Context, req *v1.TenantMemberQuotaReq) (*v1.TenantMemberQuotaRes, error) { @@ -60,23 +62,22 @@ func (s *sTenant) MemberQuotaSet(ctx context.Context, req *v1.TenantMemberQuotaS tenantID := middleware.GetTenantID(ctx) if req.QuotaType == "periodic" && req.Period == "" { - return nil, fmt.Errorf("周期类型不能为空") + return nil, common.NewBadRequestError("周期类型不能为空") } - data := g.Map{ - "quota_type": req.QuotaType, - "quota_limit": req.QuotaLimit, - "quota_used": 0, - "quota_period": nil, - "quota_reset_at": nil, + data := do.TntUsers{ + QuotaType: req.QuotaType, + QuotaLimit: req.QuotaLimit, + QuotaPeriod: nil, } if req.QuotaType == "periodic" { - data["quota_period"] = req.Period - data["quota_reset_at"] = gtime.Now() + data.QuotaPeriod = req.Period + now := gtime.Now() + data.QuotaResetAt = now } if req.QuotaType == "none" { - data["quota_limit"] = 0 + data.QuotaLimit = 0 } _, err := dao.TntUsers.Ctx(ctx). @@ -133,5 +134,5 @@ func calcNextReset(resetAt *time.Time, period string) *time.Time { func invalidateMemberQuotaCache(ctx context.Context, tenantID, userID int64) { key := fmt.Sprintf("member_quota:%d:%d", tenantID, userID) - _, _ = g.Redis().Do(ctx, "DEL", key) + _, _ = g.Redis().Del(ctx, key) } diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index b8530d9..6846f46 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -8,11 +8,13 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" do "github.com/qianfree/team-api/internal/model/do" @@ -103,12 +105,18 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用") } - // 验证 state - val, err := g.Redis().Get(ctx, oauthStatePrefix+req.State) + // 原子验证并消费 state(Lua 脚本防止 TOCTOU 重放) + val, err := g.Redis().Do(ctx, "EVAL", ` + local v = redis.call("GET", KEYS[1]) + if v then + redis.call("DEL", KEYS[1]) + return v + end + return nil + `, 1, oauthStatePrefix+req.State) if err != nil || val.IsNil() || val.String() != req.Provider { return nil, common.NewBusinessError(10060, "OAuth 授权码无效") } - g.Redis().Del(ctx, oauthStatePrefix+req.State) // 换取 token oauthToken, err := provider.ExchangeToken(ctx, req.Code) @@ -145,7 +153,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( userID = identity.UserId // 更新 token 信息 - dao.TntOauthIdentities.Ctx(ctx). + _, err = dao.TntOauthIdentities.Ctx(ctx). Where("id", identity.Id). Data(do.TntOauthIdentities{ AccessToken: oauthToken.AccessToken, @@ -153,6 +161,9 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), ProviderUsername: userInfo.Username, }).Update() + if err != nil { + return nil, err + } } else { // 尝试自动注册 autoRegister := getOAuthSetting(ctx, "oauth_auto_register") @@ -172,10 +183,23 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( Where("code", tenantCode). Where("status", "active"). Scan(&tenant) - if err != nil || tenant.Id == 0 { + if err != nil || tenant == nil { return nil, common.NewBusinessError(10060, "OAuth 自动注册未配置") } + // 校验成员上限 + memberCount, _ := dao.TntUsers.Ctx(ctx). + Where("tenant_id", tenant.Id). + Where("status", "active"). + Count() + effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, tenant.Id) + if err != nil { + return nil, common.NewBusinessError(10060, "查询租户限制信息失败") + } + if memberCount >= effectiveMaxMembers { + return nil, common.NewBusinessError(10060, "租户成员数已达上限") + } + username := userInfo.Username if username == "" { username = fmt.Sprintf("%s_%s", req.Provider, userInfo.ProviderUserID) @@ -209,7 +233,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( // 创建 OAuth 身份绑定 rawData, _ := json.Marshal(userInfo.RawData) - dao.TntOauthIdentities.Ctx(ctx).Data(do.TntOauthIdentities{ + _, err = dao.TntOauthIdentities.Ctx(ctx).Data(do.TntOauthIdentities{ TenantId: tenantID, UserId: userID, Provider: req.Provider, @@ -222,13 +246,22 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), RawData: string(rawData), }).Insert() + if err != nil { + return nil, err + } } - // 查找用户角色 + // 查找用户角色(先校验再创建 session) var user *struct { Role string `json:"role"` } - dao.TntUsers.Ctx(ctx).Where("id", userID).Scan(&user) + err = dao.TntUsers.Ctx(ctx).Where("id", userID).Scan(&user) + if err != nil { + return nil, err + } + if user == nil { + return nil, common.NewBusinessError(10060, "OAuth 登录失败:用户不存在") + } // 创建 session refreshToken, err := common.GenerateRefreshToken() @@ -251,14 +284,6 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( return nil, err } - if user == nil { - return nil, common.NewBusinessError(10060, "OAuth 登录失败:用户不存在") - } - - if user == nil { - return nil, common.NewBusinessError(10060, "OAuth 登录失败:用户不存在") - } - // 生成 JWT token pair tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", user.Role, tenantID, sessionID, jti) if err != nil { @@ -301,17 +326,20 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut Id int64 `json:"id"` UserId int64 `json:"user_id"` } - dao.TntOauthIdentities.Ctx(ctx). + err = dao.TntOauthIdentities.Ctx(ctx). Where("provider", req.Provider). Where("provider_user_id", userInfo.ProviderUserID). Scan(&existing) + if err != nil { + return nil, err + } if existing != nil && existing.UserId != userID { return nil, common.NewBusinessError(10062, "该 OAuth 账号已绑定其他用户") } rawData, _ := json.Marshal(userInfo.RawData) if existing != nil && existing.Id > 0 { - dao.TntOauthIdentities.Ctx(ctx). + _, err = dao.TntOauthIdentities.Ctx(ctx). Where("id", existing.Id). Data(do.TntOauthIdentities{ AccessToken: oauthToken.AccessToken, @@ -319,8 +347,11 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), RawData: string(rawData), }).Update() + if err != nil { + return nil, err + } } else { - dao.TntOauthIdentities.Ctx(ctx).Data(do.TntOauthIdentities{ + _, err = dao.TntOauthIdentities.Ctx(ctx).Data(do.TntOauthIdentities{ TenantId: tenantID, UserId: userID, Provider: req.Provider, @@ -333,6 +364,9 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)), RawData: string(rawData), }).Insert() + if err != nil { + return nil, err + } } return &v1.OAuthLinkRes{}, nil @@ -413,12 +447,17 @@ func (p *GitHubProvider) GetAuthorizeURL(clientID, redirectURI, state string) st } func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string) (*OAuthToken, error) { - body := fmt.Sprintf(`{"client_id":"%s","client_secret":"%s","code":"%s"}`, - getOAuthSetting(ctx, "oauth_github_client_id"), - getOAuthSetting(ctx, "oauth_github_client_secret"), - code) + bodyMap := map[string]string{ + "client_id": getOAuthSetting(ctx, "oauth_github_client_id"), + "client_secret": getOAuthSetting(ctx, "oauth_github_client_secret"), + "code": code, + } + bodyBytes, err := json.Marshal(bodyMap) + if err != nil { + return nil, err + } - resp, err := http.Post("https://github.com/login/oauth/access_token", "application/json", strings.NewReader(body)) + resp, err := http.Post("https://github.com/login/oauth/access_token", "application/json", strings.NewReader(string(bodyBytes))) if err != nil { return nil, err } @@ -481,13 +520,14 @@ func (p *GoogleProvider) GetAuthorizeURL(clientID, redirectURI, state string) st } func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuthToken, error) { - body := fmt.Sprintf("code=%s&client_id=%s&client_secret=%s&redirect_uri=%s&grant_type=authorization_code", - code, - getOAuthSetting(ctx, "oauth_google_client_id"), - getOAuthSetting(ctx, "oauth_google_client_secret"), - getOAuthSetting(ctx, "site_url")) + v := url.Values{} + v.Set("code", code) + v.Set("client_id", getOAuthSetting(ctx, "oauth_google_client_id")) + v.Set("client_secret", getOAuthSetting(ctx, "oauth_google_client_secret")) + v.Set("redirect_uri", getOAuthSetting(ctx, "site_url")) + v.Set("grant_type", "authorization_code") - resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(body)) + resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode())) if err != nil { return nil, err } diff --git a/internal/logic/tenant/organization.go b/internal/logic/tenant/organization.go index e0f76fb..5c4858d 100644 --- a/internal/logic/tenant/organization.go +++ b/internal/logic/tenant/organization.go @@ -2,16 +2,20 @@ package tenant import ( "context" - do "github.com/qianfree/team-api/internal/model/do" - "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" - v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/utility/crypto" + + do "github.com/qianfree/team-api/internal/model/do" + + "github.com/gogf/gf/v2/database/gdb" + + v1 "github.com/qianfree/team-api/api/tenant/v1" ) // ownerOnly checks if the current user is the tenant owner. @@ -54,12 +58,15 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1 } // Look up level name - var levelName string + var levelName *string if tenant.Level > 0 { - _ = dao.TntTenantLevelConfigs.Ctx(ctx). + err = dao.TntTenantLevelConfigs.Ctx(ctx). Where("level", tenant.Level). Fields("name"). Scan(&levelName) + if err != nil { + g.Log().Warningf(ctx, "查询租户等级名称失败: %v", err) + } } // 计算实际生效的成员数上限(NULL时取等级配置) @@ -71,13 +78,18 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1 } return &v1.TenantOrgInfoRes{ - ID: tenant.Id, - Name: tenant.Name, - Code: tenant.Code, - LogoURL: tenant.LogoUrl, - Status: tenant.Status, - Level: tenant.Level, - LevelName: levelName, + ID: tenant.Id, + Name: tenant.Name, + Code: tenant.Code, + LogoURL: tenant.LogoUrl, + Status: tenant.Status, + Level: tenant.Level, + LevelName: func() string { + if levelName != nil { + return *levelName + } + return "" + }(), MaxMembers: effectiveMaxMembers, MemberCount: int(memberCount), CreatedAt: tenant.CreatedAt, @@ -153,9 +165,6 @@ func (s *sTenant) TransferOwnership(ctx context.Context, req *v1.TenantOrgTransf if newOwner == nil { return nil, common.NewNotFoundError("用户") } - if newOwner.ID == 0 { - return nil, common.NewBadRequestError("目标用户不存在") - } err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // Demote current owner to admin diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go index b8d08ab..8ab11ab 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -4,6 +4,7 @@ import ( "context" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/internal/dao" @@ -138,8 +139,8 @@ func (s *sTenant) ProjectUpdate(ctx context.Context, req *v1.TenantProjectUpdate return nil, common.NewNotFoundError("项目") } - if project.Status == "archived" || project.Status == "budget_exhausted" { - return nil, common.NewBadRequestError("归档或预算耗尽的项目不能直接编辑,请先恢复状态") + if project.Status == "archived" { + return nil, common.NewBadRequestError("归档的项目不能直接编辑,请先取消归档") } updateData := do.TntProjects{} @@ -195,13 +196,16 @@ func (s *sTenant) ProjectArchive(ctx context.Context, req *v1.TenantProjectArchi } // Revoke all keys belonging to this project - dao.ApiKeys.Ctx(ctx). + _, err = dao.ApiKeys.Ctx(ctx). Where("tenant_id", tenantID). Where("project_id", req.Id). Where("status", "active"). Data(do.ApiKeys{ Status: "revoked", }).Update() + if err != nil { + return nil, err + } _, err = dao.TntProjects.Ctx(ctx). Where("id", req.Id). @@ -449,10 +453,13 @@ func (s *sTenant) ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProject for _, modelName := range req.ModelNames { if modelName != "" { - dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{ + _, err = dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{ ApiKeyId: id, ModelName: modelName, }) + if err != nil { + g.Log().Warningf(ctx, "创建密钥模型范围失败: %v", err) + } } } @@ -686,14 +693,37 @@ func convertApiKeyRowsToMaps(ctx context.Context, rows any) []map[string]any { result := make([]map[string]any, 0) switch v := rows.(type) { case []apiKeyRow: + // Batch query model counts + keyIDs := make([]int64, len(v)) + for i, r := range v { + keyIDs[i] = r.Id + } + modelCountMap := make(map[int64]int) + if len(keyIDs) > 0 { + type countRow struct { + ApiKeyId int64 `json:"api_key_id"` + Cnt int `json:"cnt"` + } + var countRows []countRow + err := dao.ApiKeyModelScopes.Ctx(ctx). + WhereIn("api_key_id", keyIDs). + Fields("api_key_id, COUNT(*) as cnt"). + Group("api_key_id"). + Scan(&countRows) + if err == nil { + for _, cr := range countRows { + modelCountMap[cr.ApiKeyId] = cr.Cnt + } + } + } + for _, r := range v { - modelCount, _ := dao.ApiKeyModelScopes.Ctx(ctx).Where("api_key_id", r.Id).Count() m := map[string]any{ "id": r.Id, "name": r.Name, "key_prefix": r.KeyPrefix, "status": r.Status, - "model_count": modelCount, + "model_count": modelCountMap[r.Id], } if r.CreatedAt != nil { m["created_at"] = r.CreatedAt.String() @@ -732,12 +762,3 @@ func convertUsageLogRowsToMaps(rows any) []map[string]any { } return result } - -// generateApiKey generates a random API key string using the relay package. -func generateApiKey() string { - rawKey, _, _, err := relay.GenerateApiKey(context.Background()) - if err != nil { - return "sk-err-" + gtime.Now().TimestampMilliStr() - } - return rawKey -}