From 3bd3027251dd5e008e60ba7299ef63dbc8f8a32c Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Mon, 20 Apr 2026 22:05:33 +0800 Subject: [PATCH] feat: expose auth identity migration reports --- .../admin/admin_basic_handlers_test.go | 12 ++ .../handler/admin/admin_service_stub_test.go | 34 +++++ .../internal/handler/admin/user_handler.go | 25 ++++ backend/internal/server/routes/admin.go | 2 + backend/internal/service/admin_service.go | 135 ++++++++++++++++++ ..._service_identity_migration_report_test.go | 92 ++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 backend/internal/service/admin_service_identity_migration_report_test.go diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index cba3ae21..ff9eec7e 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -22,6 +22,8 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { redeemHandler := NewRedeemHandler(adminSvc, nil) 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", userHandler.ListAuthIdentityMigrationReports) router.GET("/api/v1/admin/users/:id", userHandler.GetByID) router.POST("/api/v1/admin/users", userHandler.Create) router.PUT("/api/v1/admin/users/:id", userHandler.Update) @@ -70,6 +72,16 @@ func TestUserHandlerEndpoints(t *testing.T) { router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/auth-identity-migration-reports/summary", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/auth-identity-migration-reports?report_type=oidc_synthetic_email_requires_manual_recovery", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/1", nil) router.ServeHTTP(rec, req) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 6d1ef1b6..681c25c6 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -17,6 +17,7 @@ type stubAdminService struct { proxies []service.Proxy proxyCounts []service.ProxyWithAccountCount redeems []service.RedeemCode + migrationReports []service.AuthIdentityMigrationReport createdAccounts []*service.CreateAccountInput createdProxies []*service.CreateProxyInput updatedProxyIDs []int64 @@ -123,6 +124,15 @@ func newStubAdminService() *stubAdminService { proxies: []service.Proxy{proxy}, proxyCounts: []service.ProxyWithAccountCount{{Proxy: proxy, AccountCount: 1}}, redeems: []service.RedeemCode{redeem}, + migrationReports: []service.AuthIdentityMigrationReport{ + { + ID: 1, + ReportType: "oidc_synthetic_email_requires_manual_recovery", + ReportKey: "u-1", + Details: map[string]any{"user_id": 1}, + CreatedAt: now, + }, + }, } } @@ -167,6 +177,30 @@ func (s *stubAdminService) GetUserUsageStats(ctx context.Context, userID int64, return map[string]any{"user_id": userID}, nil } +func (s *stubAdminService) ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]service.AuthIdentityMigrationReport, int64, error) { + if reportType == "" { + return s.migrationReports, int64(len(s.migrationReports)), nil + } + filtered := make([]service.AuthIdentityMigrationReport, 0, len(s.migrationReports)) + for _, report := range s.migrationReports { + if strings.EqualFold(report.ReportType, reportType) { + filtered = append(filtered, report) + } + } + return filtered, int64(len(filtered)), nil +} + +func (s *stubAdminService) GetAuthIdentityMigrationReportSummary(ctx context.Context) (*service.AuthIdentityMigrationReportSummary, error) { + summary := &service.AuthIdentityMigrationReportSummary{ + ByType: map[string]int64{}, + } + for _, report := range s.migrationReports { + summary.Total++ + summary.ByType[report.ReportType]++ + } + return summary, nil +} + 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 } diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 1453bd07..ee3fbb1e 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -172,6 +172,31 @@ func (h *UserHandler) GetByID(c *gin.Context) { response.Success(c, dto.UserFromServiceAdmin(user)) } +// GetAuthIdentityMigrationReportSummary returns aggregate migration report counts. +// GET /api/v1/admin/users/auth-identity-migration-reports/summary +func (h *UserHandler) GetAuthIdentityMigrationReportSummary(c *gin.Context) { + summary, err := h.adminService.GetAuthIdentityMigrationReportSummary(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, summary) +} + +// ListAuthIdentityMigrationReports returns paginated auth identity migration reports. +// GET /api/v1/admin/users/auth-identity-migration-reports +func (h *UserHandler) ListAuthIdentityMigrationReports(c *gin.Context) { + page, pageSize := response.ParsePagination(c) + reportType := strings.TrimSpace(c.Query("report_type")) + + reports, total, err := h.adminService.ListAuthIdentityMigrationReports(c.Request.Context(), reportType, page, pageSize) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Paginated(c, reports, total, page, pageSize) +} + // Create handles creating a new user // POST /api/v1/admin/users func (h *UserHandler) Create(c *gin.Context) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 9af0fd8e..0b5aaf09 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -210,6 +210,8 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) { func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users := admin.Group("/users") { + users.GET("/auth-identity-migration-reports/summary", h.Admin.User.GetAuthIdentityMigrationReportSummary) + users.GET("/auth-identity-migration-reports", h.Admin.User.ListAuthIdentityMigrationReports) users.GET("", h.Admin.User.List) users.GET("/:id", h.Admin.User.GetByID) users.POST("", h.Admin.User.Create) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 7c26a47c..972681a5 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "database/sql" + "encoding/json" "errors" "fmt" "io" @@ -16,6 +18,8 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/util/httputil" + + entsql "entgo.io/ent/dialect/sql" ) // AdminService interface defines admin management operations @@ -33,6 +37,8 @@ type AdminService interface { // codeType is optional - pass empty string to return all types. // Also returns totalRecharged (sum of all positive balance top-ups). GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) + ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error) + GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) // Group management ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) @@ -127,6 +133,19 @@ type UpdateUserInput struct { GroupRates map[int64]*float64 } +type AuthIdentityMigrationReport struct { + ID int64 `json:"id"` + ReportType string `json:"report_type"` + ReportKey string `json:"report_key"` + Details map[string]any `json:"details"` + CreatedAt time.Time `json:"created_at"` +} + +type AuthIdentityMigrationReportSummary struct { + Total int64 `json:"total"` + ByType map[string]int64 `json:"by_type"` +} + type CreateGroupInput struct { Name string Description string @@ -788,6 +807,122 @@ func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int return codes, result.Total, totalRecharged, nil } +func (s *adminServiceImpl) ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error) { + db, err := s.adminSQLDB() + if err != nil { + return nil, 0, err + } + + reportType = strings.TrimSpace(reportType) + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + var total int64 + if err := db.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_migration_reports +WHERE ($1 = '' OR report_type = $1)`, + reportType, + ).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := db.QueryContext(ctx, ` +SELECT id, report_type, report_key, details, created_at +FROM auth_identity_migration_reports +WHERE ($1 = '' OR report_type = $1) +ORDER BY created_at DESC, id DESC +LIMIT $2 OFFSET $3`, + reportType, + pageSize, + offset, + ) + if err != nil { + return nil, 0, err + } + defer func() { _ = rows.Close() }() + + reports := make([]AuthIdentityMigrationReport, 0) + for rows.Next() { + report, scanErr := scanAuthIdentityMigrationReport(rows) + if scanErr != nil { + return nil, 0, scanErr + } + reports = append(reports, report) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return reports, total, nil +} + +func (s *adminServiceImpl) GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) { + db, err := s.adminSQLDB() + if err != nil { + return nil, err + } + + rows, err := db.QueryContext(ctx, ` +SELECT report_type, COUNT(*) +FROM auth_identity_migration_reports +GROUP BY report_type +ORDER BY report_type ASC`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + summary := &AuthIdentityMigrationReportSummary{ + ByType: make(map[string]int64), + } + for rows.Next() { + var reportType string + var count int64 + if err := rows.Scan(&reportType, &count); err != nil { + return nil, err + } + summary.ByType[reportType] = count + summary.Total += count + } + if err := rows.Err(); err != nil { + return nil, err + } + return summary, nil +} + +func (s *adminServiceImpl) adminSQLDB() (*sql.DB, error) { + if s == nil || s.entClient == nil { + return nil, infraerrors.ServiceUnavailable("ADMIN_SQL_NOT_READY", "admin sql access is not ready") + } + driver, ok := s.entClient.Driver().(*entsql.Driver) + if !ok || driver.DB() == nil { + return nil, infraerrors.ServiceUnavailable("ADMIN_SQL_NOT_READY", "admin sql access is not ready") + } + return driver.DB(), nil +} + +func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) { + var ( + report AuthIdentityMigrationReport + details []byte + ) + if err := scanner.Scan(&report.ID, &report.ReportType, &report.ReportKey, &details, &report.CreatedAt); err != nil { + return AuthIdentityMigrationReport{}, err + } + report.Details = map[string]any{} + if len(details) > 0 { + if err := json.Unmarshal(details, &report.Details); err != nil { + return AuthIdentityMigrationReport{}, err + } + } + return report, nil +} + // Group management implementations func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} diff --git a/backend/internal/service/admin_service_identity_migration_report_test.go b/backend/internal/service/admin_service_identity_migration_report_test.go new file mode 100644 index 00000000..75ca3e5a --- /dev/null +++ b/backend/internal/service/admin_service_identity_migration_report_test.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "database/sql" + "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/enttest" + "github.com/stretchr/testify/require" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + _ "modernc.org/sqlite" +) + +func newAdminServiceMigrationReportTestClient(t *testing.T) *dbent.Client { + t.Helper() + + db, err := sql.Open("sqlite", "file:admin_service_migration_reports?mode=memory&cache=shared&_fk=1") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.Exec("PRAGMA foreign_keys = ON") + require.NoError(t, err) + + _, err = db.Exec(`CREATE TABLE auth_identity_migration_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_type TEXT NOT NULL, + report_key TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '{}', + created_at DATETIME NOT NULL + )`) + require.NoError(t, err) + + drv := entsql.OpenDB(dialect.SQLite, db) + client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestAdminServiceListAuthIdentityMigrationReports(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), + ($5, $6, $7, $8)`, + "oidc_synthetic_email_requires_manual_recovery", "u-1", `{"user_id":1}`, now, + "wechat_provider_key_conflict", "u-2", `{"user_id":2}`, now.Add(-time.Minute), + ) + require.NoError(t, err) + + svc := &adminServiceImpl{entClient: client} + reports, total, err := svc.ListAuthIdentityMigrationReports(context.Background(), "oidc_synthetic_email_requires_manual_recovery", 1, 20) + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Len(t, reports, 1) + require.Equal(t, "oidc_synthetic_email_requires_manual_recovery", reports[0].ReportType) + require.Equal(t, float64(1), reports[0].Details["user_id"]) +} + +func TestAdminServiceGetAuthIdentityMigrationReportSummary(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), + ($5, $6, $7, $8), + ($9, $10, $11, $12)`, + "oidc_synthetic_email_requires_manual_recovery", "u-1", `{"user_id":1}`, now, + "wechat_provider_key_conflict", "u-2", `{"user_id":2}`, now.Add(-time.Minute), + "wechat_provider_key_conflict", "u-3", `{"user_id":3}`, now.Add(-2*time.Minute), + ) + require.NoError(t, err) + + svc := &adminServiceImpl{entClient: client} + summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(3), summary.Total) + require.Equal(t, int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"]) + require.Equal(t, int64(2), summary.ByType["wechat_provider_key_conflict"]) +}