forked from baron/baron-sso
adminfront 조직 통계오류 보정. Kratos Projection용 통계테이블 구조 추가
This commit is contained in:
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
// Auto-migrate
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to migrate database: %s", err)
|
||||
}
|
||||
|
||||
176
backend/internal/repository/user_projection_repository.go
Normal file
176
backend/internal/repository/user_projection_repository.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type UserProjectionRepository interface {
|
||||
IsReady(ctx context.Context) (bool, error)
|
||||
GetStatus(ctx context.Context) (domain.UserProjectionStatus, error)
|
||||
CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
|
||||
ReplaceAllFromKratos(ctx context.Context, users []domain.User) error
|
||||
MarkFailed(ctx context.Context, syncErr error) error
|
||||
}
|
||||
|
||||
type userProjectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserProjectionRepository(db *gorm.DB) UserProjectionRepository {
|
||||
return &userProjectionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) IsReady(ctx context.Context) (bool, error) {
|
||||
status, err := r.GetStatus(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status.Ready, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
var projectedUsers int64
|
||||
if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&projectedUsers).Error; err != nil {
|
||||
return domain.UserProjectionStatus{}, err
|
||||
}
|
||||
|
||||
var state domain.UserProjectionState
|
||||
err := r.db.WithContext(ctx).First(&state, "name = ?", domain.UserProjectionNameKratos).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusFailed,
|
||||
Ready: false,
|
||||
ProjectedUsers: projectedUsers,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.UserProjectionStatus{}, err
|
||||
}
|
||||
return domain.UserProjectionStatus{
|
||||
Name: state.Name,
|
||||
Status: state.Status,
|
||||
Ready: state.Status == domain.UserProjectionStatusReady && state.LastSyncedAt != nil,
|
||||
LastSyncedAt: state.LastSyncedAt,
|
||||
LastError: state.LastError,
|
||||
UpdatedAt: &state.UpdatedAt,
|
||||
ProjectedUsers: projectedUsers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
valuePlaceholders := make([]string, 0, len(tenants))
|
||||
args := make([]interface{}, 0, len(tenants)*2)
|
||||
for _, tenant := range tenants {
|
||||
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
|
||||
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH requested(tenant_id, slug) AS (
|
||||
VALUES %s
|
||||
)
|
||||
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
|
||||
FROM requested
|
||||
LEFT JOIN users ON users.deleted_at IS NULL AND (
|
||||
users.tenant_id::text = requested.tenant_id
|
||||
OR LOWER(users.company_code) = LOWER(requested.slug)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM unnest(users.company_codes) AS company_code
|
||||
WHERE LOWER(company_code) = LOWER(requested.slug)
|
||||
)
|
||||
)
|
||||
GROUP BY requested.tenant_id
|
||||
`, strings.Join(valuePlaceholders, ","))
|
||||
|
||||
type result struct {
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var rows []result
|
||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
counts[row.TenantID] = row.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := make([]string, 0, len(users))
|
||||
for i := range users {
|
||||
users[i].DeletedAt = gorm.DeletedAt{}
|
||||
if users[i].CreatedAt.IsZero() {
|
||||
users[i].CreatedAt = now
|
||||
}
|
||||
if users[i].UpdatedAt.IsZero() {
|
||||
users[i].UpdatedAt = now
|
||||
}
|
||||
ids = append(ids, users[i].ID)
|
||||
}
|
||||
|
||||
if len(users) > 0 {
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id NOT IN ?", ids).Delete(&domain.User{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.Where("1 = 1").Delete(&domain.User{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "")
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userProjectionRepository) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
message := ""
|
||||
if syncErr != nil {
|
||||
message = syncErr.Error()
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return upsertUserProjectionState(tx, domain.UserProjectionStatusFailed, nil, message)
|
||||
})
|
||||
}
|
||||
|
||||
func upsertUserProjectionState(tx *gorm.DB, status string, syncedAt *time.Time, lastError string) error {
|
||||
state := domain.UserProjectionState{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: status,
|
||||
LastSyncedAt: syncedAt,
|
||||
LastError: lastError,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"status",
|
||||
"last_synced_at",
|
||||
"last_error",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(&state).Error
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
||||
|
||||
tenantID := "10000000-0000-0000-0000-000000000001"
|
||||
tenantSlug := "projection-saman"
|
||||
require.NoError(t, testDB.Create(&domain.Tenant{
|
||||
ID: tenantID,
|
||||
Name: "Projection Saman",
|
||||
Slug: tenantSlug,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}).Error)
|
||||
stale := &domain.User{
|
||||
ID: "00000000-0000-0000-0000-000000000099",
|
||||
Email: "stale@example.com",
|
||||
Name: "Stale",
|
||||
CompanyCode: tenantSlug,
|
||||
}
|
||||
require.NoError(t, NewUserRepository(testDB).Create(ctx, stale))
|
||||
|
||||
users := []domain.User{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000101",
|
||||
Email: "one@example.com",
|
||||
Name: "One",
|
||||
CompanyCode: tenantSlug,
|
||||
TenantID: &tenantID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000102",
|
||||
Email: "two@example.com",
|
||||
Name: "Two",
|
||||
CompanyCodes: []string{tenantSlug},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, repo.ReplaceAllFromKratos(ctx, users))
|
||||
|
||||
ready, err := repo.IsReady(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ready)
|
||||
|
||||
counts, err := repo.CountTenantMembers(ctx, []domain.Tenant{
|
||||
{ID: tenantID, Slug: tenantSlug},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), counts[tenantID])
|
||||
|
||||
var activeCount int64
|
||||
require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error)
|
||||
assert.Equal(t, int64(2), activeCount)
|
||||
}
|
||||
|
||||
func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewUserProjectionRepository(testDB)
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
|
||||
|
||||
require.NoError(t, repo.MarkFailed(ctx, errors.New("kratos down")))
|
||||
|
||||
ready, err := repo.IsReady(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ready)
|
||||
}
|
||||
@@ -164,9 +164,9 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
query := `
|
||||
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
|
||||
FROM (
|
||||
SELECT id, company_code as comp_code FROM users WHERE LOWER(company_code) = ANY($1)
|
||||
SELECT id, company_code as comp_code FROM users WHERE deleted_at IS NULL AND LOWER(company_code) = ANY($1)
|
||||
UNION ALL
|
||||
SELECT id, unnest(company_codes) as comp_code FROM users WHERE company_codes && $1
|
||||
SELECT id, unnest(company_codes) as comp_code FROM users WHERE deleted_at IS NULL AND company_codes IS NOT NULL
|
||||
) as combined
|
||||
WHERE LOWER(comp_code) = ANY($1)
|
||||
GROUP BY LOWER(comp_code)
|
||||
|
||||
@@ -95,6 +95,25 @@ func TestUserRepository(t *testing.T) {
|
||||
assert.Equal(t, int64(0), counts["tenant-c"])
|
||||
})
|
||||
|
||||
t.Run("CountByCompanyCodes excludes soft deleted cache rows", func(t *testing.T) {
|
||||
testDB.Exec("DELETE FROM users")
|
||||
|
||||
active := &domain.User{Email: "active@a.com", Name: "Active", CompanyCode: "tenant-a"}
|
||||
deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", CompanyCode: "tenant-a"}
|
||||
arrayDeleted := &domain.User{Email: "array-deleted@a.com", Name: "Array Deleted", CompanyCodes: []string{"tenant-a"}}
|
||||
|
||||
assert.NoError(t, repo.Create(ctx, active))
|
||||
assert.NoError(t, repo.Create(ctx, deleted))
|
||||
assert.NoError(t, repo.Create(ctx, arrayDeleted))
|
||||
assert.NoError(t, repo.Delete(ctx, deleted.ID))
|
||||
assert.NoError(t, repo.Delete(ctx, arrayDeleted.ID))
|
||||
|
||||
counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a"})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), counts["tenant-a"])
|
||||
})
|
||||
|
||||
t.Run("Multi-Identifier Support", func(t *testing.T) {
|
||||
_ = testDB.AutoMigrate(&domain.UserLoginID{})
|
||||
testDB.Exec("DELETE FROM user_login_ids")
|
||||
|
||||
Reference in New Issue
Block a user