feat: resolve auth identity migration reports

This commit is contained in:
IanShaw027
2026-04-20 22:29:21 +08:00
parent 452e55a53c
commit 724f8e89a1
7 changed files with 209 additions and 15 deletions

View File

@@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -24,6 +25,10 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
router.GET("/api/v1/admin/users", userHandler.List) router.GET("/api/v1/admin/users", userHandler.List)
router.GET("/api/v1/admin/users/auth-identity-migration-reports/summary", userHandler.GetAuthIdentityMigrationReportSummary) router.GET("/api/v1/admin/users/auth-identity-migration-reports/summary", userHandler.GetAuthIdentityMigrationReportSummary)
router.GET("/api/v1/admin/users/auth-identity-migration-reports", userHandler.ListAuthIdentityMigrationReports) router.GET("/api/v1/admin/users/auth-identity-migration-reports", userHandler.ListAuthIdentityMigrationReports)
router.POST("/api/v1/admin/users/auth-identity-migration-reports/:id/resolve", func(c *gin.Context) {
c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 99})
userHandler.ResolveAuthIdentityMigrationReport(c)
})
router.GET("/api/v1/admin/users/:id", userHandler.GetByID) router.GET("/api/v1/admin/users/:id", userHandler.GetByID)
router.POST("/api/v1/admin/users/:id/auth-identities", userHandler.BindAuthIdentity) router.POST("/api/v1/admin/users/:id/auth-identities", userHandler.BindAuthIdentity)
router.POST("/api/v1/admin/users", userHandler.Create) router.POST("/api/v1/admin/users", userHandler.Create)
@@ -83,6 +88,13 @@ func TestUserHandlerEndpoints(t *testing.T) {
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, http.StatusOK, rec.Code)
body, _ := json.Marshal(map[string]any{"resolution_note": "resolved by manual bind"})
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/auth-identity-migration-reports/1/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/1", nil) req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/1", nil)
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -99,7 +111,7 @@ func TestUserHandlerEndpoints(t *testing.T) {
"channel_subject": "openid-123", "channel_subject": "openid-123",
}, },
} }
body, _ := json.Marshal(bindBody) body, _ = json.Marshal(bindBody)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/1/auth-identities", bytes.NewReader(body)) req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/1/auth-identities", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")

View File

