diff --git a/_mocks/opencsg.com/csghub-server/builder/store/database/mock_UserStore.go b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_UserStore.go index 2b5bfa669..982fd6614 100644 --- a/_mocks/opencsg.com/csghub-server/builder/store/database/mock_UserStore.go +++ b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_UserStore.go @@ -1207,6 +1207,55 @@ func (_c *MockUserStore_UpdateLabels_Call) RunAndReturn(run func(context.Context return _c } +// UpdatePhone provides a mock function with given fields: ctx, userID, phone, phoneArea +func (_m *MockUserStore) UpdatePhone(ctx context.Context, userID int64, phone string, phoneArea string) error { + ret := _m.Called(ctx, userID, phone, phoneArea) + + if len(ret) == 0 { + panic("no return value specified for UpdatePhone") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string, string) error); ok { + r0 = rf(ctx, userID, phone, phoneArea) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockUserStore_UpdatePhone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePhone' +type MockUserStore_UpdatePhone_Call struct { + *mock.Call +} + +// UpdatePhone is a helper method to define mock.On call +// - ctx context.Context +// - userID int64 +// - phone string +// - phoneArea string +func (_e *MockUserStore_Expecter) UpdatePhone(ctx interface{}, userID interface{}, phone interface{}, phoneArea interface{}) *MockUserStore_UpdatePhone_Call { + return &MockUserStore_UpdatePhone_Call{Call: _e.mock.On("UpdatePhone", ctx, userID, phone, phoneArea)} +} + +func (_c *MockUserStore_UpdatePhone_Call) Run(run func(ctx context.Context, userID int64, phone string, phoneArea string)) *MockUserStore_UpdatePhone_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockUserStore_UpdatePhone_Call) Return(_a0 error) *MockUserStore_UpdatePhone_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockUserStore_UpdatePhone_Call) RunAndReturn(run func(context.Context, int64, string, string) error) *MockUserStore_UpdatePhone_Call { + _c.Call.Return(run) + return _c +} + // UpdateVerifyStatus provides a mock function with given fields: ctx, uuid, status func (_m *MockUserStore) UpdateVerifyStatus(ctx context.Context, uuid string, status types.VerifyStatus) error { ret := _m.Called(ctx, uuid, status) diff --git a/_mocks/opencsg.com/csghub-server/user/component/mock_UserComponent.go b/_mocks/opencsg.com/csghub-server/user/component/mock_UserComponent.go index a85a3d5f9..e8bdab8ab 100644 --- a/_mocks/opencsg.com/csghub-server/user/component/mock_UserComponent.go +++ b/_mocks/opencsg.com/csghub-server/user/component/mock_UserComponent.go @@ -952,6 +952,125 @@ func (_c *MockUserComponent_ResetUserTags_Call) RunAndReturn(run func(context.Co return _c } +// SendPublicSMSCode provides a mock function with given fields: ctx, req +func (_m *MockUserComponent) SendPublicSMSCode(ctx context.Context, req types.SendPublicSMSCodeRequest) (*types.SendSMSCodeResponse, error) { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for SendPublicSMSCode") + } + + var r0 *types.SendSMSCodeResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, types.SendPublicSMSCodeRequest) (*types.SendSMSCodeResponse, error)); ok { + return rf(ctx, req) + } + if rf, ok := ret.Get(0).(func(context.Context, types.SendPublicSMSCodeRequest) *types.SendSMSCodeResponse); ok { + r0 = rf(ctx, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.SendSMSCodeResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, types.SendPublicSMSCodeRequest) error); ok { + r1 = rf(ctx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUserComponent_SendPublicSMSCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendPublicSMSCode' +type MockUserComponent_SendPublicSMSCode_Call struct { + *mock.Call +} + +// SendPublicSMSCode is a helper method to define mock.On call +// - ctx context.Context +// - req types.SendPublicSMSCodeRequest +func (_e *MockUserComponent_Expecter) SendPublicSMSCode(ctx interface{}, req interface{}) *MockUserComponent_SendPublicSMSCode_Call { + return &MockUserComponent_SendPublicSMSCode_Call{Call: _e.mock.On("SendPublicSMSCode", ctx, req)} +} + +func (_c *MockUserComponent_SendPublicSMSCode_Call) Run(run func(ctx context.Context, req types.SendPublicSMSCodeRequest)) *MockUserComponent_SendPublicSMSCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.SendPublicSMSCodeRequest)) + }) + return _c +} + +func (_c *MockUserComponent_SendPublicSMSCode_Call) Return(_a0 *types.SendSMSCodeResponse, _a1 error) *MockUserComponent_SendPublicSMSCode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUserComponent_SendPublicSMSCode_Call) RunAndReturn(run func(context.Context, types.SendPublicSMSCodeRequest) (*types.SendSMSCodeResponse, error)) *MockUserComponent_SendPublicSMSCode_Call { + _c.Call.Return(run) + return _c +} + +// SendSMSCode provides a mock function with given fields: ctx, uid, req +func (_m *MockUserComponent) SendSMSCode(ctx context.Context, uid string, req types.SendSMSCodeRequest) (*types.SendSMSCodeResponse, error) { + ret := _m.Called(ctx, uid, req) + + if len(ret) == 0 { + panic("no return value specified for SendSMSCode") + } + + var r0 *types.SendSMSCodeResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, types.SendSMSCodeRequest) (*types.SendSMSCodeResponse, error)); ok { + return rf(ctx, uid, req) + } + if rf, ok := ret.Get(0).(func(context.Context, string, types.SendSMSCodeRequest) *types.SendSMSCodeResponse); ok { + r0 = rf(ctx, uid, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.SendSMSCodeResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, types.SendSMSCodeRequest) error); ok { + r1 = rf(ctx, uid, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUserComponent_SendSMSCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSMSCode' +type MockUserComponent_SendSMSCode_Call struct { + *mock.Call +} + +// SendSMSCode is a helper method to define mock.On call +// - ctx context.Context +// - uid string +// - req types.SendSMSCodeRequest +func (_e *MockUserComponent_Expecter) SendSMSCode(ctx interface{}, uid interface{}, req interface{}) *MockUserComponent_SendSMSCode_Call { + return &MockUserComponent_SendSMSCode_Call{Call: _e.mock.On("SendSMSCode", ctx, uid, req)} +} + +func (_c *MockUserComponent_SendSMSCode_Call) Run(run func(ctx context.Context, uid string, req types.SendSMSCodeRequest)) *MockUserComponent_SendSMSCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(types.SendSMSCodeRequest)) + }) + return _c +} + +func (_c *MockUserComponent_SendSMSCode_Call) Return(_a0 *types.SendSMSCodeResponse, _a1 error) *MockUserComponent_SendSMSCode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUserComponent_SendSMSCode_Call) RunAndReturn(run func(context.Context, string, types.SendSMSCodeRequest) (*types.SendSMSCodeResponse, error)) *MockUserComponent_SendSMSCode_Call { + _c.Call.Return(run) + return _c +} + // Signin provides a mock function with given fields: ctx, code, state func (_m *MockUserComponent) Signin(ctx context.Context, code string, state string) (*types.JWTClaims, string, error) { ret := _m.Called(ctx, code, state) @@ -1115,6 +1234,54 @@ func (_c *MockUserComponent_UpdateByUUID_Call) RunAndReturn(run func(context.Con return _c } +// UpdatePhone provides a mock function with given fields: ctx, uid, req +func (_m *MockUserComponent) UpdatePhone(ctx context.Context, uid string, req types.UpdateUserPhoneRequest) error { + ret := _m.Called(ctx, uid, req) + + if len(ret) == 0 { + panic("no return value specified for UpdatePhone") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, types.UpdateUserPhoneRequest) error); ok { + r0 = rf(ctx, uid, req) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockUserComponent_UpdatePhone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePhone' +type MockUserComponent_UpdatePhone_Call struct { + *mock.Call +} + +// UpdatePhone is a helper method to define mock.On call +// - ctx context.Context +// - uid string +// - req types.UpdateUserPhoneRequest +func (_e *MockUserComponent_Expecter) UpdatePhone(ctx interface{}, uid interface{}, req interface{}) *MockUserComponent_UpdatePhone_Call { + return &MockUserComponent_UpdatePhone_Call{Call: _e.mock.On("UpdatePhone", ctx, uid, req)} +} + +func (_c *MockUserComponent_UpdatePhone_Call) Run(run func(ctx context.Context, uid string, req types.UpdateUserPhoneRequest)) *MockUserComponent_UpdatePhone_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(types.UpdateUserPhoneRequest)) + }) + return _c +} + +func (_c *MockUserComponent_UpdatePhone_Call) Return(_a0 error) *MockUserComponent_UpdatePhone_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockUserComponent_UpdatePhone_Call) RunAndReturn(run func(context.Context, string, types.UpdateUserPhoneRequest) error) *MockUserComponent_UpdatePhone_Call { + _c.Call.Return(run) + return _c +} + // UpdateUserLabels provides a mock function with given fields: ctx, req func (_m *MockUserComponent) UpdateUserLabels(ctx context.Context, req *types.UserLabelsRequest) error { ret := _m.Called(ctx, req) @@ -1162,6 +1329,53 @@ func (_c *MockUserComponent_UpdateUserLabels_Call) RunAndReturn(run func(context return _c } +// VerifyPublicSMSCode provides a mock function with given fields: ctx, req +func (_m *MockUserComponent) VerifyPublicSMSCode(ctx context.Context, req types.VerifyPublicSMSCodeRequest) error { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for VerifyPublicSMSCode") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, types.VerifyPublicSMSCodeRequest) error); ok { + r0 = rf(ctx, req) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockUserComponent_VerifyPublicSMSCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyPublicSMSCode' +type MockUserComponent_VerifyPublicSMSCode_Call struct { + *mock.Call +} + +// VerifyPublicSMSCode is a helper method to define mock.On call +// - ctx context.Context +// - req types.VerifyPublicSMSCodeRequest +func (_e *MockUserComponent_Expecter) VerifyPublicSMSCode(ctx interface{}, req interface{}) *MockUserComponent_VerifyPublicSMSCode_Call { + return &MockUserComponent_VerifyPublicSMSCode_Call{Call: _e.mock.On("VerifyPublicSMSCode", ctx, req)} +} + +func (_c *MockUserComponent_VerifyPublicSMSCode_Call) Run(run func(ctx context.Context, req types.VerifyPublicSMSCodeRequest)) *MockUserComponent_VerifyPublicSMSCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.VerifyPublicSMSCodeRequest)) + }) + return _c +} + +func (_c *MockUserComponent_VerifyPublicSMSCode_Call) Return(_a0 error) *MockUserComponent_VerifyPublicSMSCode_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockUserComponent_VerifyPublicSMSCode_Call) RunAndReturn(run func(context.Context, types.VerifyPublicSMSCodeRequest) error) *MockUserComponent_VerifyPublicSMSCode_Call { + _c.Call.Return(run) + return _c +} + // NewMockUserComponent creates a new instance of MockUserComponent. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockUserComponent(t interface { diff --git a/aigateway/router/aigateway.go b/aigateway/router/aigateway.go index d03d101cd..55efa2c2a 100644 --- a/aigateway/router/aigateway.go +++ b/aigateway/router/aigateway.go @@ -3,22 +3,28 @@ package router import ( "fmt" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "opencsg.com/csghub-server/aigateway/handler" "opencsg.com/csghub-server/api/middleware" + "opencsg.com/csghub-server/builder/instrumentation" "opencsg.com/csghub-server/common/config" - "opencsg.com/csghub-server/common/i18n" ) func NewRouter(config *config.Config) (*gin.Engine, error) { r := gin.New() - r.Use(gin.Recovery()) - r.Use(middleware.Log()) + middleware.SetInfraMiddleware(r, config, instrumentation.Aigateway) + r.Use(cors.New(cors.Config{ + AllowCredentials: true, + AllowHeaders: []string{"*"}, + AllowMethods: []string{"*"}, + AllowAllOrigins: true, + })) //to access model,fintune with any kind of tokens in auth header - i18n.InitLocalizersFromEmbedFile() - r.Use(middleware.ModifyAcceptLanguageMiddleware(), middleware.LocalizedErrorMiddleware()) r.Use(middleware.Authenticator(config)) - mustLogin := middleware.MustLogin() + middlewareCollection := middleware.MiddlewareCollection{} + middlewareCollection.Auth.NeedLogin = middleware.MustLogin() + middlewareCollection.Auth.NeedPhoneVerified = middleware.NeedPhoneVerified(config) v1Group := r.Group("/v1") @@ -26,16 +32,19 @@ func NewRouter(config *config.Config) (*gin.Engine, error) { if err != nil { return nil, fmt.Errorf("error creating openai handler :%w", err) } - v1Group.GET("/models", mustLogin, openAIhandler.ListModels) - v1Group.GET("/models/:model", mustLogin, openAIhandler.GetModel) - v1Group.POST("/chat/completions", mustLogin, openAIhandler.Chat) - v1Group.POST("/embeddings", mustLogin, openAIhandler.Embedding) + v1Group.GET("/models", middlewareCollection.Auth.NeedLogin, openAIhandler.ListModels) + v1Group.GET("/models/:model", middlewareCollection.Auth.NeedLogin, openAIhandler.GetModel) + v1Group.POST("/chat/completions", middlewareCollection.Auth.NeedLogin, openAIhandler.Chat) + v1Group.POST("/embeddings", middlewareCollection.Auth.NeedLogin, openAIhandler.Embedding) mcpProxy, err := handler.NewMCPProxyHandler(config) if err != nil { return nil, fmt.Errorf("error creating mcp proxy handler :%w", err) } CreateMCPRoute(v1Group, mcpProxy) + if err := extendRoutes(v1Group, middlewareCollection, config); err != nil { + return nil, fmt.Errorf("error creating extended routes :%w", err) + } return r, nil } diff --git a/aigateway/router/api_ce.go b/aigateway/router/api_ce.go new file mode 100644 index 000000000..561537181 --- /dev/null +++ b/aigateway/router/api_ce.go @@ -0,0 +1,13 @@ +//go:build !ee && !saas + +package router + +import ( + "github.com/gin-gonic/gin" + "opencsg.com/csghub-server/api/middleware" + "opencsg.com/csghub-server/common/config" +) + +func extendRoutes(_ *gin.RouterGroup, _ middleware.MiddlewareCollection, _ *config.Config) error { + return nil +} diff --git a/api/handler/finetune.go b/api/handler/finetune.go index 00e1b9f74..de262f476 100644 --- a/api/handler/finetune.go +++ b/api/handler/finetune.go @@ -11,31 +11,9 @@ import ( "github.com/gin-gonic/gin" "opencsg.com/csghub-server/api/httpbase" - "opencsg.com/csghub-server/common/config" "opencsg.com/csghub-server/common/types" - "opencsg.com/csghub-server/component" ) -func NewFinetuneHandler(config *config.Config) (*FinetuneHandler, error) { - ftComp, err := component.NewFinetuneComponent(config) - if err != nil { - return nil, err - } - sc, err := component.NewSensitiveComponent(config) - if err != nil { - return nil, fmt.Errorf("error creating sensitive component:%w", err) - } - return &FinetuneHandler{ - ftComp: ftComp, - sensitive: sc, - }, nil -} - -type FinetuneHandler struct { - ftComp component.FinetuneComponent - sensitive component.SensitiveComponent -} - // create finetune godoc // @Security ApiKey // @Summary run fineune with model and dataset @@ -72,6 +50,9 @@ func (h *FinetuneHandler) RunFinetuneJob(ctx *gin.Context) { httpbase.ServerError(ctx, err) return } + + h.createAgentInstanceTask(ctx.Request.Context(), req.Agent, finetune.TaskId, currentUser) + httpbase.OK(ctx, finetune) } diff --git a/api/handler/finetune_ce.go b/api/handler/finetune_ce.go new file mode 100644 index 000000000..a514f9efa --- /dev/null +++ b/api/handler/finetune_ce.go @@ -0,0 +1,34 @@ +//go:build !ee && !saas + +package handler + +import ( + "context" + "fmt" + + "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/component" +) + +func NewFinetuneHandler(config *config.Config) (*FinetuneHandler, error) { + ftComp, err := component.NewFinetuneComponent(config) + if err != nil { + return nil, err + } + sc, err := component.NewSensitiveComponent(config) + if err != nil { + return nil, fmt.Errorf("error creating sensitive component:%w", err) + } + return &FinetuneHandler{ + ftComp: ftComp, + sensitive: sc, + }, nil +} + +type FinetuneHandler struct { + ftComp component.FinetuneComponent + sensitive component.SensitiveComponent +} + +func (h *FinetuneHandler) createAgentInstanceTask(_ context.Context, _ string, _ string, _ string) { +} diff --git a/api/handler/finetune_test.go b/api/handler/finetune_ce_test.go similarity index 99% rename from api/handler/finetune_test.go rename to api/handler/finetune_ce_test.go index b33e5246d..6c424d4c4 100644 --- a/api/handler/finetune_test.go +++ b/api/handler/finetune_ce_test.go @@ -1,3 +1,5 @@ +//go:build !ee && !saas + package handler import ( diff --git a/api/handler/model.go b/api/handler/model.go index 5c2ba18ef..2bbab85c7 100644 --- a/api/handler/model.go +++ b/api/handler/model.go @@ -11,39 +11,11 @@ import ( "github.com/gin-gonic/gin" "opencsg.com/csghub-server/api/httpbase" - "opencsg.com/csghub-server/common/config" "opencsg.com/csghub-server/common/errorx" "opencsg.com/csghub-server/common/types" "opencsg.com/csghub-server/common/utils/common" - "opencsg.com/csghub-server/component" ) -func NewModelHandler(config *config.Config) (*ModelHandler, error) { - uc, err := component.NewModelComponent(config) - if err != nil { - return nil, err - } - sc, err := component.NewSensitiveComponent(config) - if err != nil { - return nil, fmt.Errorf("error creating sensitive component:%w", err) - } - repo, err := component.NewRepoComponent(config) - if err != nil { - return nil, fmt.Errorf("error creating repo component:%w", err) - } - return &ModelHandler{ - model: uc, - sensitive: sc, - repo: repo, - }, nil -} - -type ModelHandler struct { - model component.ModelComponent - repo component.RepoComponent - sensitive component.SensitiveComponent -} - // GetVisiableModels godoc // @Security ApiKey // @Summary Get Visiable models for current user @@ -843,6 +815,8 @@ func (h *ModelHandler) FinetuneCreate(ctx *gin.Context) { slog.Debug("deploy model as instance created", slog.String("namespace", namespace), slog.String("name", name), slog.Int64("deploy_id", deployID)) + h.createAgentInstanceTask(ctx.Request.Context(), req.Agent, fmt.Sprintf("%d", deployID), types.AgentTaskTypeInference, currentUser) + // return deploy_id response := types.DeployRepo{DeployID: deployID} diff --git a/api/handler/model_ce.go b/api/handler/model_ce.go new file mode 100644 index 000000000..411546771 --- /dev/null +++ b/api/handler/model_ce.go @@ -0,0 +1,41 @@ +//go:build !ee && !saas + +package handler + +import ( + "context" + "fmt" + + "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/types" + "opencsg.com/csghub-server/component" +) + +func NewModelHandler(config *config.Config) (*ModelHandler, error) { + uc, err := component.NewModelComponent(config) + if err != nil { + return nil, err + } + sc, err := component.NewSensitiveComponent(config) + if err != nil { + return nil, fmt.Errorf("error creating sensitive component:%w", err) + } + repo, err := component.NewRepoComponent(config) + if err != nil { + return nil, fmt.Errorf("error creating repo component:%w", err) + } + return &ModelHandler{ + model: uc, + sensitive: sc, + repo: repo, + }, nil +} + +type ModelHandler struct { + model component.ModelComponent + repo component.RepoComponent + sensitive component.SensitiveComponent +} + +func (h *ModelHandler) createAgentInstanceTask(_ context.Context, _ string, _ string, _ types.AgentTaskType, _ string) { +} diff --git a/api/handler/model_test.go b/api/handler/model_ce_test.go similarity index 99% rename from api/handler/model_test.go rename to api/handler/model_ce_test.go index 9d0cffbe1..420173fd9 100644 --- a/api/handler/model_test.go +++ b/api/handler/model_ce_test.go @@ -1,3 +1,5 @@ +//go:build !ee && !saas + package handler import ( diff --git a/api/router/api.go b/api/router/api.go index 92f448bfd..b0095f661 100644 --- a/api/router/api.go +++ b/api/router/api.go @@ -974,6 +974,8 @@ func createUserRoutes(apiGroup *gin.RouterGroup, middlewareCollection middleware apiGroup.POST("/user/email-verification-code/:email", middlewareCollection.Auth.NeedLogin, userProxyHandler.Proxy) apiGroup.POST("/user/sms-code", middlewareCollection.Auth.NeedLogin, userProxyHandler.Proxy) apiGroup.PUT("/user/phone", middlewareCollection.Auth.NeedLogin, userProxyHandler.Proxy) + apiGroup.POST("/user/public/sms-code", userProxyHandler.Proxy) + apiGroup.POST("/user/public/sms-code/verify", userProxyHandler.Proxy) } { diff --git a/builder/rpc/sso.go b/builder/rpc/sso.go index 75a8167f4..81b31bc3f 100644 --- a/builder/rpc/sso.go +++ b/builder/rpc/sso.go @@ -55,6 +55,7 @@ type SSOUserInfo struct { RegProvider string Gender string Phone string + PhoneArea string LastSigninTime string Avatar string Homepage string @@ -62,9 +63,10 @@ type SSOUserInfo struct { } type SSOUpdateUserInfo struct { - UUID string - Name string - Email string - Gender string - Phone string + UUID string + Name string + Email string + Gender string + Phone string + PhoneArea string } diff --git a/builder/rpc/sso_casdoor.go b/builder/rpc/sso_casdoor.go index 721b5bfca..1b7a25def 100644 --- a/builder/rpc/sso_casdoor.go +++ b/builder/rpc/sso_casdoor.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "log/slog" + "strings" "github.com/casdoor/casdoor-go-sdk/casdoorsdk" "golang.org/x/oauth2" "opencsg.com/csghub-server/common/errorx" + "opencsg.com/csghub-server/common/utils/common" ) type casdoorClientImpl struct { @@ -42,10 +44,23 @@ func (c *casdoorClientImpl) UpdateUserInfo(ctx context.Context, userInfo *SSOUpd if userInfo.Email != "" { casu.Email = userInfo.Email } + if userInfo.Phone != "" { casu.Phone = userInfo.Phone } + if userInfo.PhoneArea != "" { + if !strings.HasPrefix(userInfo.PhoneArea, "+") { + userInfo.PhoneArea = "+" + userInfo.PhoneArea + } + countryCode, err := common.GetCountryCodeByPhoneArea(casu.Phone, userInfo.PhoneArea) + if err != nil { + slog.Error("failed to get country code by phone area", "phone area", userInfo.PhoneArea, "error", err) + return fmt.Errorf("failed to get country code by phone area:%s", userInfo.PhoneArea) + } + casu.CountryCode = countryCode + } + // casdoor update user api don't allow empty display name, so we set it if casu.DisplayName == "" { casu.DisplayName = casu.Name @@ -75,6 +90,15 @@ func (c *casdoorClientImpl) GetUserInfo(ctx context.Context, accessToken string) ) } + var phoneArea string + if claims.User.Phone != "" && claims.User.CountryCode != "" { + phoneArea, err = common.GetPhoneAreaByCountryCode(claims.User.Phone, claims.User.CountryCode) + if err != nil { + // since phone area(stored in db) isn't invoked in csghub side currently, we just print the warning log + slog.Warn("failed to get phone area by country code", "name", claims.User.Name, "error", err) + } + } + return &SSOUserInfo{ WeChat: claims.WeChat, Name: claims.User.Name, @@ -83,6 +107,7 @@ func (c *casdoorClientImpl) GetUserInfo(ctx context.Context, accessToken string) RegProvider: SSOTypeCasdoor, Gender: claims.User.Gender, Phone: claims.User.Phone, + PhoneArea: phoneArea, LastSigninTime: claims.User.LastSigninTime, Avatar: claims.User.Avatar, Homepage: claims.User.Homepage, diff --git a/builder/store/database/user.go b/builder/store/database/user.go index 48508576c..2c7765e87 100644 --- a/builder/store/database/user.go +++ b/builder/store/database/user.go @@ -37,6 +37,7 @@ type UserStore interface { IsExistWithDeleted(ctx context.Context, username string) (bool, error) GetEmails(ctx context.Context, per, page int) ([]string, int, error) GetUserUUIDs(ctx context.Context, per, page int) ([]string, int, error) + UpdatePhone(ctx context.Context, userID int64, phone string, phoneArea string) error } // Implement the UserStore interface in UserStoreImpl @@ -583,3 +584,14 @@ func (s *UserStoreImpl) GetUserUUIDs(ctx context.Context, per, page int) (uuids } return uuids, count, nil } + +func (s *UserStoreImpl) UpdatePhone(ctx context.Context, userID int64, phone string, phoneArea string) error { + _, err := s.db.Operator.Core. + NewUpdate(). + Model(&User{}). + Set("phone = ?", phone). + Set("phone_area = ?", phoneArea). + Where("id = ?", userID). + Exec(ctx) + return errorx.HandleDBError(err, nil) +} diff --git a/builder/store/database/user_test.go b/builder/store/database/user_test.go index ce542221a..a2bcfce10 100644 --- a/builder/store/database/user_test.go +++ b/builder/store/database/user_test.go @@ -440,3 +440,34 @@ func TestGetUserTags(t *testing.T) { require.Contains(t, tagIDs, tag.ID) } } + +// test update phone +func TestUserStore_UpdatePhone(t *testing.T) { + db := tests.InitTestDB() + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + us := database.NewUserStoreWithDB(db) + err := us.Create(ctx, &database.User{ + GitID: 10001, + UUID: "1", + Username: "u-foo", + Phone: "12345678901", + PhoneArea: "", + }, &database.Namespace{Path: "u-foo"}) + require.NoError(t, err) + + user, err := us.FindByUUID(ctx, "1") + require.NoError(t, err) + require.Equal(t, "12345678901", user.Phone) + require.Equal(t, "", user.PhoneArea) + + err = us.UpdatePhone(ctx, user.ID, "12345678902", "+86") + require.NoError(t, err) + + user, err = us.FindByUUID(ctx, "1") + require.NoError(t, err) + require.Equal(t, "12345678902", user.Phone) + require.Equal(t, "+86", user.PhoneArea) +} diff --git a/common/errorx/error_user.go b/common/errorx/error_user.go new file mode 100644 index 000000000..1e5f470bd --- /dev/null +++ b/common/errorx/error_user.go @@ -0,0 +1,211 @@ +package errorx + +const errUserPrefix = "USER-ERR" + +const ( + needPhone = iota + needDifferentPhone + phoneAlreadyExistsInSSO + forbidChangePhone + failedToUpdatePhone + forbidSendPhoneVerifyCodeFrequently + failedSendPhoneVerifyCode + phoneVerifyCodeExpiredOrNotFound + phoneVerifyCodeInvalid + verificationCodeRequired + verificationCodeLengthInvalid + invalidPhoneNumber + usernameExists + emailExists +) + +var ( + // phone number is required + // + // Description: The request must include a phone number in the header or body to identify the target account. + // + // Description_ZH: 请求必须在请求头或正文中包含电话号码以识别目标账户。 + // + // en-US: Phone number is required + // + // zh-CN: 需要提供电话号码 + // + // zh-HK: 需要電話號碼 + ErrNeedPhone error = CustomError{prefix: errUserPrefix, code: needPhone} + // new phone number must be different from current phone number + // + // Description: The new phone number must be different from the current phone number. + // + // Description_ZH: 新电话号码必须与当前电话号码不同。 + // + // en-US: New phone number must be different from current phone number + // + // zh-CN: 新电话号码必须与当前电话号码不同 + // + // zh-HK: 新電話號碼必須與當前電話號碼不同 + ErrNeedDifferentPhone error = CustomError{prefix: errUserPrefix, code: needDifferentPhone} + // new phone number already exists in sso service + // + // Description: The new phone number already exists in sso service. + // + // Description_ZH: 新电话号码已经存在于sso服务中。 + // + // en-US: New phone number already exists in sso service + // + // zh-CN: 新电话号码已经存在于sso服务中 + // + // zh-HK: 新電話號碼已經存在於sso服務中 + ErrPhoneAlreadyExistsInSSO error = CustomError{prefix: errUserPrefix, code: phoneAlreadyExistsInSSO} + // forbid change phone number + // + // Description: The phone number cannot be changed. + // + // Description_ZH: 电话号码不能被更改。 + // + // en-US: Forbid change phone number + // + // zh-CN: 禁止更改电话号码 + // + // zh-HK: 禁止更改電話號碼 + ErrForbidChangePhone error = CustomError{prefix: errUserPrefix, code: forbidChangePhone} + // failed to update phone number + // + // Description: Failed to update phone number. + // + // Description_ZH: 更新电话号码失败。 + // + // en-US: Failed to update phone number + // + // zh-CN: 更新电话号码失败 + // + // zh-HK: 更新電話號碼失敗 + ErrFailedToUpdatePhone error = CustomError{prefix: errUserPrefix, code: failedToUpdatePhone} + // forbid send phone verify code frequently + // + // Description: Send phone verify code frequently. + // + // Description_ZH: 发送手机验证码过于频繁。 + // + // en-US: Forbid send phone verify code frequently + // + // zh-CN: 禁止频繁发送手机验证码 + // + // zh-HK: 禁止頻繁發送手機驗證碼 + ErrForbidSendPhoneVerifyCodeFrequently error = CustomError{prefix: errUserPrefix, code: forbidSendPhoneVerifyCodeFrequently} + // failed to send phone verify code + // + // Description: Failed to send phone verify code. + // + // Description_ZH: 发送手机验证码失败。 + // + // en-US: Failed to send phone verify code + // + // zh-CN: 发送手机验证码失败 + // + // zh-HK: 發送手機驗證碼失敗 + ErrFailedSendPhoneVerifyCode error = CustomError{prefix: errUserPrefix, code: failedSendPhoneVerifyCode} + // phone verify code expired or not found + // + // Description: Phone verify code expired or not found. + // + // Description_ZH: 手机验证码已过期或不存在。 + // + // en-US: Phone verify code expired or not found + // + // zh-CN: 手机验证码已过期或不存在 + // + // zh-HK: 手機驗證碼已過期或不存在 + ErrPhoneVerifyCodeExpiredOrNotFound error = CustomError{prefix: errUserPrefix, code: phoneVerifyCodeExpiredOrNotFound} + // phone verify code is invalid + // + // Description: Phone verify code is invalid. + // + // Description_ZH: 手机验证码无效。 + // + // en-US: Phone verify code is invalid + // + // zh-CN: 手机验证码无效 + // + // zh-HK: 手機驗證碼無效 + ErrPhoneVerifyCodeInvalid error = CustomError{prefix: errUserPrefix, code: phoneVerifyCodeInvalid} + + // verification code can not be empty + // + // Description: Verification code can not be empty. + // + // Description_ZH: 验证码不能为空。 + // + // en-US: Verification code can not be empty + // + // zh-CN: 验证码不能为空 + // + // zh-HK: 驗證碼不能為空 + ErrVerificationCodeRequired error = CustomError{prefix: errUserPrefix, code: verificationCodeRequired} + // verification code length must be 6 + // + // Description: Verification code length must be 6. + // + // Description_ZH: 验证码长度必须为6。 + // + // en-US: Verification code length must be 6 + // + // zh-CN: 验证码长度必须为6 + // + // zh-HK: 驗證碼長度必須為6 + ErrVerificationCodeLength error = CustomError{prefix: errUserPrefix, code: verificationCodeLengthInvalid} + // invalid phone number + // + // Description: Invalid phone number. + // + // Description_ZH: 无效的电话号码。 + // + // en-US: Invalid phone number + // + // zh-CN: 无效的电话号码 + // + // zh-HK: 無效的電話號碼 + ErrInvalidPhoneNumber error = CustomError{prefix: errUserPrefix, code: invalidPhoneNumber} + // username already exists + // + // Description: The username provided already exists in the system. + // + // Description_ZH: 提供的用户名已存在于系统中。 + // + // en-US: Username already exists + // + // zh-CN: 用户名已存在 + // + // zh-HK: 用戶名已存在 + ErrUsernameExists error = CustomError{prefix: errUserPrefix, code: usernameExists} + + // email already exists in the system + // + // Description: The email address provided already exists in the system. + // + // Description_ZH: 提供的电子邮件地址已存在于系统中。 + // + // en-US: Email already exists + // + // zh-CN: 邮箱已存在 + // + // zh-HK: 電郵已存在 + ErrEmailExists error = CustomError{prefix: errUserPrefix, code: emailExists} +) + +// UsernameExists creates a specific error for username conflicts with the conflicting username +func UsernameExists(username string) error { + return CustomError{ + prefix: errUserPrefix, + code: usernameExists, + context: map[string]interface{}{"username": username}, + } +} + +// EmailExists creates a specific error for email conflicts with the conflicting email +func EmailExists(email string) error { + return CustomError{ + prefix: errUserPrefix, + code: emailExists, + context: map[string]interface{}{"email": email}, + } +} diff --git a/common/i18n/en-US/err_task.json b/common/i18n/en-US/err_task.json index b84cf890c..7ff8c8d56 100644 --- a/common/i18n/en-US/err_task.json +++ b/common/i18n/en-US/err_task.json @@ -9,6 +9,6 @@ "other": "Multi-host inference requires minimum replica count to be greater than zero" }, "error.TASK-ERR-3": { - "other": "Multi-host notebook is not supported so far" + "other": "Multi-host notebook is not supported" } } \ No newline at end of file diff --git a/common/i18n/en-US/err_user.json b/common/i18n/en-US/err_user.json new file mode 100644 index 000000000..e0fba644c --- /dev/null +++ b/common/i18n/en-US/err_user.json @@ -0,0 +1,44 @@ +{ + "error.USER-ERR-0": { + "other": "Phone number is required" + }, + "error.USER-ERR-1": { + "other": "New phone number must be different from current phone number" + }, + "error.USER-ERR-10": { + "other": "Verification code length must be 6" + }, + "error.USER-ERR-11": { + "other": "Invalid phone number" + }, + "error.USER-ERR-12": { + "other": "Username already exists" + }, + "error.USER-ERR-13": { + "other": "Email already exists" + }, + "error.USER-ERR-2": { + "other": "New phone number already exists in sso service" + }, + "error.USER-ERR-3": { + "other": "Forbid change phone number" + }, + "error.USER-ERR-4": { + "other": "Failed to update phone number" + }, + "error.USER-ERR-5": { + "other": "Forbid send phone verify code frequently" + }, + "error.USER-ERR-6": { + "other": "Failed to send phone verify code" + }, + "error.USER-ERR-7": { + "other": "Phone verify code expired or not found" + }, + "error.USER-ERR-8": { + "other": "Phone verify code is invalid" + }, + "error.USER-ERR-9": { + "other": "Verification code can not be empty" + } +} \ No newline at end of file diff --git a/common/i18n/zh-CN/err_task.json b/common/i18n/zh-CN/err_task.json index 6265481ac..6621b2d12 100644 --- a/common/i18n/zh-CN/err_task.json +++ b/common/i18n/zh-CN/err_task.json @@ -9,6 +9,6 @@ "other": "多主机推理仅支持大于 0 的最低副本数" }, "error.TASK-ERR-3": { - "other": "多机模式暂不支持notebook" + "other": "不支持多主机 Notebook" } } \ No newline at end of file diff --git a/common/i18n/zh-CN/err_user.json b/common/i18n/zh-CN/err_user.json new file mode 100644 index 000000000..2e1722f28 --- /dev/null +++ b/common/i18n/zh-CN/err_user.json @@ -0,0 +1,44 @@ +{ + "error.USER-ERR-0": { + "other": "需要提供电话号码" + }, + "error.USER-ERR-1": { + "other": "新电话号码必须与当前电话号码不同" + }, + "error.USER-ERR-10": { + "other": "验证码长度必须为6" + }, + "error.USER-ERR-11": { + "other": "无效的电话号码" + }, + "error.USER-ERR-12": { + "other": "用户名已存在" + }, + "error.USER-ERR-13": { + "other": "邮箱已存在" + }, + "error.USER-ERR-2": { + "other": "新电话号码已经存在于sso服务中" + }, + "error.USER-ERR-3": { + "other": "禁止更改电话号码" + }, + "error.USER-ERR-4": { + "other": "更新电话号码失败" + }, + "error.USER-ERR-5": { + "other": "禁止频繁发送手机验证码" + }, + "error.USER-ERR-6": { + "other": "发送手机验证码失败" + }, + "error.USER-ERR-7": { + "other": "手机验证码已过期或不存在" + }, + "error.USER-ERR-8": { + "other": "手机验证码无效" + }, + "error.USER-ERR-9": { + "other": "验证码不能为空" + } +} \ No newline at end of file diff --git a/common/i18n/zh-HK/err_task.json b/common/i18n/zh-HK/err_task.json index 0cd0d75cb..7637118e2 100644 --- a/common/i18n/zh-HK/err_task.json +++ b/common/i18n/zh-HK/err_task.json @@ -9,6 +9,6 @@ "other": "多主機推理僅支持大於 0 的最低副本數" }, "error.TASK-ERR-3": { - "other": "多機模式暫不支持notebook" + "other": "不支援多主機 Notebook" } } \ No newline at end of file diff --git a/common/i18n/zh-HK/err_user.json b/common/i18n/zh-HK/err_user.json new file mode 100644 index 000000000..41c134367 --- /dev/null +++ b/common/i18n/zh-HK/err_user.json @@ -0,0 +1,44 @@ +{ + "error.USER-ERR-0": { + "other": "需要電話號碼" + }, + "error.USER-ERR-1": { + "other": "新電話號碼必須與當前電話號碼不同" + }, + "error.USER-ERR-10": { + "other": "驗證碼長度必須為6" + }, + "error.USER-ERR-11": { + "other": "無效的電話號碼" + }, + "error.USER-ERR-12": { + "other": "用戶名已存在" + }, + "error.USER-ERR-13": { + "other": "電郵已存在" + }, + "error.USER-ERR-2": { + "other": "新電話號碼已經存在於sso服務中" + }, + "error.USER-ERR-3": { + "other": "禁止更改電話號碼" + }, + "error.USER-ERR-4": { + "other": "更新電話號碼失敗" + }, + "error.USER-ERR-5": { + "other": "禁止頻繁發送手機驗證碼" + }, + "error.USER-ERR-6": { + "other": "發送手機驗證碼失敗" + }, + "error.USER-ERR-7": { + "other": "手機驗證碼已過期或不存在" + }, + "error.USER-ERR-8": { + "other": "手機驗證碼無效" + }, + "error.USER-ERR-9": { + "other": "驗證碼不能為空" + } +} \ No newline at end of file diff --git a/common/types/agent.go b/common/types/agent.go new file mode 100644 index 000000000..3c2e4fc84 --- /dev/null +++ b/common/types/agent.go @@ -0,0 +1,460 @@ +package types + +import ( + "encoding/json" + "time" +) + +// AgentTemplate represents the template for an agent +type AgentTemplate struct { + ID int64 `json:"id"` + Type *string `json:"type" binding:"required"` // Possible values: langflow, agno, code, etc. + UserUUID *string `json:"-"` // Will be set from HTTP header using httpbase.GetCurrentUserUUID + Name *string `json:"name" binding:"required,max=255"` // Agent template name + Description *string `json:"description" binding:"omitempty,max=500"` // Agent template description + Content *string `json:"content,omitempty"` // Used to store the complete content of the template + Public *bool `json:"public,omitempty"` // Whether the template is public + Metadata *map[string]any `json:"metadata,omitempty"` // Template metadata + CreatedAt time.Time `json:"created_at"` // When the template was created + UpdatedAt time.Time `json:"updated_at"` // When the template was last updated +} + +type AgentTemplateFilter struct { + Search string + Type string +} + +// AgentInstance represents an instance created from an agent template +type AgentInstance struct { + ID int64 `json:"id"` + TemplateID *int64 `json:"template_id" binding:"omitempty,gte=1"` // Associated with the id in the template table + UserUUID *string `json:"-"` // Will be set from HTTP header using httpbase.GetCurrentUserUUID + Name *string `json:"name"` // Instance name + Description *string `json:"description" binding:"omitempty"` // Instance description + Type *string `json:"type"` // Possible values: langflow, agno, code, etc. + ContentID *string `json:"content_id" binding:"omitempty"` // Used to specify the unique id of the instance resource + Public *bool `json:"public"` // Whether the instance is public + Editable bool `json:"editable"` // Whether the instance is editable + IsRunning bool `json:"is_running"` // Whether the instance is running + BuiltIn bool `json:"built_in"` // Whether the instance is built-in + Metadata *map[string]any `json:"metadata,omitempty"` // Instance metadata + CreatedAt time.Time `json:"created_at"` // When the instance was created + UpdatedAt time.Time `json:"updated_at"` // When the instance was last updated +} + +type AgentType string + +const ( + AgentTypeLangflow AgentType = "langflow" + AgentTypeCode AgentType = "code" +) + +func (t AgentType) String() string { + return string(t) +} + +type AgentInstanceFilter struct { + Search string + Type string + TemplateID *int64 `json:"template_id,omitempty"` + BuiltIn *bool `json:"built_in"` + Public *bool `json:"public"` +} + +type UpdateAgentInstanceRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Metadata *map[string]any `json:"metadata,omitempty"` +} + +type AgentInstanceCreationResult struct { + ID string + Name string + Description string + Metadata map[string]any // Additional metadata for the agent instance +} + +// LangFlowChatRequest represents a chat request to an agent instance +type LangflowChatRequest struct { + SessionID *string `json:"session_id,omitempty"` // Optional session ID (client-provided) + InputValue string `json:"input_value" binding:"required"` // Input value for the agent + InputType string `json:"input_type" binding:"required"` // Type of input (e.g., "chat") + OutputType string `json:"output_type" binding:"required"` // Type of output (e.g., "chat") + Tweaks json.RawMessage `json:"tweaks,omitempty"` // Optional parameter tweaks +} + +// AgentChatResponse represents the response from an agent chat +type AgentChatResponse struct { + SessionID string `json:"session_id"` // Session ID used for this conversation + OutputType string `json:"output_type"` // Output type from the request + Message string `json:"message"` // Agent's response message + InstanceID int64 `json:"instance_id"` // Agent instance ID + ContentID string `json:"content_id"` // Agent instance content ID + Type string `json:"type"` // Agent instance type + Timestamp string `json:"timestamp"` // When the response was generated + Sender string `json:"sender"` // Which Agent sent the message +} + +// AgentChatSession represents a chat session +type AgentInstanceSession struct { + ID int64 `json:"id"` + SessionUUID string `json:"session_uuid"` + Name string `json:"name"` + Type string `json:"type"` // Possible values: langflow, agno, code, etc. + InstanceID int64 `json:"instance_id"` + UserUUID string `json:"user_uuid"` + LastTurn int64 `json:"last_turn"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentInstanceSessionFilter struct { + InstanceID *int64 + Search string +} + +type CreateAgentInstanceSessionRequest struct { + SessionUUID *string `json:"session_uuid,omitempty"` + Name *string `json:"name,omitempty" binding:"omitempty,max=255"` + Type string `json:"-"` // Possible values: langflow, agno, code, etc. + InstanceID *int64 `json:"-"` + ContentID *string `json:"-"` +} + +type CreateAgentInstanceSessionResponse struct { + SessionUUID string `json:"session_uuid"` +} + +type UpdateAgentInstanceSessionRequest struct { + Name string `json:"name" binding:"required,max=255"` +} + +type RecordAgentInstanceSessionHistoryRequest struct { + SessionUUID string `json:"session_uuid"` + Request bool `json:"request"` + Content string `json:"content"` +} + +type CreateSessionHistoryRequest struct { + SessionUUID string `json:"-"` + Messages []SessionHistoryMessage `json:"messages" binding:"required"` +} + +type SessionHistoryMessage struct { + Request bool `json:"request"` // true: request, false: response + Content string `json:"content,omitempty"` // message content +} + +type CreateSessionHistoryResponse struct { + MsgUUIDs []string `json:"msg_uuids"` +} + +type SessionHistoryMessageType string + +const ( + SessionHistoryMessageTypeCreate SessionHistoryMessageType = "create" + SessionHistoryMessageTypeUpdateFeedback SessionHistoryMessageType = "update_feedback" + SessionHistoryMessageTypeRewrite SessionHistoryMessageType = "rewrite" +) + +// SessionHistoryMessageEnvelope is a unified message structure for all session history operations +type SessionHistoryMessageEnvelope struct { + // Common fields + MessageType SessionHistoryMessageType `json:"message_type"` + MsgUUID string `json:"msg_uuid"` + SessionID int64 `json:"session_id"` + SessionUUID string `json:"session_uuid"` + Request bool `json:"request"` // true: request, false: response + + // Create/Rewrite fields + Content string `json:"content,omitempty"` // message content + IsRewritten *bool `json:"is_rewritten,omitempty"` // true: rewritten by user's request + + // UpdateFeedback field + Feedback *AgentSessionHistoryFeedback `json:"feedback,omitempty"` // feedback: none, like, dislike + + // Rewrite field + OriginalMsgUUID string `json:"original_msg_uuid,omitempty"` // original message UUID when rewriting +} + +// AgentInstanceSessionHistory represents a session history +type AgentInstanceSessionHistory struct { + ID int64 `json:"id"` + MsgUUID string `json:"msg_uuid"` + SessionID int64 `json:"session_id"` + SessionUUID string `json:"session_uuid"` + Request bool `json:"request"` + Content string `json:"content"` + Feedback AgentSessionHistoryFeedback `json:"feedback"` + IsRewritten bool `json:"is_rewritten"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentInstanceSessionResponse struct { + SessionUUID string `json:"session_uuid"` + Histories []string `json:"histories"` // list of history contents +} + +type AgentSessionHistoryFeedback string + +const ( + AgentSessionHistoryFeedbackNone AgentSessionHistoryFeedback = "none" + AgentSessionHistoryFeedbackLike AgentSessionHistoryFeedback = "like" + AgentSessionHistoryFeedbackDislike AgentSessionHistoryFeedback = "dislike" +) + +type FeedbackSessionHistoryRequest struct { + MsgUUID string `json:"-"` + Feedback AgentSessionHistoryFeedback `json:"feedback" binding:"required,oneof=none like dislike"` +} + +type RewriteSessionHistoryRequest struct { + OriginalMsgUUID string `json:"-"` + Content string `json:"content" binding:"required"` +} + +type RewriteSessionHistoryResponse struct { + MsgUUID string `json:"msg_uuid"` +} + +type AgentTaskType string + +const ( + AgentTaskTypeFinetuneJob AgentTaskType = "finetuneJob" + AgentTaskTypeInference AgentTaskType = "inference" +) + +func (t AgentTaskType) String() string { + return string(t) +} + +// AgentInfo represents the agent information parsed from JSON string +// e.g. {"type": "code", "id": "123", "request_id": "123"} +type AgentInfo struct { + Type string `json:"type"` // Agent type (e.g., "code", "langflow") + ID string `json:"id"` // Agent content ID + RequestID string `json:"request_id"` // request ID (session uuid) +} + +type AgentInstanceTaskReq struct { + TaskID string `json:"task_id"` // Task ID from argo_workflows + Agent string `json:"agent"` // Agent JSON string, e.g. {"type": "code", "id": "123"} + Type AgentTaskType `json:"type"` // Agent task type (e.g., "finetune", "inference") + Username string `json:"username"` // Username +} + +type AgentStreamEvent struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +type CodeAgentRequest struct { + RequestID string `json:"request_id,omitempty"` // Session ID (client-provided) + Query string `json:"query" binding:"required"` // The user's query/question + MaxLoop int `json:"max_loop" binding:"omitempty,min=1"` // Maximum number of execution loops (default: 1) + SearchEngines []string `json:"search_engines"` // List of search engines to use + Stream bool `json:"stream"` // Whether to stream the response + AgentName string `json:"agent_name" binding:"required"` // Name of the agent to use + StreamMode *StreamMode `json:"stream_mode,omitempty"` // Stream configuration + History []CodeAgentRequestMessage `json:"history,omitempty"` // Conversation history +} + +type StreamMode struct { + Mode string `json:"mode" binding:"required"` // Stream mode (e.g., "general") + Token int `json:"token" binding:"min=1"` // Token-based streaming interval + Time int `json:"time" binding:"min=1"` // Time-based streaming interval +} + +type CodeAgentRequestMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type CodeAgentSyncOperation string + +const ( + CodeAgentSyncOperationUpdate CodeAgentSyncOperation = "update" + CodeAgentSyncOperationDelete CodeAgentSyncOperation = "delete" +) + +func (o CodeAgentSyncOperation) String() string { + return string(o) +} + +// AgentInstanceStatusRequest represents a request to get status for an agent instance +type AgentInstanceStatusRequest struct { + Type string `json:"type" binding:"required"` // Agent type (e.g., "code", "langflow") + ContentID string `json:"content_id" binding:"required"` // Content ID of the instance +} + +// AgentInstanceStatusResponse represents the status response for an agent instance +type AgentInstanceStatusResponse struct { + ID int64 `json:"id"` // Instance ID + Type string `json:"type"` // Agent type + ContentID string `json:"content_id"` // Content ID of the instance + Status string `json:"status,omitempty"` // Full status string (e.g., "Running", "Stopped", "Building", etc.) + Error string `json:"error,omitempty"` // Error message if status check failed +} + +// AgentInstanceStatusResult represents the status result for a single instance from adapter +type AgentInstanceStatusResult struct { + Status string + Error error +} + +// AgentTaskStatus represents the unified task status +type AgentTaskStatus string + +const ( + AgentTaskStatusInProgress AgentTaskStatus = "in_progress" + AgentTaskStatusCompleted AgentTaskStatus = "completed" + AgentTaskStatusFailed AgentTaskStatus = "failed" +) + +// AgentTaskFilter represents the filter for listing agent tasks +type AgentTaskFilter struct { + Search string `json:"search,omitempty"` // Search by task name + TaskType AgentTaskType `json:"task_type,omitempty"` // Filter by task type (finetuneJob, inference) + Status AgentTaskStatus `json:"status,omitempty"` // Filter by status (in_progress, completed, failed) + InstanceID *int64 `json:"instance_id,omitempty"` // Filter by instance ID + SessionUUID string `json:"session_uuid,omitempty"` // Filter by session UUID +} + +// AgentTaskListItem represents a task item in the list +type AgentTaskListItem struct { + ID int64 `json:"id"` + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + TaskType AgentTaskType `json:"task_type"` + TaskStatus AgentTaskStatus `json:"task_status"` + InstanceID int64 `json:"instance_id"` + SessionUUID string `json:"session_uuid"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` +} + +// AgentTaskDetail represents detailed task information +type AgentTaskDetail struct { + ID int64 `json:"id"` + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + TaskDesc string `json:"task_desc"` + TaskType AgentTaskType `json:"task_type"` + Status AgentTaskStatus `json:"status"` + InstanceID int64 `json:"instance_id"` + InstanceType string `json:"instance_type"` + InstanceName string `json:"instance_name"` + SessionUUID string `json:"session_uuid"` + SessionName string `json:"session_name"` + Username string `json:"username"` + Backend string `json:"backend"` // Backend system of the task (argo_workflow, deploy) + Metadata map[string]any `json:"metadata,omitempty"` // Backend-specific fields (argo_workflow or deploy) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentMonitorRequest struct { + MonitorID string `json:"monitor_id" binding:"required"` + InstanceIDs []int64 `json:"instance_ids" binding:"required"` +} + +// AgentMCPServerConnectionType represents the connection type for MCP servers +type AgentMCPServerConnectionType string + +const ( + AgentMCPServerConnectionTypeJSON AgentMCPServerConnectionType = "json" + AgentMCPServerConnectionTypeSSE AgentMCPServerConnectionType = "sse" +) + +func (t AgentMCPServerConnectionType) String() string { + return string(t) +} + +// AgentMCPServer represents an MCP server configuration for an agent (API layer) +type AgentMCPServer struct { + ID int64 `json:"id"` + UserUUID string `json:"user_uuid"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Protocol string `json:"protocol"` // streamable or sse + URL string `json:"url"` + Headers *map[string]any `json:"headers,omitempty"` + Env *map[string]any `json:"env,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentMCPServerFilter represents the filter for listing agent MCP servers +type AgentMCPServerFilter struct { + Search string `json:"search,omitempty"` // Search term for name field + UserUUID string `json:"user_uuid"` // Filter by user UUID (always set, includes built-in servers) + BuiltIn *bool `json:"built_in,omitempty"` // Filter by built-in status + Protocol *string `json:"protocol,omitempty"` // Filter by protocol (streamable, sse) + NeedInstall *bool `json:"need_install,omitempty"` // Filter by need_install status +} + +// CreateAgentMCPServerRequest represents a request to create an agent MCP server +type CreateAgentMCPServerRequest struct { + Name string `json:"name" binding:"required,max=255"` + Description string `json:"description,omitempty"` + Protocol string `json:"protocol" binding:"required,oneof=streamable sse"` + URL string `json:"url" binding:"required"` + Headers map[string]any `json:"headers,omitempty"` + Env map[string]any `json:"env,omitempty"` + UserUUID string `json:"-"` // Will be set from HTTP header +} + +// UpdateAgentMCPServerRequest represents a request to update an agent MCP server +type UpdateAgentMCPServerRequest struct { + Name *string `json:"name,omitempty" binding:"omitempty,max=255"` + Description *string `json:"description,omitempty"` + Protocol *string `json:"protocol,omitempty" binding:"omitempty,oneof=streamable sse"` + URL *string `json:"url,omitempty"` + Headers *map[string]any `json:"headers,omitempty"` + Env *map[string]any `json:"env,omitempty"` +} + +// AgentMCPServerListItem represents an MCP server in list responses +type AgentMCPServerListItem struct { + ID string `json:"id"` // String ID (format: "builtin:{id}" or "user:{id}") + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + Owner string `json:"owner"` + Avatar string `json:"avatar"` + Protocol string `json:"protocol,omitempty"` + BuiltIn bool `json:"built_in"` + NeedInstall bool `json:"need_install"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentMCPServerDetail represents a complete MCP server with all configuration details +type AgentMCPServerDetail struct { + ID string `json:"id"` // String ID (format: "builtin:{id}" or "user:{id}") + Name string `json:"name"` + Description string `json:"description"` + UserUUID string `json:"user_uuid"` + Owner string `json:"owner"` + Avatar string `json:"avatar"` + Protocol string `json:"protocol,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]any `json:"headers,omitempty"` + Env map[string]any `json:"env,omitempty"` + BuiltIn bool `json:"built_in"` + NeedInstall bool `json:"need_install"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentMCPServerIDPrefix string + +const ( + AgentMCPServerIDPrefixBuiltin AgentMCPServerIDPrefix = "builtin:" + AgentMCPServerIDPrefixUser AgentMCPServerIDPrefix = "user:" +) + +func (p AgentMCPServerIDPrefix) String() string { + return string(p) +} diff --git a/common/types/finetune.go b/common/types/finetune.go index a9888f5a7..bd4ad5b06 100644 --- a/common/types/finetune.go +++ b/common/types/finetune.go @@ -45,6 +45,7 @@ type FinetuneReq struct { ShareMode bool `json:"share_mode"` LearningRate float64 `json:"learning_rate"` CustomeArgs string `json:"custom_args"` + Agent string `json:"agent,omitempty"` } type FinetineGetReq struct { diff --git a/common/types/model.go b/common/types/model.go index 04565a1cb..0e3c815fe 100644 --- a/common/types/model.go +++ b/common/types/model.go @@ -292,6 +292,7 @@ type InstanceRunReq struct { Revision string `json:"revision"` OrderDetailID int64 `json:"order_detail_id"` EngineArgs string `json:"engine_args"` + Agent string `json:"agent"` } var _ SensitiveRequestV2 = (*InstanceRunReq)(nil) diff --git a/common/types/user.go b/common/types/user.go index e599edb7d..8cc27c27a 100644 --- a/common/types/user.go +++ b/common/types/user.go @@ -27,8 +27,6 @@ type UpdateUserRequest struct { Username string `json:"-"` Email *string `json:"email,omitempty" binding:"omitnil,email"` EmailVerificationCode *string `json:"email_verification_code,omitempty"` - Phone *string `json:"phone,omitempty"` - PhoneArea *string `json:"phone_area,omitempty"` UUID *string `json:"-"` // should be updated by admin Roles *[]string `json:"roles,omitempty" example:"[super_user, admin, personal_user]"` @@ -43,6 +41,34 @@ type UpdateUserRequest struct { TagIDs []int64 `json:"tag_ids,omitempty"` } +type SendSMSCodeRequest struct { + PhoneArea string `json:"phone_area" binding:"required"` + Phone string `json:"phone" binding:"required"` +} + +type SendSMSCodeResponse struct { + ExpiredAt time.Time `json:"expired_at"` +} + +type SendPublicSMSCodeRequest struct { + Scene string `json:"scene" binding:"required"` + PhoneArea string `json:"phone_area" binding:"required"` + Phone string `json:"phone" binding:"required"` +} + +type VerifyPublicSMSCodeRequest struct { + Scene string `json:"scene" binding:"required"` + Phone string `json:"phone" binding:"required"` + PhoneArea string `json:"phone_area" binding:"required"` + VerificationCode string `json:"verification_code" binding:"required,len=6"` +} + +type UpdateUserPhoneRequest struct { + Phone *string `json:"phone" binding:"required"` + PhoneArea *string `json:"phone_area,omitempty"` + VerificationCode *string `json:"verification_code" binding:"required,len=6"` +} + var _ SensitiveRequestV2 = (*UpdateUserRequest)(nil) func (u *UpdateUserRequest) GetSensitiveFields() []SensitiveField { @@ -300,3 +326,14 @@ type CloseAccountReq struct { Repository bool `json:"repository"` Discussion bool `json:"discussion"` } +type UserIndexReq struct { + Search string `json:"search"` + VerifyStatus VerifyStatus `json:"verify_status"` + Labels []string `json:"labels"` + Per int `json:"per"` +} + +type UserIndexResp struct { + Users []*User `json:"users"` + Error error `json:"error"` +} diff --git a/common/utils/common/phone.go b/common/utils/common/phone.go new file mode 100644 index 000000000..64f86e919 --- /dev/null +++ b/common/utils/common/phone.go @@ -0,0 +1,35 @@ +package common + +import ( + "fmt" + + "github.com/nyaruka/phonenumbers" +) + +func GetCountryCodeByPhoneArea(phone string, phoneArea string) (string, error) { + phoneNumber, err := phonenumbers.Parse(fmt.Sprintf("%s%s", phoneArea, phone), "") + if err != nil { + return "", err + } + countryCode := phonenumbers.GetRegionCodeForNumber(phoneNumber) + if countryCode == "" { + return "", fmt.Errorf("country code is empty for phone area:%s", phoneArea) + } + return countryCode, nil +} + +func GetPhoneAreaByCountryCode(phone string, countryCode string) (string, error) { + num, err := phonenumbers.Parse(phone, countryCode) + if err != nil { + return "", err + } + return fmt.Sprintf("+%d", num.GetCountryCode()), nil +} + +func IsValidNumber(phone string, phoneArea string) (bool, error) { + num, err := phonenumbers.Parse(fmt.Sprintf("%s%s", phoneArea, phone), "") + if err != nil { + return false, err + } + return phonenumbers.IsValidNumber(num), nil +} diff --git a/common/utils/common/phone_test.go b/common/utils/common/phone_test.go new file mode 100644 index 000000000..a9f7dc103 --- /dev/null +++ b/common/utils/common/phone_test.go @@ -0,0 +1,66 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserComponent_GetCountryCode(t *testing.T) { + t.Run("test get country code for china", func(t *testing.T) { + countryCode, err := GetCountryCodeByPhoneArea("12345678901", "+86") + require.Nil(t, err) + require.Equal(t, "CN", countryCode) + }) + t.Run("test get country code for us", func(t *testing.T) { + countryCode, err := GetCountryCodeByPhoneArea("4155552671", "+1") + require.Nil(t, err) + require.Equal(t, "US", countryCode) + }) + t.Run("test get country code for hongkong", func(t *testing.T) { + countryCode, err := GetCountryCodeByPhoneArea("66668877", "+852") + require.Nil(t, err) + require.Equal(t, "HK", countryCode) + }) + t.Run("test get country code for invalid phone area", func(t *testing.T) { + countryCode, err := GetCountryCodeByPhoneArea("12345678901", "+11") + require.NotNil(t, err) + require.Equal(t, "", countryCode) + }) +} + +func TestUserComponent_GetPhoneAreaByCountryCode(t *testing.T) { + t.Run("test get phone area by country code for china", func(t *testing.T) { + phoneArea, err := GetPhoneAreaByCountryCode("12345678901", "CN") + require.Nil(t, err) + require.Equal(t, "+86", phoneArea) + }) + t.Run("test get phone area by country code for us", func(t *testing.T) { + phoneArea, err := GetPhoneAreaByCountryCode("4155552671", "US") + require.Nil(t, err) + require.Equal(t, "+1", phoneArea) + }) + t.Run("test get phone area by country code for hongkong", func(t *testing.T) { + phoneArea, err := GetPhoneAreaByCountryCode("66668877", "HK") + require.Nil(t, err) + require.Equal(t, "+852", phoneArea) + }) + t.Run("test get phone area by country code for invalid phone area", func(t *testing.T) { + phoneArea, err := GetPhoneAreaByCountryCode("12345678901", "OZ") + require.NotNil(t, err) + require.Equal(t, "", phoneArea) + }) +} + +func TestUserComponent_IsValidNumber(t *testing.T) { + t.Run("test is valid number for china", func(t *testing.T) { + isValid, err := IsValidNumber("13626487789", "+86") + require.Nil(t, err) + require.True(t, isValid) + }) + t.Run("test is invalid number for hongkong", func(t *testing.T) { + isValid, err := IsValidNumber("13626487789", "+852") + require.Nil(t, err) + require.False(t, isValid) + }) +} diff --git a/docs/error_codes_en.md b/docs/error_codes_en.md index 61f61810c..ee0a7d5d0 100644 --- a/docs/error_codes_en.md +++ b/docs/error_codes_en.md @@ -434,30 +434,6 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `gitReplicateRepositoryFailed` - **Description:** replicate repository failed. This can be caused by network problems, authentication issues, or the specified repository does not exist. -## Invitation Errors - -### `INVITATION-ERR-0` - -- **Error Code:** `INVITATION-ERR-0` -- **Error Name:** `userPhoneNotSet` -- **Description:** The phone number is not set, cannot create invitation code. - ---- - -### `INVITATION-ERR-1` - -- **Error Code:** `INVITATION-ERR-1` -- **Error Name:** `invitationNotFound` -- **Description:** The invitation not found. - ---- - -### `INVITATION-ERR-2` - -- **Error Code:** `INVITATION-ERR-2` -- **Error Name:** `userAlreadyHasInvitationCode` -- **Description:** The invitation code already exists. - ## License Errors ### `LICENSE-ERR-0` @@ -466,30 +442,6 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `noActiveLicense` - **Description:** No active license found for the current system. ---- - -### `LICENSE-ERR-1` - -- **Error Code:** `LICENSE-ERR-1` -- **Error Name:** `licenseExpired` -- **Description:** The license is expired, could not be verified and imported. - -## Moderation Errors - -### `MOD-ERR-0` - -- **Error Code:** `MOD-ERR-0` -- **Error Name:** `codeNameRequire` -- **Description:** The request parameter does not match the server requirements, and the server cannot process the request. - ---- - -### `MOD-ERR-1` - -- **Error Code:** `MOD-ERR-1` -- **Error Name:** `codeWordRequire` -- **Description:** The request parameter does not match the server requirements, and the server cannot process the request. - ## Req Errors ### `REQ-ERR-0` @@ -578,22 +530,6 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `errRateLimitExceeded` - **Description:** The user has sent too many requests in a given amount of time. Further requests will be blocked until the rate limit resets or a valid captcha is provided. ---- - -### `REQ-ERR-11` - -- **Error Code:** `REQ-ERR-11` -- **Error Name:** `errLimitedIPLocation` -- **Description:** Requests originating from this IP location are restricted. To proceed, please complete a captcha verification. - ---- - -### `REQ-ERR-12` - -- **Error Code:** `REQ-ERR-12` -- **Error Name:** `errCaptchaIncorrect` -- **Description:** The provided captcha verification failed. Please try again with a valid captcha. - ## System Errors ### `SYS-ERR-0` @@ -698,22 +634,6 @@ This document lists all the custom error codes defined in the project, categoriz - **Error Name:** `multiHostNotebookNotSupported` - **Description:** The multi-host notebook feature (running notebook tasks across multiple hosts) is not supported. ---- - -### `TASK-ERR-4` - -- **Error Code:** `TASK-ERR-4` -- **Error Name:** `notEnoughResource` -- **Description:** The task requires more resources than are available in the cluster. This error occurs when the cluster does not have sufficient capacity to run the task. - ---- - -### `TASK-ERR-5` - -- **Error Code:** `TASK-ERR-5` -- **Error Name:** `clusterUnavailable` -- **Description:** The cluster is currently unavailable, either due to maintenance or other reasons. This error occurs when the cluster is not ready to accept new tasks. - ## User Errors ### `USER-ERR-0` diff --git a/docs/error_codes_zh.md b/docs/error_codes_zh.md index 94458f3ea..118346845 100644 --- a/docs/error_codes_zh.md +++ b/docs/error_codes_zh.md @@ -434,30 +434,6 @@ - **错误名:** `gitReplicateRepositoryFailed` - **描述:** 转移仓库失败。这可能由网络问题、身份验证问题或指定的仓库不存在引起。 -## Invitation 错误 - -### `INVITATION-ERR-0` - -- **错误代码:** `INVITATION-ERR-0` -- **错误名:** `userPhoneNotSet` -- **描述:** 未绑定手机号,不能创建邀请码。 - ---- - -### `INVITATION-ERR-1` - -- **错误代码:** `INVITATION-ERR-1` -- **错误名:** `invitationNotFound` -- **描述:** 邀请码不存在。 - ---- - -### `INVITATION-ERR-2` - -- **错误代码:** `INVITATION-ERR-2` -- **错误名:** `userAlreadyHasInvitationCode` -- **描述:** 邀请码已存在。 - ## License 错误 ### `LICENSE-ERR-0` @@ -466,30 +442,6 @@ - **错误名:** `noActiveLicense` - **描述:** 当前系统没有有效的许可证。 ---- - -### `LICENSE-ERR-1` - -- **错误代码:** `LICENSE-ERR-1` -- **错误名:** `licenseExpired` -- **描述:** 许可证已过期,无法验证和导入。 - -## Moderation 错误 - -### `MOD-ERR-0` - -- **错误代码:** `MOD-ERR-0` -- **错误名:** `codeNameRequire` -- **描述:** 请求参数不匹配, 服务器无法处理该请求。 - ---- - -### `MOD-ERR-1` - -- **错误代码:** `MOD-ERR-1` -- **错误名:** `codeWordRequire` -- **描述:** 请求参数不匹配, 服务器无法处理该请求。 - ## Req 错误 ### `REQ-ERR-0` @@ -578,22 +530,6 @@ - **错误名:** `errRateLimitExceeded` - **描述:** 用户在给定的时间内发送了太多的请求。在速率限制重置或提供有效的验证码之前,将阻止进一步的请求。 ---- - -### `REQ-ERR-11` - -- **错误代码:** `REQ-ERR-11` -- **错误名:** `errLimitedIPLocation` -- **描述:** 来自此IP位置的请求受到限制。要继续操作,请完成验证码验证。 - ---- - -### `REQ-ERR-12` - -- **错误代码:** `REQ-ERR-12` -- **错误名:** `errCaptchaIncorrect` -- **描述:** 提供的验证码验证失败。请使用有效的验证码重试。 - ## System 错误 ### `SYS-ERR-0` @@ -698,22 +634,6 @@ - **错误名:** `multiHostNotebookNotSupported` - **描述:** 多主机 Notebook 功能(在多个主机上运行 Notebook 任务)不被支持。请改用单主机 Notebook 执行。 ---- - -### `TASK-ERR-4` - -- **错误代码:** `TASK-ERR-4` -- **错误名:** `notEnoughResource` -- **描述:** 任务需要的资源超过了集群可用的资源。当集群资源不足时,会出现此错误。 - ---- - -### `TASK-ERR-5` - -- **错误代码:** `TASK-ERR-5` -- **错误名:** `clusterUnavailable` -- **描述:** 集群当前不可用,可能是由于维护或其他原因。当集群未准备好接受新任务时,会出现此错误。 - ## User 错误 ### `USER-ERR-0` diff --git a/go.mod b/go.mod index 35c089177..9b311b72f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/OpenCSGs/gitea-go-sdk/gitea v0.0.0-20240618091626-54fa52f1cec6 github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.8 github.com/alibabacloud-go/dm-20151123/v2 v2.4.0 + github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.1.2 github.com/alibabacloud-go/green-20220302/v2 v2.20.0 github.com/alibabacloud-go/tea v1.3.9 github.com/aliyun/alibaba-cloud-sdk-go v1.62.648 @@ -36,6 +37,7 @@ require ( github.com/minio/sha256-simd v1.0.1 github.com/naoina/toml v0.1.1 github.com/nicksnyder/go-i18n/v2 v2.6.0 + github.com/nyaruka/phonenumbers v1.6.5 github.com/openai/openai-go/v3 v3.8.1 github.com/parquet-go/parquet-go v0.24.0 github.com/prometheus/client_golang v1.21.1 @@ -267,7 +269,7 @@ require ( github.com/alibabacloud-go/openapi-util v0.1.1 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 github.com/aliyun/credentials-go v1.4.5 - github.com/argoproj/argo-workflows/v3 v3.6.12 + github.com/argoproj/argo-workflows/v3 v3.5.13 github.com/blendle/zapdriver v1.3.1 // indirect github.com/bwmarrin/snowflake v0.3.0 github.com/bytedance/sonic v1.12.5 // indirect diff --git a/go.sum b/go.sum index 103d606c2..23e4cd258 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ= @@ -89,6 +89,7 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10/go.mod h1:26a14FGhZVELuz2cc2AolvW4RHmIO3/HRwsdHhaIPDE= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.11/go.mod h1:wHxkgZT1ClZdcwEVP/pDgYK/9HucsnCfMipmJgCz4xY= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.8 h1:AL+nH363NJFS1NXIjCdmj5MOElgKEqgFeoq7vjje350= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.8/go.mod h1:d+z3ScRqc7PFzg4h9oqE3h8yunRZvAvU7u+iuPYEhpU= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= @@ -101,6 +102,8 @@ github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/dm-20151123/v2 v2.4.0 h1:E9e02h6nVk5Aqotb/eQA3MOd8855hfiL6nPEMBmqwdk= github.com/alibabacloud-go/dm-20151123/v2 v2.4.0/go.mod h1:Ykvp8vX/5CFvPjRy7yfCwB/45F4RhMmd6xCO5jpOkc0= +github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.1.2 h1:mNSlLE7QQiZLmC55BJog3PFJFtQp10lbnnEWWIxeCvM= +github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.1.2/go.mod h1:mYOaEwXaib4RLB2NY8cXFjKbxPQHUqt6lhPEOvqR8aw= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/endpoint-util v1.1.1 h1:ZkBv2/jnghxtU0p+upSU0GGzW1VL9GQdZO3mcSUTUy8= github.com/alibabacloud-go/endpoint-util v1.1.1/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= @@ -140,38 +143,38 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= -github.com/argoproj/argo-workflows/v3 v3.6.12 h1:SbCYHBkBku5vVeqw6TcneKq7IxHIRRbkXjVtTAlo8XE= -github.com/argoproj/argo-workflows/v3 v3.6.12/go.mod h1:uITZiLxhyB6G2lnXgZGDwl/ui+j8vsR+1DURM+XKb1Q= +github.com/argoproj/argo-workflows/v3 v3.5.13 h1:d+t+nTBgfHsTTuw+KL3CmBrjvo9/VlRcMNm+FRf8FBA= +github.com/argoproj/argo-workflows/v3 v3.5.13/go.mod h1:DecB01a8UXDCjtIh0udY8XfIMIRrWrlbob7hk/uMmg0= github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.9 h1:Kg+fAYNaJeGXp1vmjtidss8O2uXIsXwaRqsQJKXVr+0= -github.com/aws/aws-sdk-go-v2/config v1.29.9/go.mod h1:oU3jj2O53kgOU4TXq/yipt6ryiooYjlkqqVaZk7gY/U= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62 h1:fvtQY3zFzYJ9CfixuAQ96IxDrBajbBWGqjNTCa79ocU= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62/go.mod h1:ElETBxIQqcxej++Cs8GyPBbgMys5DgQPTwo7cUPDKt8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 h1:KwuLovgQPcdjNMfFt9OhUd9a2OwcOKhxfvF4glTzLuA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= +github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.10 h1:PS+65jThT0T/snC5WjyfHHyUgG+eBoupSDV+f838cro= +github.com/aws/aws-sdk-go-v2/config v1.27.10/go.mod h1:BePM7Vo4OBpHreKRUMuDXX+/+JWP38FLkzl5m27/Jjs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.10 h1:qDZ3EA2lv1KangvQB6y258OssCHD0xvaGiEDkG4X/10= +github.com/aws/aws-sdk-go-v2/credentials v1.17.10/go.mod h1:6t3sucOaYDwDssHQa0ojH1RpmVmF5/jArkye1b2FKMI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.4 h1:WzFol5Cd+yDxPAdnzTA5LmpHYSWinhmSj4rQChV0ee8= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.4/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM= github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw= github.com/benbjohnson/clock v0.0.0-20160125162948-a620c1cc9866/go.mod h1:UMqtWQTnOe4byzwe7Zhwh8f8s+36uszN51sJrSIZlTE= @@ -297,8 +300,8 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= -github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/docker-credential-helpers v0.8.0 h1:YQFtbBQb4VrpoPxhFuzEBPQ9E16qz5SpHLS+uswaCp8= +github.com/docker/docker-credential-helpers v0.8.0/go.mod h1:UGFXcuoQ5TxPiB54nHOZ32AWRqQdECoh/Mg0AlEYb40= github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0= github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -347,8 +350,8 @@ github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQ github.com/fsnotify/fsnotify v1.4.3-0.20170329110642-4da3e2cfbabc/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo= github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -430,8 +433,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8Wd github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc= +github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= @@ -749,6 +752,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nyaruka/phonenumbers v1.6.5 h1:aBCaUhfpRA7hU6fsXk+p7KF1aNx4nQlq9hGeo2qdFg8= +github.com/nyaruka/phonenumbers v1.6.5/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU= github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= diff --git a/notification/notifychannel/channel/sms/client/aliyun_sms_client.go b/notification/notifychannel/channel/sms/client/aliyun_sms_client.go new file mode 100644 index 000000000..ed58d02db --- /dev/null +++ b/notification/notifychannel/channel/sms/client/aliyun_sms_client.go @@ -0,0 +1,59 @@ +package client + +import ( + "strings" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" + "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/types" +) + +type SMSClient interface { + SendSmsWithOptions( + request *dysmsapi20170525.SendSmsRequest, + runtime *util.RuntimeOptions, + ) (*dysmsapi20170525.SendSmsResponse, error) +} + +type AliyunSMSClient struct { + client SMSClient +} + +var _ SMSService = (*AliyunSMSClient)(nil) + +func NewAliyunSMSClient(config *config.Config) (SMSService, error) { + SMSConfig := &openapi.Config{ + AccessKeyId: tea.String(config.Notification.SMSAccessKeyID), + AccessKeySecret: tea.String(config.Notification.SMSAccessKeySecret), + } + client, err := dysmsapi20170525.NewClient(SMSConfig) + if err != nil { + return nil, err + } + return &AliyunSMSClient{ + client: client, + }, nil +} + +func (c *AliyunSMSClient) Send(req types.SMSReq) error { + // refer to sms client doc, the phone area should not have '+' prefix when send sms code to overseas, + for i, phoneNumber := range req.PhoneNumbers { + req.PhoneNumbers[i] = strings.TrimPrefix(phoneNumber, "+") + } + phoneNumbers := strings.Join(req.PhoneNumbers, ",") + smsReq := &dysmsapi20170525.SendSmsRequest{ + PhoneNumbers: tea.String(phoneNumbers), + SignName: tea.String(req.SignName), + TemplateCode: tea.String(req.TemplateCode), + TemplateParam: tea.String(req.TemplateParam), + } + + _, err := c.client.SendSmsWithOptions(smsReq, &util.RuntimeOptions{}) + if err != nil { + return err + } + return nil +} diff --git a/notification/notifychannel/channel/sms/client/aliyun_sms_client_test.go b/notification/notifychannel/channel/sms/client/aliyun_sms_client_test.go new file mode 100644 index 000000000..34372cc26 --- /dev/null +++ b/notification/notifychannel/channel/sms/client/aliyun_sms_client_test.go @@ -0,0 +1,69 @@ +package client + +import ( + "errors" + "testing" + + dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "opencsg.com/csghub-server/common/types" +) + +type mockSMSClient struct { + sendFunc func(req *dysmsapi20170525.SendSmsRequest, runtime *util.RuntimeOptions) (*dysmsapi20170525.SendSmsResponse, error) +} + +func (m *mockSMSClient) SendSmsWithOptions( + req *dysmsapi20170525.SendSmsRequest, + runtime *util.RuntimeOptions, +) (*dysmsapi20170525.SendSmsResponse, error) { + return m.sendFunc(req, runtime) +} + +func TestAliyunSMSClient_Send_Success(t *testing.T) { + mock := &mockSMSClient{ + sendFunc: func(req *dysmsapi20170525.SendSmsRequest, runtime *util.RuntimeOptions) (*dysmsapi20170525.SendSmsResponse, error) { + if *req.PhoneNumbers != "1234567890" { + t.Errorf("unexpected phone number: %s", *req.PhoneNumbers) + } + if *req.SignName != "TestSign" { + t.Errorf("unexpected sign name: %s", *req.SignName) + } + return &dysmsapi20170525.SendSmsResponse{}, nil + }, + } + + client := &AliyunSMSClient{client: mock} + req := types.SMSReq{ + PhoneNumbers: []string{"1234567890"}, + SignName: "TestSign", + TemplateCode: "TEMPLATE_001", + TemplateParam: `{"code":"1234"}`, + } + + err := client.Send(req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestAliyunSMSClient_Send_Failure(t *testing.T) { + mock := &mockSMSClient{ + sendFunc: func(req *dysmsapi20170525.SendSmsRequest, runtime *util.RuntimeOptions) (*dysmsapi20170525.SendSmsResponse, error) { + return nil, errors.New("send failed") + }, + } + + client := &AliyunSMSClient{client: mock} + req := types.SMSReq{ + PhoneNumbers: []string{"1234567890"}, + SignName: "TestSign", + TemplateCode: "TEMPLATE_001", + TemplateParam: `{"code":"1234"}`, + } + + err := client.Send(req) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/notification/notifychannel/channel/sms/client/sms_service.go b/notification/notifychannel/channel/sms/client/sms_service.go new file mode 100644 index 000000000..389f44d45 --- /dev/null +++ b/notification/notifychannel/channel/sms/client/sms_service.go @@ -0,0 +1,7 @@ +package client + +import "opencsg.com/csghub-server/common/types" + +type SMSService interface { + Send(req types.SMSReq) error +} diff --git a/notification/notifychannel/channel/sms/sms.go b/notification/notifychannel/channel/sms/sms.go new file mode 100644 index 000000000..31d95a8e0 --- /dev/null +++ b/notification/notifychannel/channel/sms/sms.go @@ -0,0 +1,50 @@ +package sms + +import ( + "context" + "fmt" + "log/slog" + + "opencsg.com/csghub-server/common/types" + "opencsg.com/csghub-server/notification/notifychannel" + "opencsg.com/csghub-server/notification/notifychannel/channel/sms/client" + "opencsg.com/csghub-server/notification/utils" +) + +type SMSChannel struct { + smsService client.SMSService +} + +func NewSMSChannel(smsService client.SMSService) notifychannel.Notifier { + return &SMSChannel{ + smsService: smsService, + } +} + +var _ notifychannel.Notifier = (*SMSChannel)(nil) + +func (s *SMSChannel) IsFormatRequired() bool { + return false +} + +func (s *SMSChannel) Send(ctx context.Context, req *notifychannel.NotifyRequest) error { + if err := req.Receiver.Validate(); err != nil { + return fmt.Errorf("invalid receiver: %w", err) + } + + var smsReq types.SMSReq + if req.Message != nil { + if extractedSMSReq, ok := req.Message.(types.SMSReq); ok { + smsReq = extractedSMSReq + } else { + slog.Error("invalid sms message format", "message type", fmt.Sprintf("%T", req.Message)) + return fmt.Errorf("invalid sms message format") + } + } + + if err := s.smsService.Send(smsReq); err != nil { + return utils.NewErrSendMsg(err, "failed to send sms") // should not print the message, it contains sensitive information + } + + return nil +} diff --git a/notification/notifychannel/channel/sms/sms_test.go b/notification/notifychannel/channel/sms/sms_test.go new file mode 100644 index 000000000..b343459e7 --- /dev/null +++ b/notification/notifychannel/channel/sms/sms_test.go @@ -0,0 +1,235 @@ +package sms + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "opencsg.com/csghub-server/common/types" + "opencsg.com/csghub-server/notification/notifychannel" +) + +// MockSMSService is a mock implementation of client.SMSService +type MockSMSService struct { + mock.Mock +} + +func (m *MockSMSService) Send(req types.SMSReq) error { + args := m.Called(req) + return args.Error(0) +} + +func TestSMSChannel_Send(t *testing.T) { + tests := []struct { + name string + request *notifychannel.NotifyRequest + mockSetup func(*MockSMSService) + expectedError string + expectedCalled bool + }{ + { + name: "successful send with valid SMS request", + request: ¬ifychannel.NotifyRequest{ + Message: types.SMSReq{ + PhoneNumbers: []string{"+1234567890"}, + SignName: "TestSign", + TemplateCode: "SMS_123456", + TemplateParam: `{"code":"123456"}`, + }, + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: false, + Recipients: map[string][]string{ + "user_phone_numbers": {"+1234567890"}, + }, + }, + }, + mockSetup: func(m *MockSMSService) { + m.On("Send", mock.AnythingOfType("types.SMSReq")).Return(nil) + }, + expectedError: "", + expectedCalled: true, + }, + { + name: "successful send with broadcast receiver", + request: ¬ifychannel.NotifyRequest{ + Message: types.SMSReq{ + PhoneNumbers: []string{"+1234567890", "+0987654321"}, + SignName: "TestSign", + TemplateCode: "SMS_123456", + TemplateParam: `{"code":"123456"}`, + }, + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: true, + Recipients: map[string][]string{}, + }, + }, + mockSetup: func(m *MockSMSService) { + m.On("Send", mock.AnythingOfType("types.SMSReq")).Return(nil) + }, + expectedError: "", + expectedCalled: true, + }, + { + name: "error when receiver validation fails - nil receiver", + request: ¬ifychannel.NotifyRequest{ + Message: types.SMSReq{ + PhoneNumbers: []string{"+1234567890"}, + SignName: "TestSign", + TemplateCode: "SMS_123456", + TemplateParam: `{"code":"123456"}`, + }, + Receiver: nil, + }, + mockSetup: func(m *MockSMSService) { + // No expectations set - should not be called + }, + expectedError: "invalid receiver: receiver cannot be nil", + expectedCalled: false, + }, + { + name: "error when receiver validation fails - no recipients", + request: ¬ifychannel.NotifyRequest{ + Message: types.SMSReq{ + PhoneNumbers: []string{"+1234567890"}, + SignName: "TestSign", + TemplateCode: "SMS_123456", + TemplateParam: `{"code":"123456"}`, + }, + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: false, + Recipients: map[string][]string{}, + }, + }, + mockSetup: func(m *MockSMSService) { + // No expectations set - should not be called + }, + expectedError: "invalid receiver: at least one recipient type must be specified", + expectedCalled: false, + }, + { + name: "error when receiver validation fails - empty recipients", + request: ¬ifychannel.NotifyRequest{ + Message: types.SMSReq{ + PhoneNumbers: []string{"+1234567890"}, + SignName: "TestSign", + TemplateCode: "SMS_123456", + TemplateParam: `{"code":"123456"}`, + }, + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: false, + Recipients: map[string][]string{ + "user_phone_numbers": {}, + }, + }, + }, + mockSetup: func(m *MockSMSService) { + // No expectations set - should not be called + }, + expectedError: "invalid receiver: at least one recipient must be specified", + expectedCalled: false, + }, + { + name: "error when message is not SMSReq type", + request: ¬ifychannel.NotifyRequest{ + Message: "invalid message type", + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: false, + Recipients: map[string][]string{ + "user_phone_numbers": {"+1234567890"}, + }, + }, + }, + mockSetup: func(m *MockSMSService) { + // No expectations set - should not be called + }, + expectedError: "invalid sms message format", + expectedCalled: false, + }, + { + name: "error when message is nil", + request: ¬ifychannel.NotifyRequest{ + Message: nil, + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: false, + Recipients: map[string][]string{ + "user_phone_numbers": {"+1234567890"}, + }, + }, + }, + mockSetup: func(m *MockSMSService) { + // Should be called with empty SMSReq + m.On("Send", types.SMSReq{}).Return(nil) + }, + expectedError: "", + expectedCalled: true, + }, + { + name: "error when SMS service fails", + request: ¬ifychannel.NotifyRequest{ + Message: types.SMSReq{ + PhoneNumbers: []string{"+1234567890"}, + SignName: "TestSign", + TemplateCode: "SMS_123456", + TemplateParam: `{"code":"123456"}`, + }, + Receiver: ¬ifychannel.Receiver{ + IsBroadcast: false, + Recipients: map[string][]string{ + "user_phone_numbers": {"+1234567890"}, + }, + }, + }, + mockSetup: func(m *MockSMSService) { + m.On("Send", mock.AnythingOfType("types.SMSReq")).Return(errors.New("SMS service error")) + }, + expectedError: "failed to send sms", + expectedCalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + mockSMS := &MockSMSService{} + tt.mockSetup(mockSMS) + + channel := NewSMSChannel(mockSMS) + ctx := context.Background() + + // Execute + err := channel.Send(ctx, tt.request) + + // Assert + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + } + + if tt.expectedCalled { + mockSMS.AssertExpectations(t) + } else { + mockSMS.AssertNotCalled(t, "Send") + } + }) + } +} + +func TestSMSChannel_IsFormatRequired(t *testing.T) { + mockSMS := &MockSMSService{} + channel := NewSMSChannel(mockSMS) + + assert.False(t, channel.IsFormatRequired()) +} + +func TestNewSMSChannel(t *testing.T) { + mockSMS := &MockSMSService{} + channel := NewSMSChannel(mockSMS) + + assert.NotNil(t, channel) + assert.Implements(t, (*notifychannel.Notifier)(nil), channel) +} diff --git a/notification/notifychannel/factory/register.go b/notification/notifychannel/factory/register.go index 2ef0aa93d..082804b27 100644 --- a/notification/notifychannel/factory/register.go +++ b/notification/notifychannel/factory/register.go @@ -5,25 +5,26 @@ import ( "opencsg.com/csghub-server/builder/store/database" "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/types" email "opencsg.com/csghub-server/notification/notifychannel/channel/email" emailclient "opencsg.com/csghub-server/notification/notifychannel/channel/email/client" internalmsg "opencsg.com/csghub-server/notification/notifychannel/channel/internalmsg" -) - -const ( - ChannelNameInternalMessage = "internal-message" - ChannelNameEmail = "email" + "opencsg.com/csghub-server/notification/notifychannel/channel/sms" + "opencsg.com/csghub-server/notification/notifychannel/channel/sms/client" ) // Register channels func registerChannels(config *config.Config, factory Factory) { // internal message channel internalMessageChannel := internalmsg.NewChannel(config, database.NewNotificationStore()) - factory.RegisterChannel(ChannelNameInternalMessage, internalMessageChannel) + factory.RegisterChannel(types.MessageChannelInternalMessage.String(), internalMessageChannel) // email channel registerEmailChannel(config, factory) + // sms channel + registerSMSChannel(config, factory) + extendChannels(config, factory) } @@ -41,5 +42,15 @@ func registerEmailChannel(config *config.Config, factory Factory) { } emailChannel := email.NewChannel(config, emailService) - factory.RegisterChannel(ChannelNameEmail, emailChannel) + factory.RegisterChannel(types.MessageChannelEmail.String(), emailChannel) +} + +func registerSMSChannel(config *config.Config, factory Factory) { + smsService, err := client.NewAliyunSMSClient(config) + if err != nil { + slog.Error("failed to create aliyun sms client", "error", err) + return + } + smsChannel := sms.NewSMSChannel(smsService) + factory.RegisterChannel(types.MessageChannelSMS.String(), smsChannel) } diff --git a/notification/notifychannel/receiver.go b/notification/notifychannel/receiver.go index 22141a94f..513aa4d8f 100644 --- a/notification/notifychannel/receiver.go +++ b/notification/notifychannel/receiver.go @@ -15,8 +15,9 @@ type Receiver struct { // common recipient keys const ( - RecipientKeyUserUUIDs = "user_uuids" - RecipientKeyUserEmails = "user_emails" + RecipientKeyUserUUIDs = "user_uuids" + RecipientKeyUserEmails = "user_emails" + RecipientKeyUserPhoneNumbers = "user_phone_numbers" ) func (r *Receiver) GetUserUUIDs() []string { @@ -27,6 +28,10 @@ func (r *Receiver) GetUserEmails() []string { return r.Recipients[RecipientKeyUserEmails] } +func (r *Receiver) GetUserPhoneNumbers() []string { + return r.Recipients[RecipientKeyUserPhoneNumbers] +} + func (r *Receiver) GetRecipients(recipientType string) []string { return r.Recipients[recipientType] } diff --git a/notification/scenariomgr/scenario/smsverifycode/sms_verify_code.go b/notification/scenariomgr/scenario/smsverifycode/sms_verify_code.go new file mode 100644 index 000000000..97278782d --- /dev/null +++ b/notification/scenariomgr/scenario/smsverifycode/sms_verify_code.go @@ -0,0 +1,29 @@ +package smsverifycode + +import ( + "context" + "encoding/json" + + "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/types" + "opencsg.com/csghub-server/notification/notifychannel" + "opencsg.com/csghub-server/notification/scenariomgr" +) + +// implement scenariomgr.GetDataFunc to get sms data +func GetSMSData(ctx context.Context, conf *config.Config, msg types.ScenarioMessage) (*scenariomgr.NotificationData, error) { + var req types.SMSReq + if err := json.Unmarshal([]byte(msg.Parameters), &req); err != nil { + return nil, err + } + + receiver := ¬ifychannel.Receiver{ + IsBroadcast: false, + } + receiver.AddRecipients(notifychannel.RecipientKeyUserPhoneNumbers, req.PhoneNumbers) + + return &scenariomgr.NotificationData{ + Message: req, + Receiver: receiver, + }, nil +} diff --git a/notification/scenarioregister/register.go b/notification/scenarioregister/register.go index 1cfeec2a3..1ecbd84be 100644 --- a/notification/scenarioregister/register.go +++ b/notification/scenarioregister/register.go @@ -5,6 +5,7 @@ import ( "opencsg.com/csghub-server/notification/scenariomgr" "opencsg.com/csghub-server/notification/scenariomgr/scenario/emailverifycode" "opencsg.com/csghub-server/notification/scenariomgr/scenario/internalnotification" + "opencsg.com/csghub-server/notification/scenariomgr/scenario/smsverifycode" ) func Register(d *scenariomgr.DataProvider) { @@ -102,5 +103,15 @@ func Register(d *scenariomgr.DataProvider) { }, }) + // register sms verify code scenario + scenariomgr.RegisterScenario(types.MessageScenarioSMSVerifyCode, &scenariomgr.ScenarioDefinition{ + Channels: []types.MessageChannel{ + types.MessageChannelSMS, + }, + ChannelGetDataFunc: map[types.MessageChannel]scenariomgr.GetDataFunc{ + types.MessageChannelSMS: smsverifycode.GetSMSData, + }, + }) + extend(d) } diff --git a/user/component/user.go b/user/component/user.go index 52c22e13d..f7be3b46c 100644 --- a/user/component/user.go +++ b/user/component/user.go @@ -8,11 +8,14 @@ import ( "fmt" "log/slog" "math/rand" + "strings" "sync" "time" "github.com/bwmarrin/snowflake" + "github.com/cespare/xxhash/v2" "github.com/google/uuid" + "github.com/redis/go-redis/v9" "opencsg.com/csghub-server/builder/git" "opencsg.com/csghub-server/builder/git/gitserver" "opencsg.com/csghub-server/builder/rpc" @@ -22,10 +25,18 @@ import ( "opencsg.com/csghub-server/common/errorx" "opencsg.com/csghub-server/common/types" "opencsg.com/csghub-server/common/types/enum" + "opencsg.com/csghub-server/common/utils/common" + + "github.com/avast/retry-go/v4" ) const GitalyRepoNotFoundErr = "rpc error: code = NotFound desc = repository does not exist" +const ( + SMSCodeCachePrefix = "sms:code" + SMSCodeCacheTTL = 1 * time.Minute +) + type userComponentImpl struct { userStore database.UserStore orgStore database.OrgStore @@ -92,6 +103,10 @@ type UserComponent interface { GetUserUUIDs(ctx context.Context, per, page int) ([]string, int, error) GenerateVerificationCodeAndSendEmail(ctx context.Context, uid, email string) error ResetUserTags(ctx context.Context, uid string, tagIDs []int64) error + SendSMSCode(ctx context.Context, uid string, req types.SendSMSCodeRequest) (*types.SendSMSCodeResponse, error) + UpdatePhone(ctx context.Context, uid string, req types.UpdateUserPhoneRequest) error + SendPublicSMSCode(ctx context.Context, req types.SendPublicSMSCodeRequest) (*types.SendSMSCodeResponse, error) + VerifyPublicSMSCode(ctx context.Context, req types.VerifyPublicSMSCodeRequest) error } func NewUserComponent(config *config.Config) (UserComponent, error) { @@ -149,6 +164,29 @@ func NewUserComponent(config *config.Config) (UserComponent, error) { // panic("implement me later") // } +func (c *userComponentImpl) checkUserConflictsInDB(ctx context.Context, username, email string) error { + exists, err := c.userStore.IsExist(ctx, username) + if err != nil { + return fmt.Errorf("failed to check username existence: %w", err) + } + if exists { + return errorx.UsernameExists(username) + } + + // Check email existence if email is provided + if email != "" { + user, err := c.userStore.FindByEmail(ctx, email) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to check email existence: %w", err) + } + if user.ID > 0 { + return errorx.EmailExists(email) + } + } + + return nil +} + func (c *userComponentImpl) createFromSSOUser(ctx context.Context, cu *rpc.SSOUserInfo) (*database.User, error) { var ( gsUserResp *gitserver.CreateUserResponse @@ -171,6 +209,11 @@ func (c *userComponentImpl) createFromSSOUser(ctx context.Context, cu *rpc.SSOUs canChangeUserName = false email = cu.Email } + + // Check for conflicts before proceeding + if err := c.checkUserConflictsInDB(ctx, userName, email); err != nil { + return nil, err + } //skip creating git user if email is empty, it will be created later when user set email if email != "" { gsUserReq := gitserver.CreateUserRequest{ @@ -197,6 +240,7 @@ func (c *userComponentImpl) createFromSSOUser(ctx context.Context, cu *rpc.SSOUs Gender: cu.Gender, // RoleMask: "", //will be updated when admin set user role Phone: cu.Phone, + PhoneArea: cu.PhoneArea, PhoneVerified: false, EmailVerified: false, LastLoginAt: cu.LastSigninTime, @@ -273,11 +317,6 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU } - if req.Phone != nil && user.Phone != *req.Phone { - if can, reason := c.canChangePhone(ctx, *user, opUser, *req.Phone); !can { - return errors.New(reason) - } - } if len(req.TagIDs) != 0 { if err := c.ResetUserTags(ctx, uuid, req.TagIDs); err != nil { return fmt.Errorf("failed to reset user tags,error:%w", err) @@ -293,9 +332,6 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU if req.Email != nil { params.Email = *req.Email } - if req.Phone != nil { - params.Phone = *req.Phone - } if req.NewUserName != nil { params.Name = *req.NewUserName } @@ -323,7 +359,6 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU Name: oldUser.Username, Email: oldUser.Email, Gender: oldUser.Gender, - Phone: oldUser.Phone, } err := c.sso.UpdateUserInfo(ctx, ¶ms) if err != nil { @@ -409,37 +444,6 @@ func (c *userComponentImpl) canChangeEmail(ctx context.Context, user, opuser dat return true, "" } -func (c *userComponentImpl) canChangePhone(ctx context.Context, user database.User, opUser database.User, newPhone string) (bool, string) { - if opUser.ID != user.ID { - return false, "phone can only be changed by the user itself" - } - // if user.RegProvider != "casdoor" { - // return true, "" - // } - // check phone existence in casdoor - // casu, err := c.casc.GetUserByPhone(newPhone) - // if err != nil { - // return false, "failed to check new phone existence in casdoor" - // } - // if casu != nil && casu.Id != user.UUID { - // return false, "new phone already exists in casdoor" - // } - - if !c.IsSSOUser(user.RegProvider) { - return true, "" - } - - exist, err := c.sso.IsExistByPhone(ctx, newPhone) - if err != nil { - return false, "failed to check new phone existence in casdoor" - } - if exist { - return false, "new phone already exists in casdoor" - } - - return true, "" -} - // Depricated: only useful for gitea, will be removed in the future // user registry with wechat does not have email, so git user is not created after signin // when user set email, a git user needs to be created @@ -494,12 +498,6 @@ func (c *userComponentImpl) setChangedProps(oldUser *database.User, req *types.U if req.Homepage != nil { user.Homepage = *req.Homepage } - if req.Phone != nil { - user.Phone = *req.Phone - } - if req.PhoneArea != nil { - user.PhoneArea = *req.PhoneArea - } if req.Nickname != nil { user.NickName = *req.Nickname } @@ -1182,3 +1180,236 @@ func (c *userComponentImpl) ResetUserTags(ctx context.Context, uid string, tagID } return nil } + +func normalizePhoneArea(phoneArea string) string { + if !strings.HasPrefix(phoneArea, "+") { + return fmt.Sprintf("+%s", phoneArea) + } + return phoneArea +} + +func getSMSCodeCacheKey(identifier, phoneArea, phone string) (string, error) { + h := xxhash.New() + _, err := fmt.Fprintf(h, "%s:%s", phoneArea, phone) + if err != nil { + slog.Error("failed to write phone area and phone to hash", "error", err) + return "", errorx.ErrInternalServerError + } + return fmt.Sprintf("%s:%s:%x", SMSCodeCachePrefix, identifier, h.Sum64()), nil +} + +func (c *userComponentImpl) SendSMSCode(ctx context.Context, uid string, req types.SendSMSCodeRequest) (*types.SendSMSCodeResponse, error) { + user, err := c.userStore.FindByUUID(ctx, uid) + if err != nil { + return nil, err + } + if user == nil { + return nil, errorx.ErrUserNotFound + } + + phoneArea := normalizePhoneArea(req.PhoneArea) + key, err := getSMSCodeCacheKey(uid, phoneArea, req.Phone) + if err != nil { + return nil, err + } + + return c.sendAndStoreSMSCode(ctx, phoneArea, req.Phone, key) +} + +func (c *userComponentImpl) sendAndStoreSMSCode(ctx context.Context, phoneArea, phone, cacheKey string) (*types.SendSMSCodeResponse, error) { + isValid, err := common.IsValidNumber(phone, phoneArea) + if err != nil { + slog.Error("failed to check if phone number is valid", "error", err) + return nil, errorx.ErrInternalServerError + } + if !isValid { + slog.Error("phone number is invalid") + return nil, errorx.ErrInvalidPhoneNumber + } + + code := fmt.Sprintf("%06d", rand.Intn(1000000)) + set, err := c.cache.SetNX(ctx, cacheKey, code, SMSCodeCacheTTL) + if err != nil { + slog.Error("failed to set sms code cache", "error", err) + return nil, errorx.ErrInternalServerError + } + if !set { + return nil, errorx.ErrForbidSendPhoneVerifyCodeFrequently + } + expiredAt := time.Now().Add(SMSCodeCacheTTL) + + var templateCode string + if phoneArea == "+86" { + templateCode = c.config.Notification.SMSTemplateCodeForVerifyCodeCN + } else { + templateCode = c.config.Notification.SMSTemplateCodeForVerifyCodeOversea + } + msg := types.SMSReq{ + PhoneNumbers: []string{fmt.Sprintf("%s%s", phoneArea, phone)}, + SignName: c.config.Notification.SMSSign, + TemplateCode: templateCode, + TemplateParam: fmt.Sprintf("{\"code\":\"%s\"}", code), + } + + msgBytes, err := json.Marshal(msg) + if err != nil { + slog.Error("failed to marshal sms code notification message", "error", err) + return nil, errorx.ErrInternalServerError + } + notificationMsg := types.MessageRequest{ + Scenario: types.MessageScenarioSMSVerifyCode, + Parameters: string(msgBytes), + Priority: types.MessagePriorityHigh, + } + + err = retry.Do(func() error { + return c.notificationSvc.Send(ctx, ¬ificationMsg) + }, retry.Attempts(3)) + if err != nil { + slog.Error("failed to send sms code", "error", err) + return nil, errorx.ErrFailedSendPhoneVerifyCode + } + + return &types.SendSMSCodeResponse{ + ExpiredAt: expiredAt, + }, nil +} + +func (c *userComponentImpl) UpdatePhone(ctx context.Context, uid string, req types.UpdateUserPhoneRequest) error { + user, err := c.userStore.FindByUUID(ctx, uid) + if err != nil { + return err + } + if user == nil { + return errorx.ErrUserNotFound + } + + var oldUser = *user + + if *req.Phone == "" { + return errorx.ErrNeedPhone + } + + if user.Phone == *req.Phone { + return errorx.ErrNeedDifferentPhone + } + + can, err := c.canChangePhone(ctx, user, *req.Phone) + if err != nil { + return err + } + if !can { + return errorx.ErrForbidChangePhone + } + + var phoneArea = user.PhoneArea + if req.PhoneArea != nil { + normalizedPhoneArea := normalizePhoneArea(*req.PhoneArea) + if user.PhoneArea != normalizedPhoneArea { + phoneArea = normalizedPhoneArea + } + } + + key, err := getSMSCodeCacheKey(uid, phoneArea, *req.Phone) + if err != nil { + slog.Error("failed to get sms code cache key", "error", err) + return errorx.ErrInternalServerError + } + + err = c.verifySMSCode(ctx, key, *req.VerificationCode) + if err != nil { + return err + } + + if c.IsSSOUser(user.RegProvider) { + params := rpc.SSOUpdateUserInfo{ + UUID: user.UUID, + Phone: *req.Phone, + PhoneArea: phoneArea, + } + err := c.sso.UpdateUserInfo(ctx, ¶ms) + if err != nil { + slog.Error("failed to update user's phone in sso, uuid:'%s',error:%w", user.UUID, err) + return err + } + } + + dbErr := c.userStore.UpdatePhone(ctx, user.ID, *req.Phone, phoneArea) + if dbErr != nil { + slog.Error("failed to update user's phone in db, uuid:'%s',error:%w", user.UUID, dbErr) + // rollback sso user phone change in sso + params := rpc.SSOUpdateUserInfo{ + UUID: user.UUID, + Phone: oldUser.Phone, + PhoneArea: oldUser.PhoneArea, + } + err := c.sso.UpdateUserInfo(ctx, ¶ms) + if err != nil { + slog.Error("failed to rollback sso user phone change in sso, uuid:'%s',error:%w", user.UUID, err) + return err + } + return errorx.ErrFailedToUpdatePhone + } + + return nil +} + +func (c *userComponentImpl) SendPublicSMSCode(ctx context.Context, req types.SendPublicSMSCodeRequest) (*types.SendSMSCodeResponse, error) { + phoneArea := normalizePhoneArea(req.PhoneArea) + key, err := getSMSCodeCacheKey(req.Scene, phoneArea, req.Phone) + if err != nil { + return nil, err + } + + return c.sendAndStoreSMSCode(ctx, phoneArea, req.Phone, key) +} + +func (c *userComponentImpl) VerifyPublicSMSCode(ctx context.Context, req types.VerifyPublicSMSCodeRequest) error { + phoneArea := normalizePhoneArea(req.PhoneArea) + key, err := getSMSCodeCacheKey(req.Scene, phoneArea, req.Phone) + if err != nil { + slog.Error("failed to get sms code cache key", "error", err) + return errorx.ErrInternalServerError + } + + return c.verifySMSCode(ctx, key, req.VerificationCode) +} + +func (c *userComponentImpl) canChangePhone(ctx context.Context, user *database.User, newPhone string) (bool, error) { + if !c.IsSSOUser(user.RegProvider) { + return true, nil + } + + exist, err := c.sso.IsExistByPhone(ctx, newPhone) + if err != nil { + slog.Error("failed to check new phone existence in sso", "error", err) + return false, err + } + + if exist { + return false, errorx.ErrPhoneAlreadyExistsInSSO + } + + return true, nil +} + +func (c *userComponentImpl) verifySMSCode(ctx context.Context, cacheKey, smsCode string) error { + code, err := c.cache.Get(ctx, cacheKey) + if err != nil { + if err == redis.Nil { + return errorx.ErrPhoneVerifyCodeExpiredOrNotFound + } + slog.Error("failed to get sms code cache", "error", err) + return errorx.ErrInternalServerError + } + + if code != smsCode { + return errorx.ErrPhoneVerifyCodeInvalid + } + + if err := c.cache.Del(ctx, cacheKey); err != nil { + slog.Error("failed to delete sms code cache", "error", err) + return errorx.ErrInternalServerError + } + return nil +} diff --git a/user/component/user_test.go b/user/component/user_test.go index 052ecbf3d..bfe498a7c 100644 --- a/user/component/user_test.go +++ b/user/component/user_test.go @@ -2,14 +2,21 @@ package component import ( "context" + "database/sql" + "errors" + "sync" "testing" + "github.com/redis/go-redis/v9" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" mockgit "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/builder/git/gitserver" + mockrpc "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/builder/rpc" + mockcache "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/builder/store/cache" mockdb "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/builder/store/database" "opencsg.com/csghub-server/builder/store/database" "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/errorx" "opencsg.com/csghub-server/common/types" ) @@ -145,3 +152,417 @@ func TestUserComponent_Delete(t *testing.T) { err := uc.Delete(context.TODO(), "user1", "user2") require.Nil(t, err) } + +func TestUserComponent_SendSMSCode(t *testing.T) { + mockUserStore := mockdb.NewMockUserStore(t) + mockNotificationSvcClient := mockrpc.NewMockNotificationSvcClient(t) + mockUserStore.EXPECT().FindByUUID(mock.Anything, "user1").Return(&database.User{ + ID: 1, + }, nil) + mockNotificationSvcClient.EXPECT().Send(mock.Anything, mock.MatchedBy(func(req *types.MessageRequest) bool { + return req.Scenario == types.MessageScenarioSMSVerifyCode + })).Return(nil) + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().SetNX(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(true, nil) + + config := &config.Config{} + config.Notification.SMSTemplateCodeForVerifyCodeOversea = "test" + config.Notification.SMSTemplateCodeForVerifyCodeCN = "test" + config.Notification.SMSSign = "test" + + uc := &userComponentImpl{ + userStore: mockUserStore, + notificationSvc: mockNotificationSvcClient, + cache: cache, + config: config, + } + resp, err := uc.SendSMSCode(context.TODO(), "user1", types.SendSMSCodeRequest{ + Phone: "13626487789", + PhoneArea: "+86", + }) + require.Nil(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.ExpiredAt) +} + +func TestUserComponent_SendSMSCode_InvalidPhoneNumber(t *testing.T) { + mockUserStore := mockdb.NewMockUserStore(t) + mockUserStore.EXPECT().FindByUUID(mock.Anything, "user1").Return(&database.User{ + ID: 1, + }, nil) + + uc := &userComponentImpl{ + userStore: mockUserStore, + } + resp, err := uc.SendSMSCode(context.TODO(), "user1", types.SendSMSCodeRequest{ + Phone: "66668877", + PhoneArea: "+86", + }) + require.NotNil(t, err) + require.Nil(t, resp) +} + +func TestUserComponent_UpdatePhone(t *testing.T) { + var code = "123456" + var phone = "13626487789" + var phoneArea = "+86" + + mockUserStore := mockdb.NewMockUserStore(t) + mockUserStore.EXPECT().FindByUUID(mock.Anything, "user1").Return(&database.User{ + ID: int64(1), + Phone: "13626487711", + PhoneArea: "+86", + RegProvider: "casdoor", + }, nil) + mockUserStore.EXPECT().UpdatePhone(mock.Anything, int64(1), "13626487789", "+86").Return(nil) + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().Del(mock.Anything, mock.Anything).Return(nil) + cache.EXPECT().Get(mock.Anything, mock.Anything).Return("123456", nil) + + ssomock := mockrpc.NewMockSSOInterface(t) + ssomock.EXPECT().IsExistByPhone(mock.Anything, phone).Return(false, nil) + ssomock.EXPECT().UpdateUserInfo(mock.Anything, mock.Anything).Return(nil) + + config := &config.Config{} + config.SSOType = "casdoor" + + uc := &userComponentImpl{ + userStore: mockUserStore, + cache: cache, + sso: ssomock, + config: config, + } + req := &types.UpdateUserPhoneRequest{ + Phone: &phone, + PhoneArea: &phoneArea, + VerificationCode: &code, + } + err := uc.UpdatePhone(context.TODO(), "user1", *req) + require.Nil(t, err) +} + +func TestUserComponent_SendPublicSMSCode(t *testing.T) { + mockNotificationSvcClient := mockrpc.NewMockNotificationSvcClient(t) + mockNotificationSvcClient.EXPECT().Send(mock.Anything, mock.MatchedBy(func(req *types.MessageRequest) bool { + return req.Scenario == types.MessageScenarioSMSVerifyCode + })).Return(nil) + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().SetNX(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(true, nil) + + config := &config.Config{} + config.Notification.SMSTemplateCodeForVerifyCodeOversea = "test" + config.Notification.SMSTemplateCodeForVerifyCodeCN = "test" + config.Notification.SMSSign = "test" + + uc := &userComponentImpl{ + notificationSvc: mockNotificationSvcClient, + cache: cache, + config: config, + } + resp, err := uc.SendPublicSMSCode(context.TODO(), types.SendPublicSMSCodeRequest{ + Scene: "submit-form", + Phone: "13626487789", + PhoneArea: "+86", + }) + require.Nil(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.ExpiredAt) +} + +func TestUserComponent_SendPublicSMSCode_InvalidPhoneNumber(t *testing.T) { + uc := &userComponentImpl{} + resp, err := uc.SendPublicSMSCode(context.TODO(), types.SendPublicSMSCodeRequest{ + Scene: "submit-form", + Phone: "66668877", + PhoneArea: "+86", + }) + require.NotNil(t, err) + require.Nil(t, resp) + require.ErrorIs(t, err, errorx.ErrInvalidPhoneNumber) +} + +func TestUserComponent_SendPublicSMSCode_PhoneAreaWithoutPrefix(t *testing.T) { + mockNotificationSvcClient := mockrpc.NewMockNotificationSvcClient(t) + mockNotificationSvcClient.EXPECT().Send(mock.Anything, mock.MatchedBy(func(req *types.MessageRequest) bool { + return req.Scenario == types.MessageScenarioSMSVerifyCode + })).Return(nil) + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().SetNX(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(true, nil) + + config := &config.Config{} + config.Notification.SMSTemplateCodeForVerifyCodeOversea = "test" + config.Notification.SMSTemplateCodeForVerifyCodeCN = "test" + config.Notification.SMSSign = "test" + + uc := &userComponentImpl{ + notificationSvc: mockNotificationSvcClient, + cache: cache, + config: config, + } + resp, err := uc.SendPublicSMSCode(context.TODO(), types.SendPublicSMSCodeRequest{ + Scene: "submit-form", + Phone: "13626487789", + PhoneArea: "86", + }) + require.Nil(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.ExpiredAt) +} + +func TestUserComponent_VerifyPublicSMSCode(t *testing.T) { + code := "123456" + phone := "13626487789" + phoneArea := "+86" + scene := "submit-form" + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().Get(mock.Anything, mock.Anything).Return("123456", nil) + cache.EXPECT().Del(mock.Anything, mock.Anything).Return(nil) + + uc := &userComponentImpl{ + cache: cache, + } + req := types.VerifyPublicSMSCodeRequest{ + Scene: scene, + Phone: phone, + PhoneArea: phoneArea, + VerificationCode: code, + } + err := uc.VerifyPublicSMSCode(context.TODO(), req) + require.Nil(t, err) +} + +func TestUserComponent_VerifyPublicSMSCode_InvalidCode(t *testing.T) { + wrongCode := "654321" + phone := "13626487789" + phoneArea := "+86" + scene := "submit-form" + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().Get(mock.Anything, mock.Anything).Return("123456", nil) + + uc := &userComponentImpl{ + cache: cache, + } + req := types.VerifyPublicSMSCodeRequest{ + Scene: scene, + Phone: phone, + PhoneArea: phoneArea, + VerificationCode: wrongCode, + } + err := uc.VerifyPublicSMSCode(context.TODO(), req) + require.NotNil(t, err) + require.ErrorIs(t, err, errorx.ErrPhoneVerifyCodeInvalid) +} + +func TestUserComponent_VerifyPublicSMSCode_ExpiredCode(t *testing.T) { + code := "123456" + phone := "13626487789" + phoneArea := "+86" + scene := "submit-form" + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().Get(mock.Anything, mock.Anything).Return("", redis.Nil) + + uc := &userComponentImpl{ + cache: cache, + } + req := types.VerifyPublicSMSCodeRequest{ + Scene: scene, + Phone: phone, + PhoneArea: phoneArea, + VerificationCode: code, + } + err := uc.VerifyPublicSMSCode(context.TODO(), req) + require.NotNil(t, err) + require.ErrorIs(t, err, errorx.ErrPhoneVerifyCodeExpiredOrNotFound) +} + +func TestUserComponent_VerifyPublicSMSCode_PhoneAreaWithoutPrefix(t *testing.T) { + code := "123456" + phone := "13626487789" + phoneArea := "86" + scene := "submit-form" + + cache := mockcache.NewMockRedisClient(t) + cache.EXPECT().Get(mock.Anything, mock.Anything).Return("123456", nil) + cache.EXPECT().Del(mock.Anything, mock.Anything).Return(nil) + + uc := &userComponentImpl{ + cache: cache, + } + req := types.VerifyPublicSMSCodeRequest{ + Scene: scene, + Phone: phone, + PhoneArea: phoneArea, + VerificationCode: code, + } + err := uc.VerifyPublicSMSCode(context.TODO(), req) + require.Nil(t, err) +} + +// test update UpdateByUUID +func TestUserComponent_UpdateByUUID_UpdateUserName(t *testing.T) { + mockUserStore := mockdb.NewMockUserStore(t) + mockUserStore.EXPECT().FindByUUID(mock.Anything, "user1").Return(&database.User{ + ID: 1, + UUID: "user1", + Username: "user1", + CanChangeUserName: true, + RegProvider: "casdoor", + }, nil) + mockUserStore.EXPECT().FindByUsername(mock.Anything, "new_user1").Return(database.User{}, nil) + mockUserStore.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(nil) + + ssomock := mockrpc.NewMockSSOInterface(t) + ssomock.EXPECT().UpdateUserInfo(mock.Anything, mock.Anything).Return(nil) + ssomock.EXPECT().IsExistByName(mock.Anything, "new_user1").Return(false, nil) + + config := &config.Config{} + config.SSOType = "casdoor" + + once := sync.Once{} + uc := &userComponentImpl{ + userStore: mockUserStore, + sso: ssomock, + config: config, + once: &once, + } + var userUUID = "user1" + var newUserName = "new_user1" + err := uc.UpdateByUUID(context.TODO(), &types.UpdateUserRequest{ + UUID: &userUUID, + OpUser: "user1", + NewUserName: &newUserName, + }) + require.Nil(t, err) +} + +func TestUserComponent_checkUserConflictsInDB(t *testing.T) { + tests := []struct { + name string + username string + email string + mockSetup func(*mockdb.MockUserStore) + expectedError error + expectError bool + }{ + { + name: "no conflicts - username and email available", + username: "newuser", + email: "newuser@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, nil) + mockUserStore.EXPECT().FindByEmail(mock.Anything, "newuser@example.com").Return(database.User{ID: 0}, sql.ErrNoRows) + }, + expectedError: nil, + expectError: false, + }, + { + name: "no conflicts - username available, no email provided", + username: "newuser", + email: "", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, nil) + }, + expectedError: nil, + expectError: false, + }, + { + name: "username conflict", + username: "existinguser", + email: "newuser@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "existinguser").Return(true, nil) + }, + expectedError: errorx.UsernameExists("existinguser"), + expectError: true, + }, + { + name: "email conflict", + username: "newuser", + email: "existing@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, nil) + mockUserStore.EXPECT().FindByEmail(mock.Anything, "existing@example.com").Return(database.User{ID: 123}, nil) + }, + expectedError: errorx.EmailExists("existing@example.com"), + expectError: true, + }, + { + name: "username check database error", + username: "newuser", + email: "newuser@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, errors.New("database connection error")) + }, + expectedError: nil, + expectError: true, + }, + { + name: "email check database error", + username: "newuser", + email: "newuser@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, nil) + mockUserStore.EXPECT().FindByEmail(mock.Anything, "newuser@example.com").Return(database.User{}, errors.New("database connection error")) + }, + expectedError: nil, + expectError: true, + }, + { + name: "email check returns ErrNoRows - no conflict", + username: "newuser", + email: "newuser@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, nil) + mockUserStore.EXPECT().FindByEmail(mock.Anything, "newuser@example.com").Return(database.User{ID: 0}, sql.ErrNoRows) + }, + expectedError: nil, + expectError: false, + }, + { + name: "email check returns user with ID 0 - no conflict", + username: "newuser", + email: "newuser@example.com", + mockSetup: func(mockUserStore *mockdb.MockUserStore) { + mockUserStore.EXPECT().IsExist(mock.Anything, "newuser").Return(false, nil) + mockUserStore.EXPECT().FindByEmail(mock.Anything, "newuser@example.com").Return(database.User{ID: 0}, nil) + }, + expectedError: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUserStore := mockdb.NewMockUserStore(t) + tt.mockSetup(mockUserStore) + + uc := &userComponentImpl{ + userStore: mockUserStore, + } + + err := uc.checkUserConflictsInDB(context.Background(), tt.username, tt.email) + + if tt.expectError { + require.Error(t, err) + if tt.expectedError != nil { + // Check if the error is the expected custom error type + var customErr errorx.CustomError + if errors.As(err, &customErr) { + require.True(t, customErr.Is(tt.expectedError), "Expected error type %v, got %v", tt.expectedError, err) + } else { + require.Contains(t, err.Error(), "failed to check", "Expected database error message") + } + } + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/user/handler/user.go b/user/handler/user.go index 23bcd8a11..4156421dd 100644 --- a/user/handler/user.go +++ b/user/handler/user.go @@ -324,6 +324,13 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { jwtToken, signed, err := h.c.Signin(ctx.Request.Context(), code, state) if err != nil { slog.Error("Failed to signin", slog.Any("error", err), slog.String("code", code), slog.String("state", state)) + var customErr errorx.CustomError + if errors.As(err, &customErr) { + if handleConflictCustomError(ctx, customErr, h.signinFailureRedirectURL) { + return + } + } + errorMsg := url.QueryEscape(fmt.Sprintf("failed to signin: %v", err)) errorRedirectURL := fmt.Sprintf("%s?error_code=500&error_message=%s", h.signinFailureRedirectURL, errorMsg) slog.Info("redirecting to error page", slog.String("url", errorRedirectURL)) @@ -797,3 +804,159 @@ func (e *UserHandler) ResetUserTags(ctx *gin.Context) { httpbase.OK(ctx, nil) } + +// SendSmsCode godoc +// @Security ApiKey +// @Summary generate sms verification code and send it by sms +// @Description generate sms verification code and send it by sms +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.SendSMSCodeRequest true "SendSMSCodeRequest" +// @Success 200 {object} types.Response{data=types.SendSMSCodeResponse} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/sms-code [post] +func (e *UserHandler) SendSMSCode(ctx *gin.Context) { + currentUserUUID := httpbase.GetCurrentUserUUID(ctx) + var req types.SendSMSCodeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("SendSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + resp, err := e.c.SendSMSCode(ctx, currentUserUUID, req) + if err != nil { + slog.Error("SendSMSCode failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + httpbase.OK(ctx, resp) +} + +// SendPublicSMSCode godoc +// @Security ApiKey +// @Summary generate sms verification code and send it by sms (public endpoint) +// @Description generate sms verification code and send it by sms with scene parameter. Accessible to both logged-in and anonymous users. +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.SendPublicSMSCodeRequest true "SendPublicSMSCodeRequest" +// @Success 200 {object} types.Response{data=types.SendSMSCodeResponse} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/public/sms-code [post] +func (e *UserHandler) SendPublicSMSCode(ctx *gin.Context) { + var req types.SendPublicSMSCodeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("SendPublicSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + resp, err := e.c.SendPublicSMSCode(ctx, req) + if err != nil { + slog.Error("SendPublicSMSCode failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + httpbase.OK(ctx, resp) +} + +// VerifyPublicSMSCode godoc +// @Security ApiKey +// @Summary verify sms verification code (public endpoint) +// @Description verify sms verification code with scene parameter. Accessible to both logged-in and anonymous users. +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.VerifyPublicSMSCodeRequest true "VerifyPublicSMSCodeRequest" +// @Success 200 {object} types.Response{data=nil} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/public/sms-code/verify [post] +func (e *UserHandler) VerifyPublicSMSCode(ctx *gin.Context) { + var req types.VerifyPublicSMSCodeRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("VerifyPublicSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + err := e.c.VerifyPublicSMSCode(ctx, req) + if err != nil { + slog.Error("VerifyPublicSMSCode failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + httpbase.OK(ctx, nil) +} + +// UpdatePhone godoc +// @Security ApiKey +// @Summary Update current user phone +// @Description Update current user phone +// @Tags User +// @Accept json +// @Produce json +// @Param body body types.UpdateUserPhoneRequest true "UpdateUserPhoneRequest" +// @Success 200 {object} types.Response{} "OK" +// @Failure 400 {object} types.APIBadRequest "Bad request" +// @Failure 500 {object} types.APIInternalServerError "Internal server error" +// @Router /user/phone [put] +func (e *UserHandler) UpdatePhone(ctx *gin.Context) { + currentUserUUID := httpbase.GetCurrentUserUUID(ctx) + var req types.UpdateUserPhoneRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + slog.Error("failed to update user's phone", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + + err := e.c.UpdatePhone(ctx, currentUserUUID, req) + if err != nil { + slog.Error("failed to update user's phone", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + + httpbase.OK(ctx, nil) +} + +func handleConflictCustomError(ctx *gin.Context, customErr errorx.CustomError, redirectURL string) bool { + errCode := customErr.Code() + + cField, cValue, ok := extractConflictInfo(customErr) + if !ok { + return false + } + + u, _ := url.Parse(redirectURL) + q := u.Query() + q.Set("error_code", strconv.Itoa(http.StatusConflict)) + q.Set("error_message_code", errCode) + q.Set("field_name", cField) + q.Set("field_value", cValue) + u.RawQuery = q.Encode() + slog.Info("redirecting to error page with conflict error", slog.String("url", u.String())) + ctx.Redirect(http.StatusMovedPermanently, u.String()) + return true +} + +func extractConflictInfo(customErr errorx.CustomError) (field, value string, ok bool) { + errCtx := customErr.Context() + + switch { + case customErr.Is(errorx.ErrUsernameExists): + if username, exists := errCtx["username"]; exists { + if usernameStr, ok := username.(string); ok { + return "username", usernameStr, true + } + } + case customErr.Is(errorx.ErrEmailExists): + if email, exists := errCtx["email"]; exists { + if emailStr, ok := email.(string); ok { + return "email", emailStr, true + } + } + } + return "", "", false +} diff --git a/user/handler/user_test.go b/user/handler/user_test.go index 5ee935729..1a6ffd296 100644 --- a/user/handler/user_test.go +++ b/user/handler/user_test.go @@ -1,9 +1,11 @@ package handler import ( + "bytes" "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -284,3 +286,345 @@ func TestUserHandler_Casdoor(t *testing.T) { mockUserComp.AssertExpectations(t) }) } + +// test send sms code +func TestUserHandler_SendSMSCode(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + mockUserComponent.EXPECT().SendSMSCode(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Set(httpbase.CurrentUserUUIDCtxVar, "test-user-uuid") + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/sms-code", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"phone": "12345678901", "phone_area": "+86"}`))) + handler.SendSMSCode(ctx) + assert.Equal(t, http.StatusOK, w.Code) +} + +// test update phone +func TestUserHandler_UpdatePhone(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + mockUserComponent.EXPECT().UpdatePhone(mock.Anything, mock.Anything, mock.Anything).Return(nil) + + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Set(httpbase.CurrentUserUUIDCtxVar, "test-user-uuid") + ctx.Request, _ = http.NewRequest(http.MethodPut, "/user/phone", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"phone": "12345678901", "phone_area": "+86", "verification_code": "123456"}`))) + handler.UpdatePhone(ctx) + assert.Equal(t, http.StatusOK, w.Code) +} + +// test send public sms code +func TestUserHandler_SendPublicSMSCode(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + mockResponse := &types.SendSMSCodeResponse{} + mockUserComponent.EXPECT().SendPublicSMSCode(mock.Anything, mock.Anything).Return(mockResponse, nil) + + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/public/sms-code", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"scene": "submit-form", "phone": "13626487789", "phone_area": "+86"}`))) + handler.SendPublicSMSCode(ctx) + assert.Equal(t, http.StatusOK, w.Code) +} + +// test send public sms code with invalid request +func TestUserHandler_SendPublicSMSCode_InvalidRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/public/sms-code", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"invalid": "request"}`))) + handler.SendPublicSMSCode(ctx) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// test send public sms code with error +func TestUserHandler_SendPublicSMSCode_Error(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + mockUserComponent.EXPECT().SendPublicSMSCode(mock.Anything, mock.Anything).Return(nil, errorx.ErrInvalidPhoneNumber) + + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/public/sms-code", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"scene": "submit-form", "phone": "13626487789", "phone_area": "+86"}`))) + handler.SendPublicSMSCode(ctx) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// test verify public sms code +func TestUserHandler_VerifyPublicSMSCode(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + mockUserComponent.EXPECT().VerifyPublicSMSCode(mock.Anything, mock.Anything).Return(nil) + + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/public/sms-code/verify", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"scene": "submit-form", "phone": "13626487789", "phone_area": "+86", "verification_code": "123456"}`))) + handler.VerifyPublicSMSCode(ctx) + assert.Equal(t, http.StatusOK, w.Code) +} + +// test verify public sms code with invalid request +func TestUserHandler_VerifyPublicSMSCode_InvalidRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/public/sms-code/verify", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"invalid": "request"}`))) + handler.VerifyPublicSMSCode(ctx) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// test verify public sms code with error +func TestUserHandler_VerifyPublicSMSCode_Error(t *testing.T) { + gin.SetMode(gin.TestMode) + + mockUserComponent := component.NewMockUserComponent(t) + mockUserComponent.EXPECT().VerifyPublicSMSCode(mock.Anything, mock.Anything).Return(errorx.ErrPhoneVerifyCodeInvalid) + + handler := UserHandler{ + c: mockUserComponent, + } + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request, _ = http.NewRequest(http.MethodPost, "/user/public/sms-code/verify", nil) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Body = io.NopCloser(bytes.NewBuffer([]byte(`{"scene": "submit-form", "phone": "13626487789", "phone_area": "+86", "verification_code": "123456"}`))) + handler.VerifyPublicSMSCode(ctx) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestHandleConflictCustomError(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + customErr errorx.CustomError + redirectURL string + expectedStatus int + expectedURL string + expectRedirect bool + }{ + { + name: "username conflict - successful redirect", + customErr: errorx.UsernameExists("testuser").(errorx.CustomError), + redirectURL: "https://example.com/error", + expectedStatus: http.StatusMovedPermanently, + expectedURL: "https://example.com/error?error_code=409&error_message_code=USER-ERR-12&field_name=username&field_value=testuser", + expectRedirect: true, + }, + { + name: "email conflict - successful redirect", + customErr: errorx.EmailExists("test@example.com").(errorx.CustomError), + redirectURL: "https://example.com/error", + expectedStatus: http.StatusMovedPermanently, + expectedURL: "https://example.com/error?error_code=409&error_message_code=USER-ERR-13&field_name=email&field_value=test%40example.com", + expectRedirect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest("GET", "/test", nil) + + result := handleConflictCustomError(ctx, tt.customErr, tt.redirectURL) + + assert.True(t, result, "handleConflictCustomError should return true for valid conflicts") + assert.Equal(t, tt.expectedStatus, w.Code, "HTTP status code should be 301 Moved Permanently") + + assert.Equal(t, tt.expectedURL, w.Header().Get("Location"), "Redirect URL should match expected URL") + }) + } +} + +func TestHandleConflictCustomError_InvalidErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + customErr errorx.CustomError + redirectURL string + expectReturn bool + }{ + { + name: "non-conflict error - should return false", + customErr: errorx.NewCustomError("USER-ERR", 1, nil, nil), // Some other error + redirectURL: "https://example.com/error", + expectReturn: false, + }, + { + name: "username conflict but no username in context", + customErr: errorx.NewCustomError("USER-ERR", 18, nil, map[string]interface{}{}), // UsernameExists but no username + redirectURL: "https://example.com/error", + expectReturn: false, + }, + { + name: "email conflict but no email in context", + customErr: errorx.NewCustomError("USER-ERR", 19, nil, map[string]interface{}{}), // EmailExists but no email + redirectURL: "https://example.com/error", + expectReturn: false, + }, + { + name: "username conflict but username is not string", + customErr: errorx.NewCustomError("USER-ERR", 18, nil, map[string]interface{}{"username": 123}), // UsernameExists but username is int + redirectURL: "https://example.com/error", + expectReturn: false, + }, + { + name: "email conflict but email is not string", + customErr: errorx.NewCustomError("USER-ERR", 19, nil, map[string]interface{}{"email": 123}), // EmailExists but email is int + redirectURL: "https://example.com/error", + expectReturn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + result := handleConflictCustomError(ctx, tt.customErr, tt.redirectURL) + + assert.Equal(t, tt.expectReturn, result, "handleConflictCustomError should return expected result") + + }) + } +} + +func TestExtractConflictInfo(t *testing.T) { + tests := []struct { + name string + customErr errorx.CustomError + expectedField string + expectedValue string + expectedOk bool + }{ + { + name: "username conflict with valid username", + customErr: errorx.UsernameExists("testuser").(errorx.CustomError), + expectedField: "username", + expectedValue: "testuser", + expectedOk: true, + }, + { + name: "email conflict with valid email", + customErr: errorx.EmailExists("test@example.com").(errorx.CustomError), + expectedField: "email", + expectedValue: "test@example.com", + expectedOk: true, + }, + { + name: "username conflict with special characters", + customErr: errorx.UsernameExists("user@domain.com").(errorx.CustomError), + expectedField: "username", + expectedValue: "user@domain.com", + expectedOk: true, + }, + { + name: "email conflict with special characters", + customErr: errorx.EmailExists("user+tag@domain.com").(errorx.CustomError), + expectedField: "email", + expectedValue: "user+tag@domain.com", + expectedOk: true, + }, + { + name: "non-conflict error", + customErr: errorx.NewCustomError("USER-ERR", 1, nil, nil), + expectedField: "", + expectedValue: "", + expectedOk: false, + }, + { + name: "username conflict but no username in context", + customErr: errorx.NewCustomError("USER-ERR", 18, nil, map[string]interface{}{}), + expectedField: "", + expectedValue: "", + expectedOk: false, + }, + { + name: "email conflict but no email in context", + customErr: errorx.NewCustomError("USER-ERR", 19, nil, map[string]interface{}{}), + expectedField: "", + expectedValue: "", + expectedOk: false, + }, + { + name: "username conflict but username is not string", + customErr: errorx.NewCustomError("USER-ERR", 18, nil, map[string]interface{}{"username": 123}), + expectedField: "", + expectedValue: "", + expectedOk: false, + }, + { + name: "email conflict but email is not string", + customErr: errorx.NewCustomError("USER-ERR", 19, nil, map[string]interface{}{"email": 123}), + expectedField: "", + expectedValue: "", + expectedOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + field, value, ok := extractConflictInfo(tt.customErr) + + assert.Equal(t, tt.expectedField, field, "Field should match expected value") + assert.Equal(t, tt.expectedValue, value, "Value should match expected value") + assert.Equal(t, tt.expectedOk, ok, "Ok should match expected value") + }) + } +} diff --git a/user/router/api.go b/user/router/api.go index 1ca93e2b5..6b0d2a207 100644 --- a/user/router/api.go +++ b/user/router/api.go @@ -73,6 +73,9 @@ func NewRouter(config *config.Config) (*gin.Engine, error) { apiV1Group.GET("/organizations", orgHandler.Index) apiV1Group.GET("/organization/:namespace", orgHandler.Get) apiV1Group.GET("/organization/:namespace/members", memberCtrl.OrgMembers) + // public sms code (accessible to both logged-in and anonymous users) + userGroup.POST("/public/sms-code", userHandler.SendPublicSMSCode) + userGroup.POST("/public/sms-code/verify", userHandler.VerifyPublicSMSCode) } //internal only @@ -144,6 +147,8 @@ func NewRouter(config *config.Config) (*gin.Engine, error) { { userGroup.POST("/email-verification-code/:email", mustLogin(), userHandler.GenerateVerificationCodeAndSendEmail) + userGroup.POST("/sms-code", mustLogin(), userHandler.SendSMSCode) + userGroup.PUT("/phone", mustLogin(), userHandler.UpdatePhone) } {