From 9f2e3730cbf6393386e60f69b5f4b7170319a09a Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 18 Oct 2024 16:01:57 +0800 Subject: [PATCH] refactor(spx-backend): integrate Casdoor User API Signed-off-by: Aofei Sheng --- docs/openapi.yaml | 13 +- .../cmd/spx-backend/get_projects_list.yap | 7 +- spx-backend/cmd/spx-backend/gop_autogen.go | 122 +++++++++--------- spx-backend/internal/controller/controller.go | 10 +- .../internal/controller/controller_test.go | 28 +++- spx-backend/internal/controller/user.go | 10 +- spx-backend/internal/controller/user_test.go | 4 - spx-backend/internal/model/user.go | 35 ++++- spx-backend/internal/model/user_test.go | 62 ++++++++- spx-gui/src/apis/casdoor-user.ts | 15 --- spx-gui/src/apis/common/casdoor-client.ts | 33 ----- spx-gui/src/apis/common/exception.ts | 8 -- spx-gui/src/apis/common/index.ts | 2 - spx-gui/src/apis/user.ts | 34 +---- 14 files changed, 213 insertions(+), 170 deletions(-) delete mode 100644 spx-gui/src/apis/casdoor-user.ts delete mode 100644 spx-gui/src/apis/common/casdoor-client.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0acaf3c13..fc82c5900 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -774,7 +774,7 @@ paths: format: uri description: URL of the image to process for background removal. responses: - "201": + "200": description: Successfully processed the image for background removal. content: application/json: @@ -932,6 +932,17 @@ components: examples: - john description: Unique username of the user. + displayName: + type: string + examples: + - John Doe + description: Display name of the user. + avatar: + type: string + format: uri + examples: + - https://avatars.githubusercontent.com/u/10137?v=4 + description: URL of the user's avatar image. description: type: string examples: diff --git a/spx-backend/cmd/spx-backend/get_projects_list.yap b/spx-backend/cmd/spx-backend/get_projects_list.yap index 19859ee2d..ce27b5e09 100644 --- a/spx-backend/cmd/spx-backend/get_projects_list.yap +++ b/spx-backend/cmd/spx-backend/get_projects_list.yap @@ -12,14 +12,13 @@ import ( ctx := &Context -user, _ := controller.UserFromContext(ctx.Context()) params := controller.NewListProjectsParams() switch owner := ${owner}; owner { case "": - if user == nil { - replyWithCode(ctx, errorUnauthorized) - return + user, ok := controller.UserFromContext(ctx.Context()) + if !ok { + break } params.Owner = &user.Username case "*": diff --git a/spx-backend/cmd/spx-backend/gop_autogen.go b/spx-backend/cmd/spx-backend/gop_autogen.go index ed8f378e1..433b39e07 100644 --- a/spx-backend/cmd/spx-backend/gop_autogen.go +++ b/spx-backend/cmd/spx-backend/gop_autogen.go @@ -547,143 +547,141 @@ func (this *get_projects_list) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/get_projects_list.yap:20:1 if user == nil { //line cmd/spx-backend/get_projects_list.yap:21:1 - replyWithCode(ctx, errorUnauthorized) -//line cmd/spx-backend/get_projects_list.yap:22:1 - return + break } -//line cmd/spx-backend/get_projects_list.yap:24:1 +//line cmd/spx-backend/get_projects_list.yap:23:1 params.Owner = &user.Username -//line cmd/spx-backend/get_projects_list.yap:25:1 +//line cmd/spx-backend/get_projects_list.yap:24:1 case "*": -//line cmd/spx-backend/get_projects_list.yap:26:1 +//line cmd/spx-backend/get_projects_list.yap:25:1 params.Owner = nil -//line cmd/spx-backend/get_projects_list.yap:27:1 +//line cmd/spx-backend/get_projects_list.yap:26:1 default: -//line cmd/spx-backend/get_projects_list.yap:28:1 +//line cmd/spx-backend/get_projects_list.yap:27:1 params.Owner = &owner } -//line cmd/spx-backend/get_projects_list.yap:31:1 +//line cmd/spx-backend/get_projects_list.yap:30:1 if -//line cmd/spx-backend/get_projects_list.yap:31:1 +//line cmd/spx-backend/get_projects_list.yap:30:1 remixedFrom := this.Gop_Env("remixedFrom"); remixedFrom != "" { -//line cmd/spx-backend/get_projects_list.yap:32:1 +//line cmd/spx-backend/get_projects_list.yap:31:1 params.RemixedFrom = &remixedFrom } -//line cmd/spx-backend/get_projects_list.yap:35:1 +//line cmd/spx-backend/get_projects_list.yap:34:1 if -//line cmd/spx-backend/get_projects_list.yap:35:1 +//line cmd/spx-backend/get_projects_list.yap:34:1 keyword := this.Gop_Env("keyword"); keyword != "" { -//line cmd/spx-backend/get_projects_list.yap:36:1 +//line cmd/spx-backend/get_projects_list.yap:35:1 params.Keyword = &keyword } -//line cmd/spx-backend/get_projects_list.yap:39:1 +//line cmd/spx-backend/get_projects_list.yap:38:1 if -//line cmd/spx-backend/get_projects_list.yap:39:1 +//line cmd/spx-backend/get_projects_list.yap:38:1 visibility := this.Gop_Env("visibility"); visibility != "" { -//line cmd/spx-backend/get_projects_list.yap:40:1 +//line cmd/spx-backend/get_projects_list.yap:39:1 params.Visibility = &visibility } -//line cmd/spx-backend/get_projects_list.yap:43:1 +//line cmd/spx-backend/get_projects_list.yap:42:1 if -//line cmd/spx-backend/get_projects_list.yap:43:1 +//line cmd/spx-backend/get_projects_list.yap:42:1 liker := this.Gop_Env("liker"); liker != "" { -//line cmd/spx-backend/get_projects_list.yap:44:1 +//line cmd/spx-backend/get_projects_list.yap:43:1 params.Liker = &liker } -//line cmd/spx-backend/get_projects_list.yap:47:1 +//line cmd/spx-backend/get_projects_list.yap:46:1 if -//line cmd/spx-backend/get_projects_list.yap:47:1 +//line cmd/spx-backend/get_projects_list.yap:46:1 createdAfter := this.Gop_Env("createdAfter"); createdAfter != "" { -//line cmd/spx-backend/get_projects_list.yap:48:1 +//line cmd/spx-backend/get_projects_list.yap:47:1 createdAfterTime, err := time.Parse(time.RFC3339Nano, createdAfter) -//line cmd/spx-backend/get_projects_list.yap:49:1 +//line cmd/spx-backend/get_projects_list.yap:48:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:50:1 +//line cmd/spx-backend/get_projects_list.yap:49:1 replyWithCodeMsg(ctx, errorInvalidArgs, "invalid createdAfter") -//line cmd/spx-backend/get_projects_list.yap:51:1 +//line cmd/spx-backend/get_projects_list.yap:50:1 return } -//line cmd/spx-backend/get_projects_list.yap:53:1 +//line cmd/spx-backend/get_projects_list.yap:52:1 params.CreatedAfter = &createdAfterTime } -//line cmd/spx-backend/get_projects_list.yap:56:1 +//line cmd/spx-backend/get_projects_list.yap:55:1 if -//line cmd/spx-backend/get_projects_list.yap:56:1 +//line cmd/spx-backend/get_projects_list.yap:55:1 likesReceivedAfter := this.Gop_Env("likesReceivedAfter"); likesReceivedAfter != "" { -//line cmd/spx-backend/get_projects_list.yap:57:1 +//line cmd/spx-backend/get_projects_list.yap:56:1 likesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, likesReceivedAfter) -//line cmd/spx-backend/get_projects_list.yap:58:1 +//line cmd/spx-backend/get_projects_list.yap:57:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:59:1 +//line cmd/spx-backend/get_projects_list.yap:58:1 replyWithCodeMsg(ctx, errorInvalidArgs, "invalid likesReceivedAfter") -//line cmd/spx-backend/get_projects_list.yap:60:1 +//line cmd/spx-backend/get_projects_list.yap:59:1 return } -//line cmd/spx-backend/get_projects_list.yap:62:1 +//line cmd/spx-backend/get_projects_list.yap:61:1 params.LikesReceivedAfter = &likesReceivedAfterTime } -//line cmd/spx-backend/get_projects_list.yap:65:1 +//line cmd/spx-backend/get_projects_list.yap:64:1 if -//line cmd/spx-backend/get_projects_list.yap:65:1 +//line cmd/spx-backend/get_projects_list.yap:64:1 remixesReceivedAfter := this.Gop_Env("remixesReceivedAfter"); remixesReceivedAfter != "" { -//line cmd/spx-backend/get_projects_list.yap:66:1 +//line cmd/spx-backend/get_projects_list.yap:65:1 remixesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, remixesReceivedAfter) -//line cmd/spx-backend/get_projects_list.yap:67:1 +//line cmd/spx-backend/get_projects_list.yap:66:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:68:1 +//line cmd/spx-backend/get_projects_list.yap:67:1 replyWithCodeMsg(ctx, errorInvalidArgs, "invalid remixesReceivedAfter") -//line cmd/spx-backend/get_projects_list.yap:69:1 +//line cmd/spx-backend/get_projects_list.yap:68:1 return } -//line cmd/spx-backend/get_projects_list.yap:71:1 +//line cmd/spx-backend/get_projects_list.yap:70:1 params.RemixesReceivedAfter = &remixesReceivedAfterTime } -//line cmd/spx-backend/get_projects_list.yap:74:1 +//line cmd/spx-backend/get_projects_list.yap:73:1 if -//line cmd/spx-backend/get_projects_list.yap:74:1 +//line cmd/spx-backend/get_projects_list.yap:73:1 fromFollowees := this.Gop_Env("fromFollowees"); fromFollowees != "" { -//line cmd/spx-backend/get_projects_list.yap:75:1 +//line cmd/spx-backend/get_projects_list.yap:74:1 fromFolloweesBool, err := strconv.ParseBool(fromFollowees) -//line cmd/spx-backend/get_projects_list.yap:76:1 +//line cmd/spx-backend/get_projects_list.yap:75:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:77:1 +//line cmd/spx-backend/get_projects_list.yap:76:1 replyWithCodeMsg(ctx, errorInvalidArgs, "invalid fromFollowees") -//line cmd/spx-backend/get_projects_list.yap:78:1 +//line cmd/spx-backend/get_projects_list.yap:77:1 return } -//line cmd/spx-backend/get_projects_list.yap:80:1 +//line cmd/spx-backend/get_projects_list.yap:79:1 params.FromFollowees = &fromFolloweesBool } -//line cmd/spx-backend/get_projects_list.yap:83:1 +//line cmd/spx-backend/get_projects_list.yap:82:1 if -//line cmd/spx-backend/get_projects_list.yap:83:1 +//line cmd/spx-backend/get_projects_list.yap:82:1 orderBy := this.Gop_Env("orderBy"); orderBy != "" { -//line cmd/spx-backend/get_projects_list.yap:84:1 +//line cmd/spx-backend/get_projects_list.yap:83:1 params.OrderBy = controller.ListProjectsOrderBy(orderBy) } -//line cmd/spx-backend/get_projects_list.yap:87:1 +//line cmd/spx-backend/get_projects_list.yap:86:1 params.Pagination.Index = this.ParamInt("pageIndex", firstPageIndex) -//line cmd/spx-backend/get_projects_list.yap:88:1 +//line cmd/spx-backend/get_projects_list.yap:87:1 params.Pagination.Size = this.ParamInt("pageSize", defaultPageSize) -//line cmd/spx-backend/get_projects_list.yap:89:1 +//line cmd/spx-backend/get_projects_list.yap:88:1 if -//line cmd/spx-backend/get_projects_list.yap:89:1 +//line cmd/spx-backend/get_projects_list.yap:88:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/get_projects_list.yap:90:1 +//line cmd/spx-backend/get_projects_list.yap:89:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/get_projects_list.yap:91:1 +//line cmd/spx-backend/get_projects_list.yap:90:1 return } -//line cmd/spx-backend/get_projects_list.yap:94:1 +//line cmd/spx-backend/get_projects_list.yap:93:1 projects, err := this.ctrl.ListProjects(ctx.Context(), params) -//line cmd/spx-backend/get_projects_list.yap:95:1 +//line cmd/spx-backend/get_projects_list.yap:94:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:96:1 +//line cmd/spx-backend/get_projects_list.yap:95:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/get_projects_list.yap:97:1 +//line cmd/spx-backend/get_projects_list.yap:96:1 return } -//line cmd/spx-backend/get_projects_list.yap:99:1 +//line cmd/spx-backend/get_projects_list.yap:98:1 this.Json__1(projects) } func (this *get_projects_list) Classfname() string { diff --git a/spx-backend/internal/controller/controller.go b/spx-backend/internal/controller/controller.go index 08ad83dda..b892302df 100644 --- a/spx-backend/internal/controller/controller.go +++ b/spx-backend/internal/controller/controller.go @@ -39,7 +39,7 @@ type Controller struct { db *gorm.DB kodo *kodoConfig aigcClient *aigc.AigcClient - casdoorClient *casdoorsdk.Client + casdoorClient casdoorClient } // New creates a new controller. @@ -92,8 +92,14 @@ func newKodoConfig(logger *qiniuLog.Logger) *kodoConfig { } } +// casdoorClient is the client interface for Casdoor. +type casdoorClient interface { + ParseJwtToken(token string) (*casdoorsdk.Claims, error) + GetUser(name string) (*casdoorsdk.User, error) +} + // newCasdoorClient creates a new [casdoorsdk.Client]. -func newCasdoorClient(logger *qiniuLog.Logger) *casdoorsdk.Client { +func newCasdoorClient(logger *qiniuLog.Logger) casdoorClient { config := &casdoorsdk.AuthConfig{ Endpoint: mustEnv(logger, "GOP_CASDOOR_ENDPOINT"), ClientId: mustEnv(logger, "GOP_CASDOOR_CLIENTID"), diff --git a/spx-backend/internal/controller/controller_test.go b/spx-backend/internal/controller/controller_test.go index ec48e6e8d..e0e3f9614 100644 --- a/spx-backend/internal/controller/controller_test.go +++ b/spx-backend/internal/controller/controller_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" "github.com/goplus/builder/spx-backend/internal/aigc" "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model/modeltest" @@ -47,13 +48,38 @@ VTh1XIl/IELBoZ+rQXozGA== t.Setenv("GOP_CASDOOR_APPLICATIONNAME", "fake-application") } +type mockCasdoorClient struct { + casdoorClient casdoorClient + jwt string +} + +func newMockCasdoorClient(casdoorClient casdoorClient, jwt string) *mockCasdoorClient { + return &mockCasdoorClient{casdoorClient: casdoorClient, jwt: jwt} +} + +func (m *mockCasdoorClient) ParseJwtToken(token string) (*casdoorsdk.Claims, error) { + return m.casdoorClient.ParseJwtToken(token) +} + +func (m *mockCasdoorClient) GetUser(name string) (*casdoorsdk.User, error) { + claims, err := m.casdoorClient.ParseJwtToken(m.jwt) + if err != nil { + return nil, err + } + return &claims.User, nil +} + +const fakeUserToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJvd25lciI6IkdvUGx1cyIsIm5hbWUiOiJmYWtlLW5hbWUiLCJpZCI6IjEiLCJpc3MiOiJHb1BsdXMiLCJzdWIiOiIxIiwiZXhwIjo0ODcwNDI5MDQwfQ." + + "X0T-v-RJggMRy3Mmui2FoRH-_4DQsNA6DekUx1BfIljTZaEbHbuW59dSlKQ-i2MuYD7_8mI18vZqT3iysbKQ1T70NF97B_A130ML3pulZWlj1ZokgjCkVug25QRbq_N7JMd4apJZFlyZj8Bd2VfqtAKMlJJ4HzKzNXB-GBogDVlKeu4xJ1BiXO2rHL1PNa5KyKLSSMXmuP_Wc108RXZ0BiKDE30IG1fvcyvudXcetmltuWjuU6JRj3FGedxuVEqZLXqcm13dCxHnuFV1x1XU9KExcDvVyVB91FpBe5npzYp6WMX0fx9vU1b4eJ69EZoeMdMolhmvYInT1G8r1PEmbg" + func newTestController(t *testing.T) (ctrl *Controller, dbMock sqlmock.Sqlmock, closeDB func() error) { setTestEnv(t) logger := log.GetLogger() kodoConfig := newKodoConfig(logger) aigcClient := aigc.NewAigcClient(mustEnv(logger, "AIGC_ENDPOINT")) - casdoorClient := newCasdoorClient(logger) + casdoorClient := newMockCasdoorClient(newCasdoorClient(logger), fakeUserToken) db, dbMock, closeDB, err := modeltest.NewMockDB() require.NoError(t, err) diff --git a/spx-backend/internal/controller/user.go b/spx-backend/internal/controller/user.go index 2c598b428..5a80d50cd 100644 --- a/spx-backend/internal/controller/user.go +++ b/spx-backend/internal/controller/user.go @@ -16,6 +16,8 @@ type UserDTO struct { ModelDTO Username string `json:"username"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` Description string `json:"description"` FollowerCount int64 `json:"followerCount"` FollowingCount int64 `json:"followingCount"` @@ -29,6 +31,8 @@ func toUserDTO(mUser model.User) UserDTO { return UserDTO{ ModelDTO: toModelDTO(mUser.Model), Username: mUser.Username, + DisplayName: mUser.DisplayName, + Avatar: mUser.Avatar, Description: mUser.Description, FollowerCount: mUser.FollowerCount, FollowingCount: mUser.FollowingCount, @@ -71,7 +75,11 @@ func (ctrl *Controller) UserFromToken(ctx context.Context, token string) (*model if err != nil { return nil, fmt.Errorf("ctrl.casdoorClient.ParseJwtToken failed: %w: %w", ErrUnauthorized, err) } - mUser, err := model.FirstOrCreateUser(ctx, ctrl.db, claims.Name) + casdoorUser, err := ctrl.casdoorClient.GetUser(claims.Name) + if err != nil { + return nil, fmt.Errorf("ctrl.casdoorClient.GetUser failed: %w", err) + } + mUser, err := model.FirstOrCreateUser(ctx, ctrl.db, casdoorUser) if err != nil { return nil, err } diff --git a/spx-backend/internal/controller/user_test.go b/spx-backend/internal/controller/user_test.go index 9ea5fc454..edb026f32 100644 --- a/spx-backend/internal/controller/user_test.go +++ b/spx-backend/internal/controller/user_test.go @@ -85,10 +85,6 @@ func TestEnsureUser(t *testing.T) { }) } -const fakeUserToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + - "eyJvd25lciI6IkdvUGx1cyIsIm5hbWUiOiJmYWtlLW5hbWUiLCJpZCI6IjEiLCJpc3MiOiJHb1BsdXMiLCJzdWIiOiIxIiwiZXhwIjo0ODcwNDI5MDQwfQ." + - "X0T-v-RJggMRy3Mmui2FoRH-_4DQsNA6DekUx1BfIljTZaEbHbuW59dSlKQ-i2MuYD7_8mI18vZqT3iysbKQ1T70NF97B_A130ML3pulZWlj1ZokgjCkVug25QRbq_N7JMd4apJZFlyZj8Bd2VfqtAKMlJJ4HzKzNXB-GBogDVlKeu4xJ1BiXO2rHL1PNa5KyKLSSMXmuP_Wc108RXZ0BiKDE30IG1fvcyvudXcetmltuWjuU6JRj3FGedxuVEqZLXqcm13dCxHnuFV1x1XU9KExcDvVyVB91FpBe5npzYp6WMX0fx9vU1b4eJ69EZoeMdMolhmvYInT1G8r1PEmbg" - func TestControllerUserFromToken(t *testing.T) { t.Run("Normal", func(t *testing.T) { ctrl, dbMock, closeDB := newTestController(t) diff --git a/spx-backend/internal/model/user.go b/spx-backend/internal/model/user.go index c09cef2c0..99452064a 100644 --- a/spx-backend/internal/model/user.go +++ b/spx-backend/internal/model/user.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -15,6 +16,12 @@ type User struct { // Username is the unique username. Username string `gorm:"column:username;index:,unique,where:deleted_at IS NULL"` + // DisplayName is the display name. + DisplayName string `gorm:"column:display_name"` + + // Avatar is the URL of the avatar image. + Avatar string `gorm:"column:avatar"` + // Description is the brief bio or description. Description string `gorm:"column:description"` @@ -40,26 +47,42 @@ func (User) TableName() string { } // FirstOrCreateUser gets or creates a user. -func FirstOrCreateUser(ctx context.Context, db *gorm.DB, username string) (*User, error) { +func FirstOrCreateUser(ctx context.Context, db *gorm.DB, casdoorUser *casdoorsdk.User) (*User, error) { var mUser User if err := db.WithContext(ctx). - Where("username = ?", username). - Attrs(User{Username: username}). + Where("username = ?", casdoorUser.Name). + Attrs(User{ + Username: casdoorUser.Name, + DisplayName: casdoorUser.DisplayName, + Avatar: casdoorUser.Avatar, + }). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "username"}}, DoNothing: true, }). FirstOrCreate(&mUser). Error; err != nil { - return nil, fmt.Errorf("failed to get/create user %s: %w", username, err) + return nil, fmt.Errorf("failed to get/create user %s: %w", casdoorUser.Name, err) } if mUser.ID == 0 { // Unfortunatlly, MySQL doesn't support the RETURNING clause. if err := db.WithContext(ctx). - Where("username = ?", username). + Where("username = ?", casdoorUser.Name). First(&mUser). Error; err != nil { - return nil, fmt.Errorf("failed to get user %s: %w", username, err) + return nil, fmt.Errorf("failed to get user %s: %w", casdoorUser.Name, err) + } + } + userUpdates := map[string]any{} + if mUser.DisplayName != casdoorUser.DisplayName { + userUpdates["display_name"] = casdoorUser.DisplayName + } + if mUser.Avatar != casdoorUser.Avatar { + userUpdates["avatar"] = casdoorUser.Avatar + } + if len(userUpdates) > 0 { + if err := db.WithContext(ctx).Model(&mUser).Updates(userUpdates).Error; err != nil { + return nil, fmt.Errorf("failed to update user %s: %w", mUser.Username, err) } } return &mUser, nil diff --git a/spx-backend/internal/model/user_test.go b/spx-backend/internal/model/user_test.go index 9d9864321..aecfc6220 100644 --- a/spx-backend/internal/model/user_test.go +++ b/spx-backend/internal/model/user_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" "github.com/goplus/builder/spx-backend/internal/model/modeltest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,9 +24,17 @@ func TestFirstOrCreateUser(t *testing.T) { generateUserDBRows, err := modeltest.NewDBRowsGenerator(db, User{}) require.NoError(t, err) + expectedCasdoorUser := casdoorsdk.User{ + Name: "john", + DisplayName: "John Doe", + Avatar: "https://example.com/avatar.jpg", + } + mExpectedUser := User{ Model: Model{ID: 1}, - Username: "john", + Username: expectedCasdoorUser.Name, + DisplayName: expectedCasdoorUser.DisplayName, + Avatar: expectedCasdoorUser.Avatar, Description: "I'm John", FollowerCount: 10, FollowingCount: 5, @@ -48,7 +57,7 @@ func TestFirstOrCreateUser(t *testing.T) { WithArgs(dbMockArgs...). WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUser)...)) - mUser, err := FirstOrCreateUser(context.Background(), db, mExpectedUser.Username) + mUser, err := FirstOrCreateUser(context.Background(), db, &expectedCasdoorUser) require.NoError(t, err) assert.Equal(t, mExpectedUser, *mUser) @@ -75,7 +84,11 @@ func TestFirstOrCreateUser(t *testing.T) { Columns: []clause.Column{{Name: "username"}}, DoNothing: true, }). - Create(&User{Username: mExpectedUser.Username}). + Create(&User{ + Username: mExpectedUser.Username, + DisplayName: mExpectedUser.DisplayName, + Avatar: mExpectedUser.Avatar, + }). Statement dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) dbMockArgs[0] = sqlmock.AnyArg() @@ -95,10 +108,51 @@ func TestFirstOrCreateUser(t *testing.T) { WithArgs(dbMockArgs...). WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExpectedUser)...)) - mUser, err := FirstOrCreateUser(context.Background(), db, mExpectedUser.Username) + mUser, err := FirstOrCreateUser(context.Background(), db, &expectedCasdoorUser) require.NoError(t, err) assert.Equal(t, mExpectedUser, *mUser) require.NoError(t, dbMock.ExpectationsWereMet()) }) + + t.Run("UpdateExistingUser", func(t *testing.T) { + db, dbMock, closeDB, err := modeltest.NewMockDB() + require.NoError(t, err) + defer closeDB() + + mExistingUser := mExpectedUser + mExistingUser.DisplayName = "Old Name" + mExistingUser.Avatar = "https://example.com/old-avatar.jpg" + + dbMockStmt := db.Session(&gorm.Session{DryRun: true}). + Where("username = ?", mExistingUser.Username). + First(&User{}). + Statement + dbMockArgs := modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMock.ExpectQuery(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnRows(sqlmock.NewRows(userDBColumns).AddRows(generateUserDBRows(mExistingUser)...)) + + dbMock.ExpectBegin() + dbMockStmt = db.Session(&gorm.Session{DryRun: true, SkipDefaultTransaction: true}). + Model(&User{Model: mExistingUser.Model}). + Updates(map[string]any{ + "display_name": mExpectedUser.DisplayName, + "avatar": mExpectedUser.Avatar, + }). + Statement + dbMockArgs = modeltest.ToDriverValueSlice(dbMockStmt.Vars...) + dbMockArgs[2] = sqlmock.AnyArg() + dbMock.ExpectExec(regexp.QuoteMeta(dbMockStmt.SQL.String())). + WithArgs(dbMockArgs...). + WillReturnResult(sqlmock.NewResult(0, 1)) + dbMock.ExpectCommit() + + mUser, err := FirstOrCreateUser(context.Background(), db, &expectedCasdoorUser) + require.NoError(t, err) + assert.Equal(t, mExpectedUser.DisplayName, mUser.DisplayName) + assert.Equal(t, mExpectedUser.Avatar, mUser.Avatar) + + require.NoError(t, dbMock.ExpectationsWereMet()) + }) } diff --git a/spx-gui/src/apis/casdoor-user.ts b/spx-gui/src/apis/casdoor-user.ts deleted file mode 100644 index 78b513397..000000000 --- a/spx-gui/src/apis/casdoor-user.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { casdoorClient } from './common' -import { casdoorConfig } from '@/utils/env' - -export type CasdoorUser = { - id: string - name: string - displayName: string - avatar: string -} - -export async function getCasdoorUser(name: string): Promise { - return casdoorClient.get('/api/get-user', { - id: `${casdoorConfig.organizationName}/${name}` - }) as Promise -} diff --git a/spx-gui/src/apis/common/casdoor-client.ts b/spx-gui/src/apis/common/casdoor-client.ts deleted file mode 100644 index 774b8a221..000000000 --- a/spx-gui/src/apis/common/casdoor-client.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { casdoorConfig } from '@/utils/env' -import { CasdoorApiException } from './exception' -import { useRequest, withQueryParams, type RequestOptions, type QueryParams } from '.' - -/** Response body when exception encountered for Casdoor API calling */ -export type CasdoorApiExceptionPayload = { - /** Message for developer reading */ - msg: string -} - -function isCasdoorApiExceptionPayload(body: any): body is CasdoorApiExceptionPayload { - return body && typeof body.msg === 'string' -} - -export class CasdoorClient { - private request = useRequest(casdoorConfig.serverUrl, async (resp) => { - if (!resp.ok) { - const body = await resp.json() - if (!isCasdoorApiExceptionPayload(body)) { - throw new Error('casdoor api call failed') - } - throw new CasdoorApiException(body.msg) - } - if (resp.status === 204) return null - const body = await resp.json() - return body.data - }) - - get(path: string, params?: QueryParams, options?: Omit) { - if (params != null) path = withQueryParams(path, params) - return this.request(path, null, { ...options, method: 'GET' }) - } -} diff --git a/spx-gui/src/apis/common/exception.ts b/spx-gui/src/apis/common/exception.ts index b61435cd6..3c5b9e9fb 100644 --- a/spx-gui/src/apis/common/exception.ts +++ b/spx-gui/src/apis/common/exception.ts @@ -48,11 +48,3 @@ const codeMessages: Record = { zh: '服务器出问题了' } } - -export class CasdoorApiException extends Exception { - name = 'CasdoorApiError' - userMessage = null - constructor(message: string) { - super(message) - } -} diff --git a/spx-gui/src/apis/common/index.ts b/spx-gui/src/apis/common/index.ts index 89890b3d7..9d9c3c166 100644 --- a/spx-gui/src/apis/common/index.ts +++ b/spx-gui/src/apis/common/index.ts @@ -1,6 +1,5 @@ import { Exception } from '@/utils/exception' import { Client } from './client' -import { CasdoorClient } from './casdoor-client' /** TokenProvider provides access token used for the Authorization header */ export type TokenProvider = () => Promise @@ -124,4 +123,3 @@ export function timeStringify(time: number) { } export const client = new Client() -export const casdoorClient = new CasdoorClient() diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index e0aa1d8a6..dbf8e012b 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -1,6 +1,5 @@ import { client, type ByPage, type PaginationParams } from './common' import { ApiException, ApiExceptionCode } from './common/exception' -import { getCasdoorUser } from './casdoor-user' export type User = { /** Unique identifier */ @@ -11,35 +10,22 @@ export type User = { updatedAt: string /** Unique username of the user */ username: string - /** Brief bio or description of the user */ - description: string - - /** Name to display, from Casdoor */ + /** Display name of the user */ displayName: string - /** Avatar URL, from Casdoor */ + /** URL of the user's avatar image */ avatar: string -} - -async function completeUserWithCasdoor(user: Omit): Promise { - // TODO: cache the result of `getCasdoorUser` to avoid redundant requests? - const casdoorUser = await getCasdoorUser(user.username) - return { - ...user, - displayName: casdoorUser.displayName, - avatar: casdoorUser.avatar - } + /** Brief bio or description of the user */ + description: string } export async function getUser(name: string): Promise { - const user = await (client.get(`/user/${encodeURIComponent(name)}`) as Promise) - return completeUserWithCasdoor(user) + return await (client.get(`/user/${encodeURIComponent(name)}`) as Promise) } export type UpdateProfileParams = Pick export async function updateProfile(params: UpdateProfileParams) { - const user = await (client.put(`/user`, params) as Promise) - return completeUserWithCasdoor(user) + return await (client.put(`/user`, params) as Promise) } export type ListUserParams = PaginationParams & { @@ -54,13 +40,7 @@ export type ListUserParams = PaginationParams & { } export async function listUsers(params: ListUserParams) { - const { total, data } = await (client.get('/users/list', params) as Promise>) - return { - total, - // There is a performance issue here, as we are calling `completeUserWithCasdoor` for each user. Unfortunately, - // Casdoor doesn't provide a batch API for fetching multiple user profiles by their usernames. - data: await Promise.all(data.map(completeUserWithCasdoor)) - } + return await (client.get('/users/list', params) as Promise>) } /**