@@ -249,6 +249,20 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
return result, nil return result, nil
} }
func (s *stubAdminService) ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*service.AuthIdentityMigrationReport, error) {
now := time.Now().UTC()
for i := range s.migrationReports {
if s.migrationReports[i].ID != reportID {
continue
}
s.migrationReports[i].ResolvedAt = &now
s.migrationReports[i].ResolvedByUserID = &resolvedByUserID
s.migrationReports[i].ResolutionNote = resolutionNote
return &s.migrationReports[i], nil
}
return nil, nil
}
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) { func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
return s.groups, int64(len(s.groups)), nil return s.groups, int64(len(s.groups)), nil
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -82,6 +83,10 @@ type BindUserAuthIdentityChannelRequest struct {
Metadata map[string]any `json:"metadata"` Metadata map[string]any `json:"metadata"`
} }
type ResolveAuthIdentityMigrationReportRequest struct {
ResolutionNote string `json:"resolution_note"`
}
// List handles listing all users with pagination // List handles listing all users with pagination
// GET /api/v1/admin/users // GET /api/v1/admin/users
// Query params: // Query params:
@@ -252,6 +257,40 @@ func (h *UserHandler) BindAuthIdentity(c *gin.Context) {
response.Success(c, result) response.Success(c, result)
} }
// ResolveAuthIdentityMigrationReport marks a migration report as resolved.
// POST /api/v1/admin/users/auth-identity-migration-reports/:id/resolve
func (h *UserHandler) ResolveAuthIdentityMigrationReport(c *gin.Context) {
reportID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid report ID")
return
}
subject, ok := servermiddleware.GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
response.Unauthorized(c, "Authentication required")
return
}
var req ResolveAuthIdentityMigrationReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
report, err := h.adminService.ResolveAuthIdentityMigrationReport(
c.Request.Context(),
reportID,
subject.UserID,
req.ResolutionNote,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, report)
}
// Create handles creating a new user // Create handles creating a new user
// POST /api/v1/admin/users // POST /api/v1/admin/users
func (h *UserHandler) Create(c *gin.Context) { func (h *UserHandler) Create(c *gin.Context) {

View File

@@ -212,6 +212,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
{ {
users.GET("/auth-identity-migration-reports/summary", h.Admin.User.GetAuthIdentityMigrationReportSummary) users.GET("/auth-identity-migration-reports/summary", h.Admin.User.GetAuthIdentityMigrationReportSummary)
users.GET("/auth-identity-migration-reports", h.Admin.User.ListAuthIdentityMigrationReports) users.GET("/auth-identity-migration-reports", h.Admin.User.ListAuthIdentityMigrationReports)
users.POST("/auth-identity-migration-reports/:id/resolve", h.Admin.User.ResolveAuthIdentityMigrationReport)
users.GET("", h.Admin.User.List) users.GET("", h.Admin.User.List)
users.GET("/:id", h.Admin.User.GetByID) users.GET("/:id", h.Admin.User.GetByID)
users.POST("/:id/auth-identities", h.Admin.User.BindAuthIdentity) users.POST("/:id/auth-identities", h.Admin.User.BindAuthIdentity)

View File

@@ -42,6 +42,7 @@ type AdminService interface {
ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error) ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error)
GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error)
BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error)
ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*AuthIdentityMigrationReport, error)
// Group management // Group management
ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error)
@@ -137,16 +138,21 @@ type UpdateUserInput struct {
} }
type AuthIdentityMigrationReport struct { type AuthIdentityMigrationReport struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ReportType string `json:"report_type"` ReportType string `json:"report_type"`
ReportKey string `json:"report_key"` ReportKey string `json:"report_key"`
Details map[string]any `json:"details"` Details map[string]any `json:"details"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
ResolvedByUserID *int64 `json:"resolved_by_user_id,omitempty"`
ResolutionNote string `json:"resolution_note,omitempty"`
} }
type AuthIdentityMigrationReportSummary struct { type AuthIdentityMigrationReportSummary struct {
Total int64 `json:"total"` Total int64 `json:"total"`
ByType map[string]int64 `json:"by_type"` OpenTotal int64 `json:"open_total"`
ResolvedTotal int64 `json:"resolved_total"`
ByType map[string]int64 `json:"by_type"`
} }
type AdminBindAuthIdentityInput struct { type AdminBindAuthIdentityInput struct {
@@ -874,7 +880,7 @@ WHERE ($1 = '' OR report_type = $1)`,
} }
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT id, report_type, report_key, details, created_at SELECT id, report_type, report_key, details, created_at, resolved_at, resolved_by_user_id, resolution_note
FROM auth_identity_migration_reports FROM auth_identity_migration_reports
WHERE ($1 = '' OR report_type = $1) WHERE ($1 = '' OR report_type = $1)
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
@@ -909,7 +915,11 @@ func (s *adminServiceImpl) GetAuthIdentityMigrationReportSummary(ctx context.Con
} }
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT report_type, COUNT(*) SELECT
report_type,
COUNT(*),
SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END),
SUM(CASE WHEN resolved_at IS NOT NULL THEN 1 ELSE 0 END)
FROM auth_identity_migration_reports FROM auth_identity_migration_reports
GROUP BY report_type GROUP BY report_type
ORDER BY report_type ASC`) ORDER BY report_type ASC`)
@@ -924,11 +934,15 @@ ORDER BY report_type ASC`)
for rows.Next() { for rows.Next() {
var reportType string var reportType string
var count int64 var count int64
if err := rows.Scan(&reportType, &count); err != nil { var openCount int64
var resolvedCount int64
if err := rows.Scan(&reportType, &count, &openCount, &resolvedCount); err != nil {
return nil, err return nil, err
} }
summary.ByType[reportType] = count summary.ByType[reportType] = count
summary.Total += count summary.Total += count
summary.OpenTotal += openCount
summary.ResolvedTotal += resolvedCount
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -936,6 +950,56 @@ ORDER BY report_type ASC`)
return summary, nil return summary, nil
} }
func (s *adminServiceImpl) ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*AuthIdentityMigrationReport, error) {
if reportID <= 0 {
return nil, infraerrors.BadRequest("INVALID_INPUT", "report id must be greater than 0")
}
if resolvedByUserID <= 0 {
return nil, infraerrors.BadRequest("INVALID_INPUT", "resolved_by_user_id must be greater than 0")
}
db, err := s.adminSQLDB()
if err != nil {
return nil, err
}
now := time.Now().UTC()
result, err := db.ExecContext(ctx, `
UPDATE auth_identity_migration_reports
SET
resolved_at = COALESCE(resolved_at, $2),
resolved_by_user_id = COALESCE(resolved_by_user_id, $3),
resolution_note = $4
WHERE id = $1`,
reportID,
now,
resolvedByUserID,
strings.TrimSpace(resolutionNote),
)
if err != nil {
return nil, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, infraerrors.NotFound("AUTH_IDENTITY_MIGRATION_REPORT_NOT_FOUND", "auth identity migration report not found")
}
row := db.QueryRowContext(ctx, `
SELECT id, report_type, report_key, details, created_at, resolved_at, resolved_by_user_id, resolution_note
FROM auth_identity_migration_reports
WHERE id = $1`,
reportID,
)
report, err := scanAuthIdentityMigrationReport(row)
if err != nil {
return nil, err
}
return &report, nil
}
func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) {
if userID <= 0 { if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0") return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0")
@@ -1170,10 +1234,22 @@ func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any {
func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) { func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) {
var ( var (
report AuthIdentityMigrationReport report AuthIdentityMigrationReport
details []byte details []byte
resolvedAt sql.NullTime
resolvedByUserID sql.NullInt64
resolutionNote sql.NullString
) )
if err := scanner.Scan(&report.ID, &report.ReportType, &report.ReportKey, &details, &report.CreatedAt); err != nil { if err := scanner.Scan(
&report.ID,
&report.ReportType,
&report.ReportKey,
&details,
&report.CreatedAt,
&resolvedAt,
&resolvedByUserID,
&resolutionNote,
); err != nil {
return AuthIdentityMigrationReport{}, err return AuthIdentityMigrationReport{}, err
} }
report.Details = map[string]any{} report.Details = map[string]any{}
@@ -1182,6 +1258,15 @@ func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error
return AuthIdentityMigrationReport{}, err return AuthIdentityMigrationReport{}, err
} }
} }
if resolvedAt.Valid {
report.ResolvedAt = &resolvedAt.Time
}
if resolvedByUserID.Valid {
report.ResolvedByUserID = &resolvedByUserID.Int64
}
if resolutionNote.Valid {
report.ResolutionNote = resolutionNote.String
}
return report, nil return report, nil
} }

