forked from baron/baron-sso
381 lines
9.5 KiB
Go
381 lines
9.5 KiB
Go
package repository
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"context"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type DataIntegrityChecker interface {
|
|
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
|
|
ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error)
|
|
DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error)
|
|
}
|
|
|
|
type dataIntegrityChecker struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewDataIntegrityChecker(db *gorm.DB) DataIntegrityChecker {
|
|
return &dataIntegrityChecker{db: db}
|
|
}
|
|
|
|
func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
|
return CheckDataIntegrity(ctx, c.db)
|
|
}
|
|
|
|
func (c *dataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
|
|
return ListOrphanUserLoginIDs(ctx, c.db, nil)
|
|
}
|
|
|
|
func (c *dataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
|
return DeleteOrphanUserLoginIDs(ctx, c.db, ids)
|
|
}
|
|
|
|
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
|
|
tenantChecks := []domain.DataIntegrityCheck{
|
|
{
|
|
Key: "duplicate_tenant_slugs",
|
|
Label: "중복 테넌트 slug",
|
|
Description: "삭제되지 않은 tenant의 slug를 대소문자 무시 기준으로 검사합니다.",
|
|
Severity: "error",
|
|
Count: 0,
|
|
},
|
|
{
|
|
Key: "orphan_tenant_parents",
|
|
Label: "유령 상위 테넌트 참조",
|
|
Description: "tenant.parent_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
|
Severity: "error",
|
|
Count: 0,
|
|
},
|
|
}
|
|
userChecks := []domain.DataIntegrityCheck{
|
|
{
|
|
Key: "orphan_user_tenant_memberships",
|
|
Label: "유령 테넌트 사용자 소속",
|
|
Description: "users.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
|
Severity: "error",
|
|
Count: 0,
|
|
},
|
|
{
|
|
Key: "orphan_user_login_id_tenants",
|
|
Label: "유령 테넌트 로그인 ID",
|
|
Description: "user_login_ids.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
|
Severity: "error",
|
|
Count: 0,
|
|
},
|
|
{
|
|
Key: "orphan_user_login_id_users",
|
|
Label: "유령 사용자 로그인 ID",
|
|
Description: "user_login_ids.user_id가 없거나 삭제된 user를 참조하는지 검사합니다.",
|
|
Severity: "error",
|
|
Count: 0,
|
|
},
|
|
}
|
|
|
|
counts := []struct {
|
|
target *int64
|
|
query string
|
|
}{
|
|
{
|
|
target: &tenantChecks[0].Count,
|
|
query: `
|
|
SELECT COUNT(*)
|
|
FROM (
|
|
SELECT LOWER(TRIM(slug)) AS normalized_slug
|
|
FROM tenants
|
|
WHERE deleted_at IS NULL
|
|
AND status <> 'deleted'
|
|
AND TRIM(slug) <> ''
|
|
GROUP BY LOWER(TRIM(slug))
|
|
HAVING COUNT(*) > 1
|
|
) AS duplicate_slugs
|
|
`,
|
|
},
|
|
{
|
|
target: &tenantChecks[1].Count,
|
|
query: `
|
|
SELECT COUNT(*)
|
|
FROM tenants AS child
|
|
WHERE child.deleted_at IS NULL
|
|
AND child.parent_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM tenants AS parent
|
|
WHERE parent.id = child.parent_id
|
|
AND parent.deleted_at IS NULL
|
|
)
|
|
`,
|
|
},
|
|
{
|
|
target: &userChecks[0].Count,
|
|
query: `
|
|
SELECT COUNT(*)
|
|
FROM users AS u
|
|
WHERE u.deleted_at IS NULL
|
|
AND u.tenant_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM tenants AS t
|
|
WHERE t.id = u.tenant_id
|
|
AND t.deleted_at IS NULL
|
|
)
|
|
`,
|
|
},
|
|
{
|
|
target: &userChecks[1].Count,
|
|
query: `
|
|
SELECT COUNT(*)
|
|
FROM user_login_ids AS uli
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM tenants AS t
|
|
WHERE t.id = uli.tenant_id
|
|
AND t.deleted_at IS NULL
|
|
)
|
|
`,
|
|
},
|
|
{
|
|
target: &userChecks[2].Count,
|
|
query: `
|
|
SELECT COUNT(*)
|
|
FROM user_login_ids AS uli
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM users AS u
|
|
WHERE u.id = uli.user_id
|
|
AND u.deleted_at IS NULL
|
|
)
|
|
`,
|
|
},
|
|
}
|
|
|
|
for _, item := range counts {
|
|
if err := db.WithContext(ctx).Raw(item.query).Scan(item.target).Error; err != nil {
|
|
return domain.DataIntegrityReport{}, err
|
|
}
|
|
}
|
|
|
|
tenantChecks = applyIntegrityStatuses(tenantChecks)
|
|
userChecks = applyIntegrityStatuses(userChecks)
|
|
sections := []domain.DataIntegritySection{
|
|
{
|
|
Key: "tenant_integrity",
|
|
Label: "테넌트 정합성",
|
|
Status: summarizeIntegrityStatus(tenantChecks),
|
|
Checks: tenantChecks,
|
|
},
|
|
{
|
|
Key: "user_integrity",
|
|
Label: "사용자 정합성",
|
|
Status: summarizeIntegrityStatus(userChecks),
|
|
Checks: userChecks,
|
|
},
|
|
}
|
|
|
|
summary := domain.DataIntegritySummary{}
|
|
for _, section := range sections {
|
|
for _, check := range section.Checks {
|
|
summary.TotalChecks++
|
|
switch check.Status {
|
|
case domain.DataIntegrityStatusFail:
|
|
summary.Failures += check.Count
|
|
case domain.DataIntegrityStatusWarning:
|
|
summary.Warnings++
|
|
default:
|
|
summary.Passed++
|
|
}
|
|
}
|
|
}
|
|
|
|
return domain.DataIntegrityReport{
|
|
Status: summarizeSectionStatus(sections),
|
|
CheckedAt: time.Now().UTC(),
|
|
Summary: summary,
|
|
Sections: sections,
|
|
}, nil
|
|
}
|
|
|
|
func applyIntegrityStatuses(checks []domain.DataIntegrityCheck) []domain.DataIntegrityCheck {
|
|
for i := range checks {
|
|
if checks[i].Count > 0 {
|
|
checks[i].Status = domain.DataIntegrityStatusFail
|
|
} else {
|
|
checks[i].Status = domain.DataIntegrityStatusPass
|
|
}
|
|
}
|
|
return checks
|
|
}
|
|
|
|
func summarizeIntegrityStatus(checks []domain.DataIntegrityCheck) domain.DataIntegrityStatus {
|
|
status := domain.DataIntegrityStatusPass
|
|
for _, check := range checks {
|
|
if check.Status == domain.DataIntegrityStatusFail {
|
|
return domain.DataIntegrityStatusFail
|
|
}
|
|
if check.Status == domain.DataIntegrityStatusWarning {
|
|
status = domain.DataIntegrityStatusWarning
|
|
}
|
|
}
|
|
return status
|
|
}
|
|
|
|
func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataIntegrityStatus {
|
|
status := domain.DataIntegrityStatusPass
|
|
for _, section := range sections {
|
|
if section.Status == domain.DataIntegrityStatusFail {
|
|
return domain.DataIntegrityStatusFail
|
|
}
|
|
if section.Status == domain.DataIntegrityStatusWarning {
|
|
status = domain.DataIntegrityStatusWarning
|
|
}
|
|
}
|
|
return status
|
|
}
|
|
|
|
func ListOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) ([]domain.OrphanUserLoginID, error) {
|
|
type orphanRow struct {
|
|
ID string
|
|
UserID string
|
|
UserEmail string
|
|
UserDeletedAt *time.Time
|
|
TenantID string
|
|
TenantSlug string
|
|
TenantDeletedAt *time.Time
|
|
FieldKey string
|
|
LoginID string
|
|
MissingUser bool
|
|
DeletedUser bool
|
|
MissingTenant bool
|
|
DeletedTenant bool
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
uli.id,
|
|
uli.user_id,
|
|
COALESCE(u.email, '') AS user_email,
|
|
u.deleted_at AS user_deleted_at,
|
|
uli.tenant_id,
|
|
COALESCE(t.slug, '') AS tenant_slug,
|
|
t.deleted_at AS tenant_deleted_at,
|
|
uli.field_key,
|
|
uli.login_id,
|
|
(u.id IS NULL) AS missing_user,
|
|
(u.id IS NOT NULL AND u.deleted_at IS NOT NULL) AS deleted_user,
|
|
(t.id IS NULL) AS missing_tenant,
|
|
(t.id IS NOT NULL AND t.deleted_at IS NOT NULL) AS deleted_tenant
|
|
FROM user_login_ids AS uli
|
|
LEFT JOIN users AS u ON u.id = uli.user_id
|
|
LEFT JOIN tenants AS t ON t.id = uli.tenant_id
|
|
WHERE (
|
|
u.id IS NULL
|
|
OR u.deleted_at IS NOT NULL
|
|
OR t.id IS NULL
|
|
OR t.deleted_at IS NOT NULL
|
|
)
|
|
`
|
|
args := []any{}
|
|
if len(ids) > 0 {
|
|
query += " AND uli.id IN ?\n"
|
|
args = append(args, ids)
|
|
}
|
|
query += "ORDER BY uli.login_id, uli.id"
|
|
|
|
var rows []orphanRow
|
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]domain.OrphanUserLoginID, 0, len(rows))
|
|
for _, row := range rows {
|
|
reasons := make([]string, 0, 4)
|
|
if row.MissingUser {
|
|
reasons = append(reasons, "missing_user")
|
|
}
|
|
if row.DeletedUser {
|
|
reasons = append(reasons, "deleted_user")
|
|
}
|
|
if row.MissingTenant {
|
|
reasons = append(reasons, "missing_tenant")
|
|
}
|
|
if row.DeletedTenant {
|
|
reasons = append(reasons, "deleted_tenant")
|
|
}
|
|
items = append(items, domain.OrphanUserLoginID{
|
|
ID: row.ID,
|
|
UserID: row.UserID,
|
|
UserEmail: row.UserEmail,
|
|
UserDeletedAt: row.UserDeletedAt,
|
|
TenantID: row.TenantID,
|
|
TenantSlug: row.TenantSlug,
|
|
TenantDeletedAt: row.TenantDeletedAt,
|
|
FieldKey: row.FieldKey,
|
|
LoginID: row.LoginID,
|
|
Reasons: reasons,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func DeleteOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
|
ids = normalizeIDList(ids)
|
|
result := domain.DeleteOrphanUserLoginIDsResult{
|
|
Deleted: []domain.OrphanUserLoginID{},
|
|
SkippedIDs: []string{},
|
|
}
|
|
if len(ids) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
items, err := ListOrphanUserLoginIDs(ctx, tx, ids)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
deletableIDs := make([]string, 0, len(items))
|
|
deletableSet := make(map[string]bool, len(items))
|
|
for _, item := range items {
|
|
deletableIDs = append(deletableIDs, item.ID)
|
|
deletableSet[item.ID] = true
|
|
}
|
|
for _, id := range ids {
|
|
if !deletableSet[id] {
|
|
result.SkippedIDs = append(result.SkippedIDs, id)
|
|
}
|
|
}
|
|
if len(deletableIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
deleteResult := tx.Exec("DELETE FROM user_login_ids WHERE id IN ?", deletableIDs)
|
|
if deleteResult.Error != nil {
|
|
return deleteResult.Error
|
|
}
|
|
result.Deleted = items
|
|
result.DeletedCount = deleteResult.RowsAffected
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func normalizeIDList(ids []string) []string {
|
|
normalized := make([]string, 0, len(ids))
|
|
seen := map[string]bool{}
|
|
for _, id := range ids {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
normalized = append(normalized, id)
|
|
}
|
|
slices.Sort(normalized)
|
|
return normalized
|
|
}
|