feat: expose auth identity migration reports

This commit is contained in:
IanShaw027
2026-04-20 22:05:33 +08:00
parent aaf4946b27
commit 3bd3027251
6 changed files with 300 additions and 0 deletions

View File

@@ -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}

View File

@@ -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"])
}