From 724f8e89a1d8a61b9847883dea8573894b6b5c02 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Mon, 20 Apr 2026 22:29:21 +0800 Subject: [PATCH] feat: resolve auth identity migration reports --- .../admin/admin_basic_handlers_test.go | 14 ++- .../handler/admin/admin_service_stub_test.go | 14 +++ .../internal/handler/admin/user_handler.go | 39 ++++++ backend/internal/server/routes/admin.go | 1 + backend/internal/service/admin_service.go | 111 ++++++++++++++++-- ..._service_identity_migration_report_test.go | 34 +++++- ...h_identity_migration_report_resolution.sql | 11 ++ 7 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 backend/migrations/114_auth_identity_migration_report_resolution.sql diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 57620005..207931d9 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/gin-gonic/gin" "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/auth-identity-migration-reports/summary", userHandler.GetAuthIdentityMigrationReportSummary) 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.POST("/api/v1/admin/users/:id/auth-identities", userHandler.BindAuthIdentity) router.POST("/api/v1/admin/users", userHandler.Create) @@ -83,6 +88,13 @@ func TestUserHandlerEndpoints(t *testing.T) { router.ServeHTTP(rec, req) 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() req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/1", nil) router.ServeHTTP(rec, req) @@ -99,7 +111,7 @@ func TestUserHandlerEndpoints(t *testing.T) { "channel_subject": "openid-123", }, } - body, _ := json.Marshal(bindBody) + body, _ = json.Marshal(bindBody) rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/1/auth-identities", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index c8c7a247..8ecadfdf 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -249,6 +249,20 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6 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) { 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 321214af..e582e322 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -7,6 +7,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "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/gin-gonic/gin" @@ -82,6 +83,10 @@ type BindUserAuthIdentityChannelRequest struct { Metadata map[string]any `json:"metadata"` } +type ResolveAuthIdentityMigrationReportRequest struct { + ResolutionNote string `json:"resolution_note"` +} + // List handles listing all users with pagination // GET /api/v1/admin/users // Query params: @@ -252,6 +257,40 @@ func (h *UserHandler) BindAuthIdentity(c *gin.Context) { 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 // 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 c78fba33..e5c0eac1 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -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", h.Admin.User.ListAuthIdentityMigrationReports) + users.POST("/auth-identity-migration-reports/:id/resolve", h.Admin.User.ResolveAuthIdentityMigrationReport) users.GET("", h.Admin.User.List) users.GET("/:id", h.Admin.User.GetByID) users.POST("/:id/auth-identities", h.Admin.User.BindAuthIdentity) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 9ff26861..3490374e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -42,6 +42,7 @@ type AdminService interface { ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error) GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) + ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*AuthIdentityMigrationReport, error) // Group management 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 { - 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"` + 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"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + ResolvedByUserID *int64 `json:"resolved_by_user_id,omitempty"` + ResolutionNote string `json:"resolution_note,omitempty"` } type AuthIdentityMigrationReportSummary struct { - Total int64 `json:"total"` - ByType map[string]int64 `json:"by_type"` + Total int64 `json:"total"` + OpenTotal int64 `json:"open_total"` + ResolvedTotal int64 `json:"resolved_total"` + ByType map[string]int64 `json:"by_type"` } type AdminBindAuthIdentityInput struct { @@ -874,7 +880,7 @@ WHERE ($1 = '' OR report_type = $1)`, } 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 WHERE ($1 = '' OR report_type = $1) ORDER BY created_at DESC, id DESC @@ -909,7 +915,11 @@ func (s *adminServiceImpl) GetAuthIdentityMigrationReportSummary(ctx context.Con } 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 GROUP BY report_type ORDER BY report_type ASC`) @@ -924,11 +934,15 @@ ORDER BY report_type ASC`) for rows.Next() { var reportType string 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 } summary.ByType[reportType] = count summary.Total += count + summary.OpenTotal += openCount + summary.ResolvedTotal += resolvedCount } if err := rows.Err(); err != nil { return nil, err @@ -936,6 +950,56 @@ ORDER BY report_type ASC`) 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) { if userID <= 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) { var ( - report AuthIdentityMigrationReport - details []byte + report AuthIdentityMigrationReport + 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 } report.Details = map[string]any{} @@ -1182,6 +1258,15 @@ func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error 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 } diff --git a/backend/internal/service/admin_service_identity_migration_report_test.go b/backend/internal/service/admin_service_identity_migration_report_test.go index 75ca3e5a..6975604b 100644 --- a/backend/internal/service/admin_service_identity_migration_report_test.go +++ b/backend/internal/service/admin_service_identity_migration_report_test.go @@ -30,7 +30,10 @@ func newAdminServiceMigrationReportTestClient(t *testing.T) *dbent.Client { report_type TEXT NOT NULL, report_key TEXT NOT NULL, 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) @@ -87,6 +90,35 @@ VALUES summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background()) require.NoError(t, err) 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(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) +} diff --git a/backend/migrations/114_auth_identity_migration_report_resolution.sql b/backend/migrations/114_auth_identity_migration_report_resolution.sql new file mode 100644 index 00000000..f84bf822 --- /dev/null +++ b/backend/migrations/114_auth_identity_migration_report_resolution.sql @@ -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);