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..40b48c8af 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 @@ -764,6 +764,65 @@ func (_c *MockUserStore_Index_Call) RunAndReturn(run func(context.Context) ([]da return _c } +// IndexWithCursor provides a mock function with given fields: ctx, req +func (_m *MockUserStore) IndexWithCursor(ctx context.Context, req types.UserIndexReq) (chan database.Wrapper, error) { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for IndexWithCursor") + } + + var r0 chan database.Wrapper + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, types.UserIndexReq) (chan database.Wrapper, error)); ok { + return rf(ctx, req) + } + if rf, ok := ret.Get(0).(func(context.Context, types.UserIndexReq) chan database.Wrapper); ok { + r0 = rf(ctx, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan database.Wrapper) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, types.UserIndexReq) error); ok { + r1 = rf(ctx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUserStore_IndexWithCursor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexWithCursor' +type MockUserStore_IndexWithCursor_Call struct { + *mock.Call +} + +// IndexWithCursor is a helper method to define mock.On call +// - ctx context.Context +// - req types.UserIndexReq +func (_e *MockUserStore_Expecter) IndexWithCursor(ctx interface{}, req interface{}) *MockUserStore_IndexWithCursor_Call { + return &MockUserStore_IndexWithCursor_Call{Call: _e.mock.On("IndexWithCursor", ctx, req)} +} + +func (_c *MockUserStore_IndexWithCursor_Call) Run(run func(ctx context.Context, req types.UserIndexReq)) *MockUserStore_IndexWithCursor_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.UserIndexReq)) + }) + return _c +} + +func (_c *MockUserStore_IndexWithCursor_Call) Return(ch chan database.Wrapper, err error) *MockUserStore_IndexWithCursor_Call { + _c.Call.Return(ch, err) + return _c +} + +func (_c *MockUserStore_IndexWithCursor_Call) RunAndReturn(run func(context.Context, types.UserIndexReq) (chan database.Wrapper, error)) *MockUserStore_IndexWithCursor_Call { + _c.Call.Return(run) + return _c +} + // IndexWithDeleted provides a mock function with given fields: ctx func (_m *MockUserStore) IndexWithDeleted(ctx context.Context) ([]database.User, error) { ret := _m.Called(ctx) @@ -822,9 +881,9 @@ func (_c *MockUserStore_IndexWithDeleted_Call) RunAndReturn(run func(context.Con return _c } -// IndexWithSearch provides a mock function with given fields: ctx, search, verifyStatus, labels, per, page -func (_m *MockUserStore) IndexWithSearch(ctx context.Context, search string, verifyStatus string, labels []string, per int, page int) ([]database.User, int, error) { - ret := _m.Called(ctx, search, verifyStatus, labels, per, page) +// IndexWithSearch provides a mock function with given fields: ctx, req +func (_m *MockUserStore) IndexWithSearch(ctx context.Context, req types.UserListReq) ([]database.User, int, error) { + ret := _m.Called(ctx, req) if len(ret) == 0 { panic("no return value specified for IndexWithSearch") @@ -833,25 +892,25 @@ func (_m *MockUserStore) IndexWithSearch(ctx context.Context, search string, ver var r0 []database.User var r1 int var r2 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, []string, int, int) ([]database.User, int, error)); ok { - return rf(ctx, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(0).(func(context.Context, types.UserListReq) ([]database.User, int, error)); ok { + return rf(ctx, req) } - if rf, ok := ret.Get(0).(func(context.Context, string, string, []string, int, int) []database.User); ok { - r0 = rf(ctx, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(0).(func(context.Context, types.UserListReq) []database.User); ok { + r0 = rf(ctx, req) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]database.User) } } - if rf, ok := ret.Get(1).(func(context.Context, string, string, []string, int, int) int); ok { - r1 = rf(ctx, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(1).(func(context.Context, types.UserListReq) int); ok { + r1 = rf(ctx, req) } else { r1 = ret.Get(1).(int) } - if rf, ok := ret.Get(2).(func(context.Context, string, string, []string, int, int) error); ok { - r2 = rf(ctx, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(2).(func(context.Context, types.UserListReq) error); ok { + r2 = rf(ctx, req) } else { r2 = ret.Error(2) } @@ -866,18 +925,14 @@ type MockUserStore_IndexWithSearch_Call struct { // IndexWithSearch is a helper method to define mock.On call // - ctx context.Context -// - search string -// - verifyStatus string -// - labels []string -// - per int -// - page int -func (_e *MockUserStore_Expecter) IndexWithSearch(ctx interface{}, search interface{}, verifyStatus interface{}, labels interface{}, per interface{}, page interface{}) *MockUserStore_IndexWithSearch_Call { - return &MockUserStore_IndexWithSearch_Call{Call: _e.mock.On("IndexWithSearch", ctx, search, verifyStatus, labels, per, page)} +// - req types.UserListReq +func (_e *MockUserStore_Expecter) IndexWithSearch(ctx interface{}, req interface{}) *MockUserStore_IndexWithSearch_Call { + return &MockUserStore_IndexWithSearch_Call{Call: _e.mock.On("IndexWithSearch", ctx, req)} } -func (_c *MockUserStore_IndexWithSearch_Call) Run(run func(ctx context.Context, search string, verifyStatus string, labels []string, per int, page int)) *MockUserStore_IndexWithSearch_Call { +func (_c *MockUserStore_IndexWithSearch_Call) Run(run func(ctx context.Context, req types.UserListReq)) *MockUserStore_IndexWithSearch_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]string), args[4].(int), args[5].(int)) + run(args[0].(context.Context), args[1].(types.UserListReq)) }) return _c } @@ -887,7 +942,7 @@ func (_c *MockUserStore_IndexWithSearch_Call) Return(_a0 []database.User, _a1 in return _c } -func (_c *MockUserStore_IndexWithSearch_Call) RunAndReturn(run func(context.Context, string, string, []string, int, int) ([]database.User, int, error)) *MockUserStore_IndexWithSearch_Call { +func (_c *MockUserStore_IndexWithSearch_Call) RunAndReturn(run func(context.Context, types.UserListReq) ([]database.User, int, error)) *MockUserStore_IndexWithSearch_Call { _c.Call.Return(run) return _c } @@ -1207,6 +1262,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..c57fa5898 100644 --- a/_mocks/opencsg.com/csghub-server/user/component/mock_UserComponent.go +++ b/_mocks/opencsg.com/csghub-server/user/component/mock_UserComponent.go @@ -833,9 +833,9 @@ func (_c *MockUserComponent_GetUserUUIDs_Call) RunAndReturn(run func(context.Con return _c } -// Index provides a mock function with given fields: ctx, visitorName, search, verifyStatus, labels, per, page -func (_m *MockUserComponent) Index(ctx context.Context, visitorName string, search string, verifyStatus string, labels []string, per int, page int) ([]*types.User, int, error) { - ret := _m.Called(ctx, visitorName, search, verifyStatus, labels, per, page) +// Index provides a mock function with given fields: ctx, req +func (_m *MockUserComponent) Index(ctx context.Context, req types.UserListReq) ([]*types.User, int, error) { + ret := _m.Called(ctx, req) if len(ret) == 0 { panic("no return value specified for Index") @@ -844,25 +844,25 @@ func (_m *MockUserComponent) Index(ctx context.Context, visitorName string, sear var r0 []*types.User var r1 int var r2 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string, []string, int, int) ([]*types.User, int, error)); ok { - return rf(ctx, visitorName, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(0).(func(context.Context, types.UserListReq) ([]*types.User, int, error)); ok { + return rf(ctx, req) } - if rf, ok := ret.Get(0).(func(context.Context, string, string, string, []string, int, int) []*types.User); ok { - r0 = rf(ctx, visitorName, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(0).(func(context.Context, types.UserListReq) []*types.User); ok { + r0 = rf(ctx, req) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*types.User) } } - if rf, ok := ret.Get(1).(func(context.Context, string, string, string, []string, int, int) int); ok { - r1 = rf(ctx, visitorName, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(1).(func(context.Context, types.UserListReq) int); ok { + r1 = rf(ctx, req) } else { r1 = ret.Get(1).(int) } - if rf, ok := ret.Get(2).(func(context.Context, string, string, string, []string, int, int) error); ok { - r2 = rf(ctx, visitorName, search, verifyStatus, labels, per, page) + if rf, ok := ret.Get(2).(func(context.Context, types.UserListReq) error); ok { + r2 = rf(ctx, req) } else { r2 = ret.Error(2) } @@ -877,19 +877,14 @@ type MockUserComponent_Index_Call struct { // Index is a helper method to define mock.On call // - ctx context.Context -// - visitorName string -// - search string -// - verifyStatus string -// - labels []string -// - per int -// - page int -func (_e *MockUserComponent_Expecter) Index(ctx interface{}, visitorName interface{}, search interface{}, verifyStatus interface{}, labels interface{}, per interface{}, page interface{}) *MockUserComponent_Index_Call { - return &MockUserComponent_Index_Call{Call: _e.mock.On("Index", ctx, visitorName, search, verifyStatus, labels, per, page)} +// - req types.UserListReq +func (_e *MockUserComponent_Expecter) Index(ctx interface{}, req interface{}) *MockUserComponent_Index_Call { + return &MockUserComponent_Index_Call{Call: _e.mock.On("Index", ctx, req)} } -func (_c *MockUserComponent_Index_Call) Run(run func(ctx context.Context, visitorName string, search string, verifyStatus string, labels []string, per int, page int)) *MockUserComponent_Index_Call { +func (_c *MockUserComponent_Index_Call) Run(run func(ctx context.Context, req types.UserListReq)) *MockUserComponent_Index_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].([]string), args[5].(int), args[6].(int)) + run(args[0].(context.Context), args[1].(types.UserListReq)) }) return _c } @@ -899,7 +894,7 @@ func (_c *MockUserComponent_Index_Call) Return(_a0 []*types.User, _a1 int, _a2 e return _c } -func (_c *MockUserComponent_Index_Call) RunAndReturn(run func(context.Context, string, string, string, []string, int, int) ([]*types.User, int, error)) *MockUserComponent_Index_Call { +func (_c *MockUserComponent_Index_Call) RunAndReturn(run func(context.Context, types.UserListReq) ([]*types.User, int, error)) *MockUserComponent_Index_Call { _c.Call.Return(run) return _c } @@ -952,6 +947,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) @@ -1068,6 +1182,65 @@ func (_c *MockUserComponent_SoftDelete_Call) RunAndReturn(run func(context.Conte return _c } +// StreamExportUsers provides a mock function with given fields: ctx, req +func (_m *MockUserComponent) StreamExportUsers(ctx context.Context, req types.UserIndexReq) (chan types.UserIndexResp, error) { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for StreamExportUsers") + } + + var r0 chan types.UserIndexResp + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, types.UserIndexReq) (chan types.UserIndexResp, error)); ok { + return rf(ctx, req) + } + if rf, ok := ret.Get(0).(func(context.Context, types.UserIndexReq) chan types.UserIndexResp); ok { + r0 = rf(ctx, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan types.UserIndexResp) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, types.UserIndexReq) error); ok { + r1 = rf(ctx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUserComponent_StreamExportUsers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamExportUsers' +type MockUserComponent_StreamExportUsers_Call struct { + *mock.Call +} + +// StreamExportUsers is a helper method to define mock.On call +// - ctx context.Context +// - req types.UserIndexReq +func (_e *MockUserComponent_Expecter) StreamExportUsers(ctx interface{}, req interface{}) *MockUserComponent_StreamExportUsers_Call { + return &MockUserComponent_StreamExportUsers_Call{Call: _e.mock.On("StreamExportUsers", ctx, req)} +} + +func (_c *MockUserComponent_StreamExportUsers_Call) Run(run func(ctx context.Context, req types.UserIndexReq)) *MockUserComponent_StreamExportUsers_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.UserIndexReq)) + }) + return _c +} + +func (_c *MockUserComponent_StreamExportUsers_Call) Return(data chan types.UserIndexResp, err error) *MockUserComponent_StreamExportUsers_Call { + _c.Call.Return(data, err) + return _c +} + +func (_c *MockUserComponent_StreamExportUsers_Call) RunAndReturn(run func(context.Context, types.UserIndexReq) (chan types.UserIndexResp, error)) *MockUserComponent_StreamExportUsers_Call { + _c.Call.Return(run) + return _c +} + // UpdateByUUID provides a mock function with given fields: ctx, req func (_m *MockUserComponent) UpdateByUUID(ctx context.Context, req *types.UpdateUserRequest) error { ret := _m.Called(ctx, req) @@ -1115,6 +1288,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 +1383,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/builder/store/database/user.go b/builder/store/database/user.go index 48508576c..aebfcbea2 100644 --- a/builder/store/database/user.go +++ b/builder/store/database/user.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "strings" "time" @@ -15,7 +16,7 @@ import ( // Define the UserStore interface type UserStore interface { Index(ctx context.Context) ([]User, error) - IndexWithSearch(ctx context.Context, search, verifyStatus string, labels []string, per, page int) ([]User, int, error) + IndexWithSearch(ctx context.Context, req types.UserListReq) ([]User, int, error) FindByUsername(ctx context.Context, username string) (User, error) FindByEmail(ctx context.Context, email string) (User, error) // Update write the user data back to db. odlUserName should not be empty if username changed @@ -37,6 +38,8 @@ 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 + IndexWithCursor(ctx context.Context, req types.UserIndexReq) (ch chan Wrapper, err error) } // Implement the UserStore interface in UserStoreImpl @@ -107,6 +110,11 @@ type User struct { times } +type Wrapper struct { + Users []User `json:"users"` + Err error `json:"error"` +} + func (u *User) Roles() []string { if len(u.RoleMask) == 0 { return []string{} @@ -129,18 +137,23 @@ func (s *UserStoreImpl) Index(ctx context.Context) (users []User, err error) { return users, errorx.HandleDBError(err, nil) } -func (s *UserStoreImpl) IndexWithSearch(ctx context.Context, search, verifyStatus string, labels []string, per, page int) (users []User, count int, err error) { - search = strings.ToLower(search) +func (s *UserStoreImpl) IndexWithSearch(ctx context.Context, req types.UserListReq) (users []User, count int, err error) { + search := strings.ToLower(req.Search) query := s.db.Operator.Core.NewSelect(). Model(&users) if search != "" { - query.Where("LOWER(username) like ? OR LOWER(email) like ?", fmt.Sprintf("%%%s%%", search), fmt.Sprintf("%%%s%%", search)) + if req.ExactMatch { + query.Where("LOWER(username) = ? OR LOWER(email) = ? OR phone = ?", strings.ToLower(search), strings.ToLower(search), search) + } else { + query.Where("LOWER(username) like ? OR LOWER(email) like ? OR phone like ?", fmt.Sprintf("%%%s%%", strings.ToLower(search)), fmt.Sprintf("%%%s%%", strings.ToLower(search)), fmt.Sprintf("%%%s%%", search)) + } + } - if verifyStatus != "" { - query.Where("verify_status = ?", verifyStatus) + if req.VerifyStatus != "" { + query.Where("verify_status = ?", req.VerifyStatus) } - if len(labels) != 0 { - labelsJSON, err := json.Marshal(labels) + if len(req.Labels) != 0 { + labelsJSON, err := json.Marshal(req.Labels) if err != nil { return nil, 0, fmt.Errorf("failed to marshal labels: %w, %w", err, errorx.ErrInternalServerError) } @@ -150,7 +163,13 @@ func (s *UserStoreImpl) IndexWithSearch(ctx context.Context, search, verifyStatu if err != nil { return users, count, errorx.HandleDBError(err, nil) } - query.Order("id asc").Limit(per).Offset((page - 1) * per) + if req.SortBy == "" { + req.SortBy = "id" + } + if req.SortOrder == "" { + req.SortOrder = "asc" + } + query.Order(fmt.Sprintf("%s %s", req.SortBy, req.SortOrder)).Limit(req.Per).Offset((req.Page - 1) * req.Per) err = query.Scan(ctx, &users) return users, count, errorx.HandleDBError(err, nil) } @@ -583,3 +602,91 @@ 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) +} + +func (s *UserStoreImpl) IndexWithCursor(ctx context.Context, req types.UserIndexReq) (chan Wrapper, error) { + batchSize := req.Per + if batchSize == 0 { + batchSize = 100 + } + + result := make(chan Wrapper) + go func() { + defer close(result) + err := s.db.RunInTx(ctx, func(ctx context.Context, tx Operator) error { + query := tx.Core.NewSelect().Model(&User{}) + if req.Search != "" { + query.Where("LOWER(username) like ? OR LOWER(email) like ? OR phone like ?", + fmt.Sprintf("%%%s%%", strings.ToLower(req.Search)), + fmt.Sprintf("%%%s%%", strings.ToLower(req.Search)), + fmt.Sprintf("%%%s%%", req.Search), + ) + } + + if req.VerifyStatus != "" { + query.Where("verify_status = ?", req.VerifyStatus) + } + + if len(req.Labels) != 0 { + labelsJSON, err := json.Marshal(req.Labels) + if err != nil { + return fmt.Errorf("failed to marshal labels: %w, %w", err, errorx.ErrInternalServerError) + } + query.Where("labels @> ?", string(labelsJSON)) + } + query.Order("id ASC") + + sqlStr := query.String() + if _, err := tx.Core.ExecContext(ctx, fmt.Sprintf("DECLARE user_cursor CURSOR WITH HOLD FOR %s", sqlStr)); err != nil { + slog.Error("failed to declare user cursor", "error", err) + return errorx.ErrInternalServerError + } + + for { + select { + case <-ctx.Done(): + return nil + default: + } + rows, err := tx.Core.QueryContext(ctx, fmt.Sprintf("FETCH FORWARD %d FROM user_cursor", batchSize)) + if err != nil { + return err + } + + var batch []User + if err := s.db.BunDB.ScanRows(ctx, rows, &batch); err != nil { + return err + } + + if len(batch) == 0 { + break + } + + result <- Wrapper{Users: batch} + } + + if _, err := tx.Core.ExecContext(ctx, `CLOSE user_cursor`); err != nil { + return err + } + + return nil + }) + + if err != nil { + slog.Error("failed to index users with cursor", "error", err) + result <- Wrapper{Err: err} + } + }() + + return result, nil +} diff --git a/builder/store/database/user_test.go b/builder/store/database/user_test.go index ce542221a..7799fc900 100644 --- a/builder/store/database/user_test.go +++ b/builder/store/database/user_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "opencsg.com/csghub-server/builder/store/database" "opencsg.com/csghub-server/common/tests" + "opencsg.com/csghub-server/common/types" ) func TestUserStore_Roles(t *testing.T) { @@ -156,7 +157,13 @@ func TestUserStore_IndexWithSearch(t *testing.T) { for _, c := range cases { t.Run(fmt.Sprintf("page %d, per %d", c.page, c.per), func(t *testing.T) { - users, count, err := userStore.IndexWithSearch(ctx, "foo", "", c.labels, c.per, c.page) + req := types.UserListReq{ + Search: "foo", + Labels: c.labels, + Per: c.per, + Page: c.page, + } + users, count, err := userStore.IndexWithSearch(ctx, req) require.Nil(t, err) require.Equal(t, c.total, count) @@ -170,6 +177,55 @@ func TestUserStore_IndexWithSearch(t *testing.T) { } +func TestUserStore_IndexWithSearchExactMatch(t *testing.T) { + db := tests.InitTestDB() + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + userStore := database.NewUserStoreWithDB(db) + err := userStore.Create(ctx, &database.User{ + GitID: 3321, + Username: "u-foo", + UUID: "1", + }, &database.Namespace{Path: "1"}) + require.Nil(t, err) + + err = userStore.Create(ctx, &database.User{ + GitID: 3322, + Username: "u-bar", + Email: "efoo@z.com", + UUID: "2", + }, &database.Namespace{Path: "2"}) + require.Nil(t, err) + + err = userStore.Create(ctx, &database.User{ + GitID: 3323, + Username: "u-barz", + Email: "ebar@z.com", + UUID: "3", + }, &database.Namespace{Path: "3"}) + require.Nil(t, err) + + _, count, err := userStore.IndexWithSearch(ctx, types.UserListReq{ + VisitorName: "u-foo", + Per: 10, + Page: 1, + ExactMatch: true, + }) + require.Nil(t, err) + require.Equal(t, 3, count) + + _, count, err = userStore.IndexWithSearch(ctx, types.UserListReq{ + Search: "u-foo", + Per: 10, + Page: 1, + ExactMatch: true, + }) + require.Nil(t, err) + require.Equal(t, 1, count) +} + func TestUserStore_CreateUser(t *testing.T) { db := tests.InitTestDB() defer db.Close() @@ -440,3 +496,83 @@ 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) +} + +func TestUserStore_IndexWithCursor1(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, + ID: 1, + UUID: "1", + Username: "u-foo", + }, &database.Namespace{Path: "u-foo"}) + require.NoError(t, err) + + err = us.Create(ctx, &database.User{ + GitID: 10002, + ID: 2, + UUID: "2", + Username: "u-foo-2", + }, &database.Namespace{Path: "u-foo-2"}) + require.NoError(t, err) + + err = us.Create(ctx, &database.User{ + GitID: 10003, + ID: 3, + UUID: "3", + Username: "u-foo-3", + }, &database.Namespace{Path: "u-foo-3"}) + require.NoError(t, err) + + req := types.UserIndexReq{ + Search: "u-foo", + Per: 1, + } + + ch, err := us.IndexWithCursor(ctx, req) + require.NoError(t, err) + + total := 0 + for wrapper := range ch { + require.NoError(t, wrapper.Err) + total += len(wrapper.Users) + } + + require.Equal(t, 3, total) +} diff --git a/common/types/user.go b/common/types/user.go index e599edb7d..9501c7f1d 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"` +} + var _ SensitiveRequestV2 = (*UpdateUserRequest)(nil) func (u *UpdateUserRequest) GetSensitiveFields() []SensitiveField { @@ -300,3 +326,28 @@ 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"` + ExactMatch bool `json:"exact_match"` +} + +type UserIndexResp struct { + Users []*User `json:"users"` + Error error `json:"error"` +} + +type UserListReq struct { + VisitorName string `json:"visitor_name"` + Search string `json:"search"` + VerifyStatus string `json:"verify_status"` + Labels []string `json:"labels"` + Per int `json:"per"` + Page int `json:"page"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` + ExactMatch bool `json:"exact_match"` +} diff --git a/user/component/user.go b/user/component/user.go index 52c22e13d..64ab17d99 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 @@ -38,9 +49,10 @@ type userComponentImpl struct { audit database.AuditLogStore pdStore database.PendingDeletionStore - gs gitserver.GitServer - jwtc JwtComponent - tokenc AccessTokenComponent + gs gitserver.GitServer + jwtc JwtComponent + tokenc AccessTokenComponent + invitationc InvitationComponent // casc *casdoorsdk.Client // casConfig *casdoorsdk.AuthConfig @@ -79,7 +91,7 @@ type UserComponent interface { CheckIfUserHasOrgs(ctx context.Context, userName string) (bool, error) CheckIfUserHasRunningOrBuildingDeployments(ctx context.Context, userName string) (bool, error) CheckIfUserHasBills(ctx context.Context, userName string) (bool, error) - Index(ctx context.Context, visitorName, search, verifyStatus string, labels []string, per, page int) ([]*types.User, int, error) + Index(ctx context.Context, req types.UserListReq) ([]*types.User, int, error) Signin(ctx context.Context, code, state string) (*types.JWTClaims, string, error) FixUserData(ctx context.Context, userName string) error UpdateUserLabels(ctx context.Context, req *types.UserLabelsRequest) error @@ -92,6 +104,11 @@ 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 + StreamExportUsers(ctx context.Context, req types.UserIndexReq) (data chan types.UserIndexResp, err error) } func NewUserComponent(config *config.Config) (UserComponent, error) { @@ -140,6 +157,11 @@ func NewUserComponent(config *config.Config) (UserComponent, error) { c.ts = database.NewTagStore() c.uts = database.NewUserTagStore() + + c.invitationc, err = NewInvitationComponent(c.config) + if err != nil { + return nil, fmt.Errorf("failed to create invitation component, error: %w", err) + } return c, nil } @@ -149,6 +171,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 +216,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 +247,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, @@ -246,6 +297,8 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU } else { opUser = *user } + + shouldSyncToIAM := false if req.Roles != nil { if can, reason := c.canChangeRole(*user, opUser); !can { return errors.New(reason) @@ -256,6 +309,7 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU if can, reason := c.canChangeUserName(ctx, *user, opUser, *req.NewUserName); !can { return errors.New(reason) } + shouldSyncToIAM = true } if req.Email != nil && user.Email != *req.Email { @@ -270,22 +324,15 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU if err != nil { return err } - + shouldSyncToIAM = true } - 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) - } + if err := c.ResetUserTags(ctx, uuid, req.TagIDs); err != nil { + return fmt.Errorf("failed to reset user tags,error:%w", err) } - // update user in casdoor first, then update user in db - if c.IsSSOUser(user.RegProvider) { + // update user in IAM first, then update user in db + if shouldSyncToIAM && c.IsSSOUser(user.RegProvider) { var params = rpc.SSOUpdateUserInfo{ UUID: uuid, } @@ -293,9 +340,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 } @@ -305,25 +349,16 @@ func (c *userComponentImpl) UpdateByUUID(ctx context.Context, req *types.UpdateU return fmt.Errorf("failed to update user in sso, uuid:'%s',error:%w", user.UUID, err) } } - /* dont update git user email anymore, as gitea has been depricated */ changedUser := c.setChangedProps(user, req) if err := c.userStore.Update(ctx, changedUser, user.Username); err != nil { - // rollback casdoor user change - // get id by user name before changed - // id := c.casc.GetId(oldCasdoorUser.Name) - // id = url.QueryEscape(id) // wechat user's name may contain special characters - // if _, err := c.casc.UpdateUserById(id, oldCasdoorUser); err != nil { - // slog.Error("failed to rollback casdoor user change", slog.String("uuid", user.UUID), slog.Any("error", err)) - // } - - if c.IsSSOUser(user.RegProvider) { + // rollback casdoor user change only if SSO update was performed + if shouldSyncToIAM && c.IsSSOUser(user.RegProvider) { params := rpc.SSOUpdateUserInfo{ UUID: uuid, 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 } @@ -559,7 +557,7 @@ func (c *userComponentImpl) Delete(ctx context.Context, operator, username strin continue } err = c.pdStore.Create(ctx, &database.PendingDeletion{ - TableName: "repositories", + TableName: database.PendingDeletionTableNameRepository, Value: repo.GitalyPath(), }) if err != nil { @@ -736,14 +734,6 @@ func (c *userComponentImpl) CheckIfUserHasBills(ctx context.Context, userName st return true, nil } - asqs, err := c.asqs.ListAllByUserID(ctx, user.ID) - if err != nil { - return false, fmt.Errorf("failed to list all account sync quotas for user %s in db, error: %w", userName, err) - } - if len(asqs) > 0 { - return true, nil - } - aus, err := c.aus.ListAllByUserUUID(ctx, user.UUID) if err != nil { return false, fmt.Errorf("failed to list all account users for user %s in db, error: %w", userName, err) @@ -818,19 +808,19 @@ func (c *userComponentImpl) buildUserInfo(ctx context.Context, dbuser *database. return &u, nil } -func (c *userComponentImpl) Index(ctx context.Context, visitorName, search, verifyStatus string, labels []string, per, page int) ([]*types.User, int, error) { +func (c *userComponentImpl) Index(ctx context.Context, req types.UserListReq) ([]*types.User, int, error) { var ( respUsers []*types.User onlyBasicInfo bool ) - canAdmin, err := c.CanAdmin(ctx, visitorName) + canAdmin, err := c.CanAdmin(ctx, req.VisitorName) if err != nil { - return nil, 0, fmt.Errorf("failed to check visitor user permission, visitor: %s, error: %w", visitorName, err) + return nil, 0, fmt.Errorf("failed to check visitor user permission, visitor: %s, error: %w", req.VisitorName, err) } if !canAdmin { onlyBasicInfo = true } - dbusers, count, err := c.userStore.IndexWithSearch(ctx, search, verifyStatus, labels, per, page) + dbusers, count, err := c.userStore.IndexWithSearch(ctx, req) if err != nil { newError := fmt.Errorf("failed to find user by name in db,error:%w", err) return nil, count, newError @@ -869,6 +859,7 @@ func (c *userComponentImpl) Index(ctx context.Context, visitorName, search, veri user.VerifyStatus = string(dbuser.VerifyStatus) user.Labels = dbuser.Labels user.LastLoginAt = dbuser.LastLoginAt + user.CreatedAt = dbuser.CreatedAt } respUsers = append(respUsers, user) @@ -918,6 +909,20 @@ func (c *userComponentImpl) Signin(ctx context.Context, code, state string) (*ty slog.Error("failed to create git user access token", "error", err, "username", dbu.Username) } }(dbu.Username) + + if dbu.Phone != "" { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*60) + defer cancel() + if err := c.invitationc.AwardCreditToInvitee(ctx, types.AwardCreditToInviteeReq{ + InviteeUUID: dbu.UUID, + InviteeName: dbu.Username, + RegisterAt: dbu.CreatedAt, + }); err != nil { + slog.Error("failed to award credit to invitee", "error", err, "invitee_uuid", dbu.UUID) + } + }() + } } else { // get user from db for username, as casdoor may have different username dbu, err = c.userStore.FindByUUID(ctx, cu.UUID) @@ -1142,6 +1147,13 @@ func (c *userComponentImpl) sendVerificationCodeEmail(ctx context.Context, uid, } func (e *userComponentImpl) VerifyVerificationCode(ctx context.Context, uid, email string, verificationCode string) error { + exists, err := e.cache.Exists(ctx, fmt.Sprintf("email_verification_code:%s:%s", uid, email)) + if err != nil { + return err + } + if exists == 0 { + return errors.New("verification code expired or not available") + } code, err := e.cache.Get(ctx, fmt.Sprintf("email_verification_code:%s:%s", uid, email)) if err != nil { return err @@ -1182,3 +1194,329 @@ 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 + } + + if *req.VerificationCode == "" { + return errorx.ErrVerificationCodeRequired + } + + if len(*req.VerificationCode) != 6 { + return errorx.ErrVerificationCodeLength + } + + 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 + } + + if oldUser.Phone == "" { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*60) + defer cancel() + if err := c.invitationc.AwardCreditToInvitee(ctx, types.AwardCreditToInviteeReq{ + InviteeUUID: oldUser.UUID, + InviteeName: oldUser.Username, + RegisterAt: oldUser.CreatedAt, + }); err != nil { + slog.Error("failed to award credit to invitee", "error", err, "invitee_name", oldUser.Username, "invitee_uuid", oldUser.UUID) + } + }() + } + + 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 +} + +func (c *userComponentImpl) StreamExportUsers(ctx context.Context, req types.UserIndexReq) (data chan types.UserIndexResp, err error) { + data = make(chan types.UserIndexResp) + + ch, err := c.userStore.IndexWithCursor(ctx, req) + if err != nil { + slog.Error("failed to query users by cursor", + slog.Any("req", req), + slog.Any("error", err), + ) + return data, errorx.ErrInternalServerError + } + + go func() { + defer close(data) + for wrapper := range ch { + if wrapper.Err != nil { + slog.Error("failed to query users by cursor", + slog.Any("req", req), + slog.Any("error", wrapper.Err), + ) + data <- types.UserIndexResp{Error: wrapper.Err} + return + } + for _, originalUser := range wrapper.Users { + var tags []types.RepoTag + for _, utag := range originalUser.Tags { + tags = append(tags, types.RepoTag{ + ID: utag.ID, + Name: utag.Tag.Name, + Category: utag.Tag.Category, + Group: utag.Tag.Group, + BuiltIn: utag.Tag.BuiltIn, + Scope: utag.Tag.Scope, + I18nKey: utag.Tag.I18nKey, + }) + } + exportUser := &types.User{ + Username: originalUser.Username, + Nickname: originalUser.NickName, + Avatar: originalUser.Avatar, + Tags: tags, + Email: originalUser.Email, + UUID: originalUser.UUID, + Bio: originalUser.Bio, + Homepage: originalUser.Homepage, + Phone: originalUser.Phone, + PhoneArea: originalUser.PhoneArea, + Roles: originalUser.Roles(), + VerifyStatus: string(originalUser.VerifyStatus), + Labels: originalUser.Labels, + LastLoginAt: originalUser.LastLoginAt, + CreatedAt: originalUser.CreatedAt, + } + + select { + case <-ctx.Done(): + slog.Info("stream export canceled while writing data", slog.String("reason", ctx.Err().Error())) + data <- types.UserIndexResp{Error: ctx.Err()} + return + case data <- types.UserIndexResp{Users: []*types.User{exportUser}}: + } + } + } + + }() + + slog.Info("stream export completed successfully") + return data, nil +} diff --git a/user/handler/user.go b/user/handler/user.go index 23bcd8a11..d12355082 100644 --- a/user/handler/user.go +++ b/user/handler/user.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -13,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "go.temporal.io/sdk/client" "opencsg.com/csghub-server/api/httpbase" + "opencsg.com/csghub-server/builder/temporal" "opencsg.com/csghub-server/common/config" "opencsg.com/csghub-server/common/errorx" "opencsg.com/csghub-server/common/types" @@ -128,7 +130,7 @@ func (h *UserHandler) Update(ctx *gin.Context) { currentUser := httpbase.GetCurrentUser(ctx) var req *types.UpdateUserRequest if err := ctx.ShouldBindJSON(&req); err != nil { - slog.Error("Bad request format", "error", err) + slog.ErrorContext(ctx.Request.Context(), "Bad request format", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, err) return } @@ -136,7 +138,7 @@ func (h *UserHandler) Update(ctx *gin.Context) { var err error _, err = h.sc.CheckRequestV2(ctx, req) if err != nil { - slog.Error("failed to check sensitive request", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "failed to check sensitive request", slog.Any("error", err)) httpbase.ServerError(ctx, fmt.Errorf("sensitive check failed: %w", err)) return } @@ -146,12 +148,12 @@ func (h *UserHandler) Update(ctx *gin.Context) { req.OpUser = currentUser err = h.c.UpdateByUUID(ctx.Request.Context(), req) if err != nil { - slog.Error("Failed to update user by uuid", slog.Any("error", err), slog.String("uuid", *req.UUID), slog.String("current_user", currentUser), slog.Any("req", *req)) + slog.ErrorContext(ctx.Request.Context(), "Failed to update user by uuid", slog.Any("error", err), slog.String("uuid", *req.UUID), slog.String("current_user", currentUser), slog.Any("req", *req)) httpbase.ServerError(ctx, err) return } - slog.Info("Update user by uuid succeed", slog.String("uuid", *req.UUID), slog.String("current_user", currentUser)) + slog.InfoContext(ctx.Request.Context(), "Update user by uuid succeed", slog.String("uuid", *req.UUID), slog.String("current_user", currentUser)) httpbase.OK(ctx, nil) } @@ -174,48 +176,56 @@ func (h *UserHandler) Delete(ctx *gin.Context) { // Check if operator can delete user isServerErr, err := h.c.CheckOperatorAndUser(ctx, operator, userName) if err != nil && isServerErr { + slog.ErrorContext(ctx.Request.Context(), "Check operator and user failed", slog.String("operator", operator), slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("user cannot be deleted: %w", err)) return } if err != nil && !isServerErr { - httpbase.BadRequestWithExt(ctx, err) + slog.ErrorContext(ctx.Request.Context(), "Bad Request", slog.String("operator", operator), slog.String("user", userName), slog.Any("err", err)) + httpbase.BadRequestWithExt(ctx, errorx.ErrAdminUserCannotBeDeleted) return } // Check if user has organizations hasOrgs, err := h.c.CheckIfUserHasOrgs(ctx, userName) if err != nil { + slog.ErrorContext(ctx.Request.Context(), "Check if user has organizations failed", slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to check if user has organzitions, error: %w", err)) return } if hasOrgs { - httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own organizations cannot be deleted"), nil)) + slog.ErrorContext(ctx.Request.Context(), "User has organizations", slog.String("user", userName)) + httpbase.BadRequestWithExt(ctx, errorx.ErrUserHasOrganizations) return } // Check if user has running or building deployments hasDeployments, err := h.c.CheckIfUserHasRunningOrBuildingDeployments(ctx, userName) if err != nil { + slog.ErrorContext(ctx.Request.Context(), "Check if user has deployments failed", slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to check if user has deployments, error: %w", err)) return } if hasDeployments { - httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own deployments cannot be deleted"), nil)) + slog.ErrorContext(ctx.Request.Context(), "User has deployments", slog.String("user", userName)) + httpbase.BadRequestWithExt(ctx, errorx.ErrUserHasDeployments) return } // Check if user has bills, Saas only hasBills, err := h.c.CheckIfUserHasBills(ctx, userName) if err != nil { + slog.ErrorContext(ctx.Request.Context(), "Check if user has bills failed", slog.String("user", userName), slog.Any("err", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to check if user has bills, error: %w", err)) return } if hasBills { - httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own bills cannot be deleted"), nil)) + slog.ErrorContext(ctx.Request.Context(), "User has bills", slog.String("user", userName)) + httpbase.BadRequestWithExt(ctx, errorx.ErrUserHasBills) return } //start workflow to delete user - workflowClient := workflow.GetWorkflowClient() + workflowClient := temporal.GetClient() workflowOptions := client.StartWorkflowOptions{ TaskQueue: workflow.WorkflowUserDeletionQueueName, } @@ -228,11 +238,12 @@ func (h *UserHandler) Delete(ctx *gin.Context) { h.config, ) if err != nil { + slog.ErrorContext(ctx.Request.Context(), "Failed to start user deletion workflow", slog.Any("error", err)) httpbase.ServerError(ctx, fmt.Errorf("failed to start user deletion workflow, error: %w", err)) return } - slog.Info("start user deletion workflow", slog.String("workflow_id", we.GetID()), slog.String("userName", userName), slog.String("operator", operator)) + slog.InfoContext(ctx.Request.Context(), "start user deletion workflow", slog.String("workflow_id", we.GetID()), slog.String("userName", userName), slog.String("operator", operator)) httpbase.OK(ctx, nil) } @@ -262,7 +273,7 @@ func (h *UserHandler) Get(ctx *gin.Context) { user, err = h.c.Get(ctx, userNameOrUUID, visitorName, useUUID) } if err != nil { - slog.Error("Failed to get user", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get user", slog.Any("error", err)) // TODO: in user server component need to use errorx if errors.Is(err, sql.ErrNoRows) { httpbase.NotFoundError(ctx, err) @@ -272,7 +283,7 @@ func (h *UserHandler) Get(ctx *gin.Context) { return } - slog.Info("Get user succeed", slog.String("userName", userNameOrUUID)) + slog.InfoContext(ctx.Request.Context(), "Get user succeed", slog.String("userName", userNameOrUUID)) httpbase.OK(ctx, user) } @@ -285,25 +296,54 @@ func (h *UserHandler) Get(ctx *gin.Context) { // @Param verify_status query string true "verify_status" // @Param search query string true "search" // @Param labels query []string false "labels, such as ['vip', 'basic']" +// @Param exact_match query bool false "exact_match, default is false" // @Success 200 {object} types.Response{data=[]types.User,total=int} "OK" +// @Param exact_match query bool false "exact_match, default is false" // @Failure 400 {object} types.APIBadRequest "Bad request" // @Failure 500 {object} types.APIInternalServerError "Internal server error" // @Router /users [get] func (h *UserHandler) Index(ctx *gin.Context) { visitorName := httpbase.GetCurrentUser(ctx) search := ctx.Query("search") + sortBy := ctx.Query("sort_by") + sortOrder := ctx.Query("sort_order") + per, page, err := common.GetPerAndPageFromContext(ctx) if err != nil { - slog.Error("Failed to get per and page", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get per and page", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, err) return } _labels := ctx.QueryArray("labels") labels := types.ParseLabels(_labels) verifyStatus := ctx.Query("verify_status") - users, count, err := h.c.Index(ctx, visitorName, search, verifyStatus, labels, per, page) + exactMatchQuery := ctx.Query("exact_match") + exactMatch := false + if exactMatchQuery != "" { + var err error + exactMatch, err = strconv.ParseBool(exactMatchQuery) + if err != nil { + slog.ErrorContext(ctx.Request.Context(), "Failed to parse exact_match", slog.Any("error", err), slog.String("exact_match", exactMatchQuery)) + ext := errorx.Ctx().Set("query", "exact_match") + httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(err, ext)) + return + } + + } + req := types.UserListReq{ + VisitorName: visitorName, + Search: search, + VerifyStatus: verifyStatus, + Labels: labels, + Per: per, + Page: page, + SortBy: sortBy, + SortOrder: sortOrder, + ExactMatch: exactMatch, + } + users, count, err := h.c.Index(ctx, req) if err != nil { - slog.Error("Failed to get user", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get user", slog.Any("error", err)) httpbase.ServerError(ctx, err) return } @@ -312,7 +352,7 @@ func (h *UserHandler) Index(ctx *gin.Context) { "total": count, } - slog.Info("Get users succeed") + slog.InfoContext(ctx.Request.Context(), "Get users succeed") httpbase.OK(ctx, respData) } @@ -324,10 +364,17 @@ 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)) - ctx.Redirect(http.StatusMovedPermanently, errorRedirectURL) + redirectWithoutBody(ctx, http.StatusFound, errorRedirectURL) return } @@ -344,7 +391,7 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { errorMsg := url.QueryEscape(errMsg) errorRedirectURL := fmt.Sprintf("%s?error_code=500&error_message=%s", h.signinFailureRedirectURL, errorMsg) slog.Info("redirecting to error page", slog.String("url", errorRedirectURL)) - ctx.Redirect(http.StatusMovedPermanently, errorRedirectURL) + redirectWithoutBody(ctx, http.StatusFound, errorRedirectURL) return } codeSoulerEndpoint := h.codeSoulerVScodeRedirectURL @@ -366,7 +413,7 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { errorRedirectURL := fmt.Sprintf("%s?error_code=500&error_message=%s", h.signinFailureRedirectURL, errorMsg) slog.Info("redirecting to error page", slog.String("url", errorRedirectURL)) - ctx.Redirect(http.StatusMovedPermanently, errorRedirectURL) + redirectWithoutBody(ctx, http.StatusFound, errorRedirectURL) return } // set jwt token in jwt query @@ -376,8 +423,8 @@ func (h *UserHandler) Casdoor(ctx *gin.Context) { targetUrl = flowURL.String() } - slog.Info("generate login redirect url", slog.Any("targetUrl", targetUrl)) - ctx.Redirect(http.StatusMovedPermanently, targetUrl) + slog.InfoContext(ctx.Request.Context(), "generate login redirect url", slog.Any("targetUrl", targetUrl)) + redirectWithoutBody(ctx, http.StatusFound, targetUrl) } func (h *UserHandler) getStarshipApiKey(ctx *gin.Context, userName, tokenName string) (string, error) { @@ -404,19 +451,19 @@ func (h *UserHandler) CreateVerify(ctx *gin.Context) { currentUser := httpbase.GetCurrentUser(ctx) var req types.UserVerifyReq if err := ctx.ShouldBindJSON(&req); err != nil { - slog.Error("Bad request format", "error", err) + slog.ErrorContext(ctx.Request.Context(), "Bad request format", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, err) return } req.Username = currentUser orgVerify, err := h.uv.Create(ctx, &req) if err != nil { - slog.Error("Failed to create organization Verify", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to create organization Verify", slog.Any("error", err)) httpbase.ServerError(ctx, err) return } - slog.Info("Create organization Verify succeed", slog.String("real name", orgVerify.RealName)) + slog.InfoContext(ctx.Request.Context(), "Create organization Verify succeed", slog.String("real name", orgVerify.RealName)) httpbase.OK(ctx, orgVerify) } @@ -436,35 +483,35 @@ func (h *UserHandler) CreateVerify(ctx *gin.Context) { func (h *UserHandler) UpdateVerify(ctx *gin.Context) { vID, err := strconv.ParseInt(ctx.Param("id"), 10, 64) if err != nil { - slog.Error("Bad request format", "error", err) + slog.ErrorContext(ctx.Request.Context(), "Bad request format", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, err) return } var req types.UserVerifyStatusReq if err := ctx.ShouldBindJSON(&req); err != nil { - slog.Error("Bad request format", "error", err) + slog.ErrorContext(ctx.Request.Context(), "Bad request format", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, err) return } if req.Status != types.VerifyStatusRejected && req.Status != types.VerifyStatusApproved { - slog.Error("Bad request format", slog.String("err", "Not allowed status")) + slog.ErrorContext(ctx.Request.Context(), "Bad request format", slog.String("err", "Not allowed status")) httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("not allowed status"), nil)) } if req.Status == types.VerifyStatusRejected && req.Reason == "" { - slog.Error("Bad request format", slog.String("err", "rejected need reason")) + slog.ErrorContext(ctx.Request.Context(), "Bad request format", slog.String("err", "rejected need reason")) httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("rejected need reason"), nil)) } orgVerify, err := h.uv.Update(ctx, vID, req.Status, req.Reason) if err != nil { - slog.Error("Failed to update organization Verify", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to update organization Verify", slog.Any("error", err)) httpbase.ServerError(ctx, err) return } - slog.Info("update organization Verify succeed", slog.String("real name", orgVerify.RealName)) + slog.InfoContext(ctx.Request.Context(), "update organization Verify succeed", slog.String("real name", orgVerify.RealName)) httpbase.OK(ctx, orgVerify) } @@ -484,7 +531,7 @@ func (h *UserHandler) GetVerify(ctx *gin.Context) { id := ctx.Param("id") orgVerify, err := h.uv.Get(ctx, id) if err != nil { - slog.Error("Failed to get organization Verify", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get organization Verify", slog.Any("error", err)) httpbase.ServerError(ctx, err) return } @@ -507,13 +554,13 @@ func (h *UserHandler) UpdateUserLabels(ctx *gin.Context) { currentUser := httpbase.GetCurrentUser(ctx) var req types.UserLabelsRequest if err := ctx.ShouldBindJSON(&req); err != nil { - slog.Error("Invalid user labels update request", "error", err) + slog.ErrorContext(ctx.Request.Context(), "Invalid user labels update request", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("invalid request body"), nil)) return } for _, label := range req.Labels { if !types.ValidLabels[label] { - slog.Error("Invalid user labels update request", slog.String("label", label)) + slog.ErrorContext(ctx.Request.Context(), "Invalid user labels update request", slog.String("label", label)) httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("invalid request label"), nil)) return } @@ -522,12 +569,12 @@ func (h *UserHandler) UpdateUserLabels(ctx *gin.Context) { err := h.c.UpdateUserLabels(ctx.Request.Context(), &req) if err != nil { - slog.Error("Failed to update user labels by uuid", slog.Any("error", err), slog.String("uid", req.UUID), slog.String("current_user", currentUser), slog.Any("req", req)) + slog.ErrorContext(ctx.Request.Context(), "Failed to update user labels by uuid", slog.Any("error", err), slog.String("uid", req.UUID), slog.String("current_user", currentUser), slog.Any("req", req)) httpbase.ServerError(ctx, err) return } - slog.Info("Update labels by uuid succeed", slog.String("uid", req.UUID), slog.String("current_user", currentUser)) + slog.InfoContext(ctx.Request.Context(), "Update labels by uuid succeed", slog.String("uid", req.UUID), slog.String("current_user", currentUser)) httpbase.OK(ctx, nil) } @@ -547,7 +594,7 @@ func (h *UserHandler) UpdateUserLabels(ctx *gin.Context) { func (h *UserHandler) GetEmails(ctx *gin.Context) { per, page, err := common.GetPerAndPageFromContext(ctx) if err != nil { - slog.Error("Failed to get per and page", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get per and page", slog.Any("error", err)) httpbase.BadRequestWithExt(ctx, err) return } @@ -559,7 +606,7 @@ func (h *UserHandler) GetEmails(ctx *gin.Context) { httpbase.ForbiddenError(ctx, err) return } - slog.Error("Failed to get all user emails", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get all user emails", slog.Any("error", err)) httpbase.ServerError(ctx, err) return } @@ -589,7 +636,7 @@ func (h *UserHandler) GetEmailsInternal(ctx *gin.Context) { emails, count, err := h.c.GetEmailsInternal(ctx, per, page) if err != nil { - slog.Error("Failed to get all user emails", slog.Any("error", err)) + slog.ErrorContext(ctx.Request.Context(), "Failed to get all user emails", slog.Any("error", err)) httpbase.ServerError(ctx, err) return } @@ -613,7 +660,7 @@ func (h *UserHandler) FindByUUIDs(ctx *gin.Context) { uuids := ctx.QueryArray("uuids") users, err := h.c.FindByUUIDs(ctx, uuids) if err != nil { - slog.Error("Failed to find user by uuids", slog.Any("error", err), slog.Any("uuids", uuids)) + slog.ErrorContext(ctx.Request.Context(), "Failed to find user by uuids", slog.Any("error", err), slog.Any("uuids", uuids)) httpbase.ServerError(ctx, err) return } @@ -679,12 +726,11 @@ func (h *UserHandler) CloseAccount(ctx *gin.Context) { } if hasBills { httpbase.BadRequestWithExt(ctx, errorx.ReqParamInvalid(errors.New("users who own bills cannot be deleted"), nil)) - return } //start workflow to soft delete user - workflowClient := workflow.GetWorkflowClient() + workflowClient := temporal.GetClient() workflowOptions := client.StartWorkflowOptions{ TaskQueue: workflow.WorkflowUserDeletionQueueName, } @@ -702,7 +748,7 @@ func (h *UserHandler) CloseAccount(ctx *gin.Context) { return } - slog.Info("start user soft deletion workflow", slog.String("workflow_id", we.GetID()), slog.String("userName", userName), slog.String("operator", operator)) + slog.InfoContext(ctx.Request.Context(), "start user soft deletion workflow", slog.String("workflow_id", we.GetID()), slog.String("userName", userName), slog.String("operator", operator)) httpbase.OK(ctx, nil) } @@ -760,7 +806,7 @@ func (e *UserHandler) GenerateVerificationCodeAndSendEmail(ctx *gin.Context) { httpbase.ForbiddenError(ctx, err) return } - slog.Error("GenerateVerificationCodeAndSendEmail failed", slog.Any("err", err)) + slog.ErrorContext(ctx.Request.Context(), "GenerateVerificationCodeAndSendEmail failed", slog.Any("err", err)) httpbase.ServerError(ctx, err) return } @@ -784,16 +830,242 @@ func (e *UserHandler) ResetUserTags(ctx *gin.Context) { uid := httpbase.GetCurrentUserUUID(ctx) var req []int64 if err := ctx.ShouldBindJSON(&req); err != nil { - slog.Error("ResetUserTags failed", slog.Any("err", err)) + slog.ErrorContext(ctx.Request.Context(), "ResetUserTags failed", slog.Any("err", err)) httpbase.ServerError(ctx, err) return } if err := e.c.ResetUserTags(ctx, uid, req); err != nil { - slog.Error("ResetUserTags failed", slog.Any("err", err)) + slog.ErrorContext(ctx.Request.Context(), "ResetUserTags failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + + 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.ErrorContext(ctx.Request.Context(), "SendSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + resp, err := e.c.SendSMSCode(ctx, currentUserUUID, req) + if err != nil { + slog.ErrorContext(ctx.Request.Context(), "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.ErrorContext(ctx.Request.Context(), "SendPublicSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + resp, err := e.c.SendPublicSMSCode(ctx, req) + if err != nil { + slog.ErrorContext(ctx.Request.Context(), "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.ErrorContext(ctx.Request.Context(), "VerifyPublicSMSCodeRequest failed", slog.Any("err", err)) + httpbase.ServerError(ctx, err) + return + } + err := e.c.VerifyPublicSMSCode(ctx, req) + if err != nil { + slog.ErrorContext(ctx.Request.Context(), "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.ErrorContext(ctx.Request.Context(), "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.ErrorContext(ctx.Request.Context(), "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.InfoContext(ctx.Request.Context(), "redirecting to error page with conflict error", slog.String("url", u.String())) + redirectWithoutBody(ctx, http.StatusFound, 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 +} + +// ExportUserInfo godoc +// @Security ApiKey +// @Summary Export users info. Only Admin +// @Tags User +// @Accept json +// @Produce json +// @Param verify_status query string false "Verify status (e.g. 'none', 'pending', 'approved', 'rejected')" +// @Param search query string false "Search keyword (match username/email/phone)" +// @Param labels query []string false "Labels (e.g. vip,basic) - multiple values supported" +// @Param cursor query int64 false "Cursor for pagination" +// @Success 200 "OK - SSE stream: events are 'users' (single user JSON), 'error' (error message), 'end' (completion message)" +// @Failure 403 {object} types.APIForbidden "Forbidden - not admin" +// @Router /users/stream-export [get] +func (h *UserHandler) ExportUserInfo(ctx *gin.Context) { + search := ctx.Query("search") + _labels := ctx.QueryArray("labels") + labels := types.ParseLabels(_labels) + verifyStatus := ctx.Query("verify_status") + + req := types.UserIndexReq{ + Search: search, + VerifyStatus: types.VerifyStatus(verifyStatus), + Labels: labels, + Per: 300, + } + + ctx.Writer.Header().Set("Content-Type", "text/event-stream") + ctx.Writer.Header().Set("Cache-Control", "no-cache") + ctx.Writer.Header().Set("Connection", "keep-alive") + ctx.Writer.Header().Set("X-Accel-Buffering", "no") + ctx.Writer.WriteHeader(http.StatusOK) + ctx.Writer.Flush() + + data, err := h.c.StreamExportUsers(ctx.Request.Context(), req) + if err != nil { + slog.ErrorContext(ctx.Request.Context(), "stream export failed in component", slog.Any("error", err), slog.Any("req", req)) + select { + case data <- types.UserIndexResp{Error: err}: + case <-ctx.Request.Context().Done(): + } + } + + for resp := range data { + if resp.Error != nil { + ctx.SSEvent("error", resp.Error.Error()) + ctx.Writer.Flush() + return + } + + jsonData, err := json.Marshal(resp.Users) + if err != nil { + slog.ErrorContext(ctx.Request.Context(), "Failed to marshal users", slog.Any("error", err)) + ctx.SSEvent("error", errorx.ErrInternalServerError.Error()) + ctx.Writer.Flush() + return + } + ctx.SSEvent("users", string(jsonData)) + ctx.Writer.Flush() + } + + ctx.SSEvent("end", "export completed") + ctx.Writer.Flush() +} + +func redirectWithoutBody(ctx *gin.Context, code int, url string) { + ctx.Header("Location", url) + ctx.AbortWithStatus(code) +}