fix(tenant):修复人员相关bug

This commit is contained in:
qian committed 2026-06-03 00:43:12 +08:00
1 parent fa70d8790c
commit e9ead5dd8a
10 files changed
+315 -120

No files matched your search

+31 -11
View File
@@ -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
+10 -1
View File
@@ -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
+9 -3
View File
@@ -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
+18 -5
View File
@@ -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).
+73 -27
View File
@@ -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
}
+32 -2
View File
@@ -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).
+12 -11
View File
@@ -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)
}
+70 -30
View File
@@ -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
}
+24 -15
View File
@@ -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
+36 -15
View File
@@ -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
}