refactor(admin): remove auth migration reports

This commit is contained in:
IanShaw027
2026-04-21 17:34:18 +08:00
parent c624cce88e
commit d08757ce9e
28 changed files with 20 additions and 1884 deletions

View File

@@ -7,7 +7,6 @@ import (
"net/http/httptest"
"testing"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -23,12 +22,6 @@ 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.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)
@@ -78,23 +71,6 @@ 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)
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)
@@ -111,7 +87,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")

View File

@@ -17,7 +17,6 @@ type stubAdminService struct {
proxies []service.Proxy
proxyCounts []service.ProxyWithAccountCount
redeems []service.RedeemCode
migrationReports []service.AuthIdentityMigrationReport
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
@@ -134,15 +133,6 @@ 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,
},
},
}
}
@@ -193,30 +183,6 @@ 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) BindUserAuthIdentity(ctx context.Context, userID int64, input service.AdminBindAuthIdentityInput) (*service.AdminBoundAuthIdentity, error) {
s.boundAuthIdentityFor = userID
copied := input
@@ -263,20 +229,6 @@ 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
}

View File

@@ -7,7 +7,6 @@ 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"
@@ -83,10 +82,6 @@ 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:
@@ -193,31 +188,6 @@ 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)
}
// BindAuthIdentity manually binds a canonical auth identity to a user.
// POST /api/v1/admin/users/:id/auth-identities
func (h *UserHandler) BindAuthIdentity(c *gin.Context) {
@@ -257,40 +227,6 @@ 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) {

View File

@@ -29,7 +29,6 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
Username: "activity-user",
Role: service.RoleUser,
Status: service.StatusActive,
LastLoginAt: &lastLoginAt,
LastActiveAt: &lastActiveAt,
LastUsedAt: &lastUsedAt,
CreatedAt: lastLoginAt.Add(-24 * time.Hour),
@@ -57,7 +56,6 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
Code int `json:"code"`
Data struct {
Items []struct {
LastLoginAt *time.Time `json:"last_login_at"`
LastActiveAt *time.Time `json:"last_active_at"`
LastUsedAt *time.Time `json:"last_used_at"`
} `json:"items"`
@@ -66,7 +64,6 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Len(t, resp.Data.Items, 1)
require.WithinDuration(t, lastLoginAt, *resp.Data.Items[0].LastLoginAt, time.Second)
require.WithinDuration(t, lastActiveAt, *resp.Data.Items[0].LastActiveAt, time.Second)
require.WithinDuration(t, lastUsedAt, *resp.Data.Items[0].LastUsedAt, time.Second)
}
@@ -86,7 +83,6 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
Username: "detail-user",
Role: service.RoleUser,
Status: service.StatusActive,
LastLoginAt: &lastLoginAt,
LastActiveAt: &lastActiveAt,
LastUsedAt: &lastUsedAt,
CreatedAt: lastLoginAt.Add(-24 * time.Hour),
@@ -107,14 +103,12 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
var resp struct {
Code int `json:"code"`
Data struct {
LastLoginAt *time.Time `json:"last_login_at"`
LastActiveAt *time.Time `json:"last_active_at"`
LastUsedAt *time.Time `json:"last_used_at"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.WithinDuration(t, lastLoginAt, *resp.Data.LastLoginAt, time.Second)
require.WithinDuration(t, lastActiveAt, *resp.Data.LastActiveAt, time.Second)
require.WithinDuration(t, lastUsedAt, *resp.Data.LastUsedAt, time.Second)
}

View File

@@ -2293,6 +2293,10 @@ func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.Use
return nil
}
func (r *oauthPendingFlowUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
return r.client.User.UpdateOneID(userID).SetLastActiveAt(activeAt).Exec(ctx)
}
func (r *oauthPendingFlowUserRepo) Delete(ctx context.Context, id int64) error {
if r.options.rejectDeleteWhileAuthIdentityExists {
count, err := r.client.AuthIdentity.Query().Where(authidentity.UserIDEQ(id)).Count(ctx)

View File

@@ -21,7 +21,6 @@ func UserFromServiceShallow(u *service.User) *User {
Concurrency: u.Concurrency,
Status: u.Status,
AllowedGroups: u.AllowedGroups,
LastLoginAt: u.LastLoginAt,
LastActiveAt: u.LastActiveAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,

View File

@@ -15,7 +15,6 @@ type User struct {
Concurrency int `json:"concurrency"`
Status string `json:"status"`
AllowedGroups []int64 `json:"allowed_groups"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@@ -21,16 +21,13 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
Username: "admin",
Role: service.RoleAdmin,
Status: service.StatusActive,
LastLoginAt: &lastLoginAt,
LastActiveAt: &lastActiveAt,
LastUsedAt: &lastUsedAt,
})
require.NotNil(t, out)
require.NotNil(t, out.LastLoginAt)
require.NotNil(t, out.LastActiveAt)
require.NotNil(t, out.LastUsedAt)
require.WithinDuration(t, lastLoginAt, *out.LastLoginAt, time.Second)
require.WithinDuration(t, lastActiveAt, *out.LastActiveAt, time.Second)
require.WithinDuration(t, lastUsedAt, *out.LastUsedAt, time.Second)
}

