fix(tenant):修复人员相关bug
This commit is contained in:
1 parent
fa70d8790c
commit
e9ead5dd8a
10 files changed
+315
-120
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user