View File

@@ -30,7 +30,10 @@ func newAdminServiceMigrationReportTestClient(t *testing.T) *dbent.Client {
report_type TEXT NOT NULL, report_type TEXT NOT NULL,
report_key TEXT NOT NULL, report_key TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '{}', details TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL created_at DATETIME NOT NULL,
resolved_at DATETIME NULL,
resolved_by_user_id INTEGER NULL,
resolution_note TEXT NOT NULL DEFAULT ''
)`) )`)
require.NoError(t, err) require.NoError(t, err)
@@ -87,6 +90,35 @@ VALUES
summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background()) summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background())
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(3), summary.Total) require.Equal(t, int64(3), summary.Total)
require.Equal(t, int64(3), summary.OpenTotal)
require.Zero(t, summary.ResolvedTotal)
require.Equal(t, int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"]) require.Equal(t, int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"])
require.Equal(t, int64(2), summary.ByType["wechat_provider_key_conflict"]) require.Equal(t, int64(2), summary.ByType["wechat_provider_key_conflict"])
} }
func TestAdminServiceResolveAuthIdentityMigrationReport(t *testing.T) {
client := newAdminServiceMigrationReportTestClient(t)
driver, ok := client.Driver().(*entsql.Driver)
require.True(t, ok)
now := time.Now().UTC()
_, err := driver.DB().ExecContext(context.Background(), `
INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at)
VALUES ($1, $2, $3, $4)`,
"oidc_synthetic_email_requires_manual_recovery", "u-1", `{"user_id":1}`, now,
)
require.NoError(t, err)
svc := &adminServiceImpl{entClient: client}
report, err := svc.ResolveAuthIdentityMigrationReport(context.Background(), 1, 99, "resolved by admin binding")
require.NoError(t, err)
require.NotNil(t, report.ResolvedAt)
require.NotNil(t, report.ResolvedByUserID)
require.Equal(t, int64(99), *report.ResolvedByUserID)
require.Equal(t, "resolved by admin binding", report.ResolutionNote)
summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background())
require.NoError(t, err)
require.Zero(t, summary.OpenTotal)
require.Equal(t, int64(1), summary.ResolvedTotal)
}

View File

@@ -0,0 +1,11 @@
ALTER TABLE auth_identity_migration_reports
ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ NULL;
ALTER TABLE auth_identity_migration_reports
ADD COLUMN IF NOT EXISTS resolved_by_user_id BIGINT NULL;
ALTER TABLE auth_identity_migration_reports
ADD COLUMN IF NOT EXISTS resolution_note TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_auth_identity_migration_reports_resolved_at
ON auth_identity_migration_reports (resolved_at);