View File

@@ -99,6 +99,12 @@ func (s *userHandlerRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64)
func (s *userHandlerRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
return nil, nil
}
func (s *userHandlerRepoStub) UpdateUserLastActiveAt(_ context.Context, _ int64, activeAt time.Time) error {
if s.user != nil {
s.user.LastActiveAt = &activeAt
}
return nil
}
func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
return nil
}

View File

@@ -1,148 +0,0 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
type AuthIdentityMigrationReport struct {
ID int64
ReportType string
ReportKey string
Details map[string]any
CreatedAt time.Time
}
type AuthIdentityMigrationReportQuery struct {
ReportType string
Limit int
Offset int
}
type AuthIdentityMigrationReportSummary struct {
Total int64
ByType map[string]int64
}
func (r *userRepository) ListAuthIdentityMigrationReports(ctx context.Context, query AuthIdentityMigrationReportQuery) ([]AuthIdentityMigrationReport, error) {
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
if exec == nil {
return nil, fmt.Errorf("sql executor is not configured")
}
limit := query.Limit
if limit <= 0 {
limit = 100
}
rows, err := exec.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`,
strings.TrimSpace(query.ReportType),
limit,
query.Offset,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
reports := make([]AuthIdentityMigrationReport, 0)
for rows.Next() {
report, scanErr := scanAuthIdentityMigrationReport(rows)
if scanErr != nil {
return nil, scanErr
}
reports = append(reports, report)
}
if err := rows.Err(); err != nil {
return nil, err
}
return reports, nil
}
func (r *userRepository) GetAuthIdentityMigrationReport(ctx context.Context, reportType, reportKey string) (*AuthIdentityMigrationReport, error) {
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
if exec == nil {
return nil, fmt.Errorf("sql executor is not configured")
}
rows, err := exec.QueryContext(ctx, `
SELECT id, report_type, report_key, details, created_at
FROM auth_identity_migration_reports
WHERE report_type = $1 AND report_key = $2
LIMIT 1`,
strings.TrimSpace(reportType),
strings.TrimSpace(reportKey),
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return nil, sql.ErrNoRows
}
report, err := scanAuthIdentityMigrationReport(rows)
if err != nil {
return nil, err
}
return &report, rows.Err()
}
func (r *userRepository) SummarizeAuthIdentityMigrationReports(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) {
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
if exec == nil {
return nil, fmt.Errorf("sql executor is not configured")
}
rows, err := exec.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 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
}

View File

@@ -36,7 +36,6 @@ TRUNCATE TABLE
auth_identity_channels,
auth_identities,
pending_auth_sessions,
auth_identity_migration_reports,
user_provider_default_grants,
user_avatars
RESTART IDENTITY`)
@@ -393,36 +392,6 @@ func (s *UserProfileIdentityRepoSuite) TestUserAvatarCRUDAndUserLookup() {
s.Require().Nil(loadedAvatar)
}
func (s *UserProfileIdentityRepoSuite) TestAuthIdentityMigrationReportHelpers_ListAndSummarize() {
_, err := integrationDB.ExecContext(s.ctx, `
INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at)
VALUES
('wechat_openid_only_requires_remediation', 'u-1', '{"user_id":1}'::jsonb, '2026-04-20T10:00:00Z'),
('wechat_openid_only_requires_remediation', 'u-2', '{"user_id":2}'::jsonb, '2026-04-20T11:00:00Z'),
('oidc_synthetic_email_requires_manual_recovery', 'u-3', '{"user_id":3}'::jsonb, '2026-04-20T12:00:00Z')`)
s.Require().NoError(err)
summary, err := s.repo.SummarizeAuthIdentityMigrationReports(s.ctx)
s.Require().NoError(err)
s.Require().Equal(int64(3), summary.Total)
s.Require().Equal(int64(2), summary.ByType["wechat_openid_only_requires_remediation"])
s.Require().Equal(int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"])
reports, err := s.repo.ListAuthIdentityMigrationReports(s.ctx, AuthIdentityMigrationReportQuery{
ReportType: "wechat_openid_only_requires_remediation",
Limit: 10,
})
s.Require().NoError(err)
s.Require().Len(reports, 2)
s.Require().Equal("u-2", reports[0].ReportKey)
s.Require().Equal(float64(2), reports[0].Details["user_id"])
report, err := s.repo.GetAuthIdentityMigrationReport(s.ctx, "oidc_synthetic_email_requires_manual_recovery", "u-3")
s.Require().NoError(err)
s.Require().Equal("u-3", report.ReportKey)
s.Require().Equal(float64(3), report.Details["user_id"])
}
func (s *UserProfileIdentityRepoSuite) TestUpdateUserLastLoginAndActiveAt_UsesDedicatedColumns() {
user := s.mustCreateUser("activity")
loginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)

View File

@@ -445,10 +445,6 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
case "created_at":
field = dbuser.FieldCreatedAt
defaultField = false
case "last_login_at":
field = dbuser.FieldLastLoginAt
defaultField = false
nullsLastField = true
case "last_active_at":
field = dbuser.FieldLastActiveAt
defaultField = false

View File

@@ -95,27 +95,6 @@ func (s *UserRepoSuite) TestUpdate_PersistsSignupSourceAndActivityTimestamps() {
s.Require().True(got.LastActiveAt.Equal(lastActiveAt))
}
func (s *UserRepoSuite) TestListWithFilters_SortByLastLoginAtDesc() {
older := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Microsecond)
newer := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond)
s.mustCreateUser(&service.User{Email: "nil-login@example.com"})
s.mustCreateUser(&service.User{Email: "older-login@example.com", LastLoginAt: &older})
s.mustCreateUser(&service.User{Email: "newer-login@example.com", LastLoginAt: &newer})
users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
Page: 1,
PageSize: 10,
SortBy: "last_login_at",
SortOrder: "desc",
}, service.UserListFilters{})
s.Require().NoError(err)
s.Require().Len(users, 3)
s.Require().Equal("newer-login@example.com", users[0].Email)
s.Require().Equal("older-login@example.com", users[1].Email)
s.Require().Equal("nil-login@example.com", users[2].Email)
}
func (s *UserRepoSuite) TestListWithFilters_SortByLastActiveAtAsc() {
earlier := time.Now().Add(-3 * time.Hour).UTC().Truncate(time.Microsecond)
later := time.Now().Add(-45 * time.Minute).UTC().Truncate(time.Microsecond)

View File

@@ -210,9 +210,6 @@ 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.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)

View File

@@ -39,10 +39,7 @@ 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)
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,24 +134,6 @@ 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"`
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"`
OpenTotal int64 `json:"open_total"`
ResolvedTotal int64 `json:"resolved_total"`
ByType map[string]int64 `json:"by_type"`
}
type AdminBindAuthIdentityInput struct {
ProviderType string
ProviderKey string
@@ -874,152 +853,6 @@ 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, 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
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(*),
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`)
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
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
}
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")
@@ -1252,44 +1085,6 @@ func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any {
return out
}
func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) {
var (
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,
&resolvedAt,
&resolvedByUserID,
&resolutionNote,
); 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
}
}
if resolvedAt.Valid {
report.ResolvedAt = &resolvedAt.Time
}
if resolvedByUserID.Valid {
report.ResolvedByUserID = &resolvedByUserID.Int64
}
if resolutionNote.Valid {
report.ResolutionNote = resolutionNote.String
}
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}

View File

@@ -1,124 +0,0 @@
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,
resolved_at DATETIME NULL,
resolved_by_user_id INTEGER NULL,
resolution_note TEXT NOT NULL DEFAULT ''
)`)
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(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)
}