From fe2dea30c9f774aba3f1cf6a7170409440d5e33a Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 28 Sep 2026 11:43:34 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20MonkeyAI=20=E6=88=90=E5=91=98=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/backend/member/member.go | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 monkeyai/backend/member/member.go diff --git a/monkeyai/backend/member/member.go b/monkeyai/backend/member/member.go new file mode 100644 index 000000000..9781a02b9 --- /dev/null +++ b/monkeyai/backend/member/member.go @@ -0,0 +1,100 @@ +package member + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +var ( + ErrSeatsExceeded = errors.New("成员席位已满") + ErrSeatsUnavailable = errors.New("成员席位授权不可用") +) + +type User struct { + ID string + Name string + Email string + AvatarURL string + Role string + Status string + JoinedAt time.Time + LastLoginAt *time.Time +} + +type InitialAdmin struct { + Name string + Email string + Password string +} + +type CreateUser struct { + ActorID string + Name string + Email string + Role string + Password string + GroupIDs []string +} + +type UpdateUser struct { + ID string + Name string + Role string + Status string +} + +type OAuthIdentity struct { + Provider string + Issuer string + Subject string + Username string + Name string + Email string + AvatarURL string + AdminOnly bool + AutoRegistrationEnabled bool +} + +type PasswordReset struct { + Email string + Password string +} + +// 接收 pgx.Tx 的方法参与调用方事务,不自行提交或回滚。 +type UserWriter interface { + EnsureInitialAdmin(context.Context, InitialAdmin) error + CreateUser(context.Context, CreateUser) (User, error) + UpdateUser(context.Context, UpdateUser) (User, error) + RegisterEmailUser(context.Context, pgx.Tx, string) (User, error) + UpsertIdentity(context.Context, OAuthIdentity) (User, error) + ResetPassword(context.Context, pgx.Tx, PasswordReset) error + ResetUserPassword(context.Context, pgx.Tx, string) (string, error) + TouchLogin(context.Context, string) error +} + +type GroupMembers struct { + ActorID string + GroupID string + UserIDs []string +} + +type MoveMember struct { + ID string + SourceGroupID *string +} + +type MoveMembers struct { + ActorID string + TargetGroupID *string + Members []MoveMember +} + +// 接收 pgx.Tx 的方法参与调用方事务,不自行提交或回滚。 +type GroupWriter interface { + SetMembers(context.Context, pgx.Tx, GroupMembers) error + MoveMembers(context.Context, pgx.Tx, MoveMembers) error + RemoveAllMembers(context.Context, pgx.Tx, string) error +} From 90dfa93a6583a8acab9cfbedc3f0f69f262f1b5c Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 28 Sep 2026 16:05:34 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20MonkeyAI=20=E6=88=90=E5=91=98=E5=86=99=E5=85=A5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/backend/internal/app/app.go | 42 +++++++++++++++- monkeyai/backend/internal/group/move.go | 38 ++++++++++----- monkeyai/backend/internal/group/service.go | 25 ++++++++-- monkeyai/backend/internal/identity/admin.go | 40 ++++++++++++---- monkeyai/backend/internal/identity/email.go | 28 +++++++++-- .../internal/identity/memberwriter_test.go | 48 +++++++++++++++++++ monkeyai/backend/internal/identity/oauth.go | 9 +++- .../backend/internal/identity/password.go | 23 ++++----- .../backend/internal/identity/postgres.go | 27 +++++++++-- monkeyai/backend/internal/identity/service.go | 14 ++++++ monkeyai/backend/member/member.go | 29 ++++++----- monkeyai/backend/member/password.go | 21 ++++++++ monkeyai/backend/server/server.go | 36 ++++++++++++++ 13 files changed, 321 insertions(+), 59 deletions(-) create mode 100644 monkeyai/backend/internal/identity/memberwriter_test.go create mode 100644 monkeyai/backend/member/password.go create mode 100644 monkeyai/backend/server/server.go diff --git a/monkeyai/backend/internal/app/app.go b/monkeyai/backend/internal/app/app.go index 37eb12221..ae4b903f6 100644 --- a/monkeyai/backend/internal/app/app.go +++ b/monkeyai/backend/internal/app/app.go @@ -35,6 +35,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/setting" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/skill" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/stats" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -51,12 +52,38 @@ type App struct { endpoints *endpoint.Service } +type MemberWriters func(*pgxpool.Pool) (member.UserWriter, member.GroupWriter, error) + func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { + return newApp(ctx, cfg, logger, nil) +} + +func NewWithMembers(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters) (*App, error) { + if factory == nil { + return nil, errors.New("私有版成员实现不可为空") + } + return newApp(ctx, cfg, logger, factory) +} + +func newApp(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters) (*App, error) { pool, err := database.Open(ctx, cfg.URL) if err != nil { return nil, err } - handler, err := newApplicationHandler(ctx, logger, pool, cfg) + var users member.UserWriter + var groups member.GroupWriter + if factory != nil { + users, groups, err = factory(pool) + if err != nil { + pool.Close() + return nil, err + } + if users == nil || groups == nil { + pool.Close() + return nil, errors.New("私有版成员实现不可为空") + } + } + handler, err := newApplicationHandlerWithMembers(ctx, logger, pool, cfg, users, groups) if err != nil { pool.Close() return nil, err @@ -103,8 +130,15 @@ func newHandler(logger *slog.Logger, database httpapi.Pinger) http.Handler { } func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, cfg config.Config) (http.Handler, error) { + return newApplicationHandlerWithMembers(ctx, logger, pool, cfg, nil, nil) +} + +func newApplicationHandlerWithMembers(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, cfg config.Config, users member.UserWriter, groups member.GroupWriter) (http.Handler, error) { settings := setting.NewService(setting.NewPostgres(pool)) identities := identity.NewService(pool, settings, cfg.PublicURL).WithEmailSender(settings) + if users != nil { + identities.WithUserWriter(users) + } if err := identities.EnsureInitialAdmin(ctx, cfg.InitialAdminName, cfg.InitialAdminEmail, cfg.InitialAdminPassword); err != nil { return nil, fmt.Errorf("初始化管理员: %w", err) } @@ -158,7 +192,11 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo })) audits.RegisterAdmin(admin) identities.RegisterAdmin(admin) - group.NewService(pool).WithAccountPreserver(charges).RegisterAdmin(admin) + groupService := group.NewService(pool).WithAccountPreserver(charges) + if groups != nil { + groupService.WithMemberWriter(groups) + } + groupService.RegisterAdmin(admin) settings.RegisterAdmin(admin) charges.RegisterAdmin(admin) stats.NewService(pool).RegisterAdmin(admin) diff --git a/monkeyai/backend/internal/group/move.go b/monkeyai/backend/internal/group/move.go index 53e96d2dc..2c8f316f3 100644 --- a/monkeyai/backend/internal/group/move.go +++ b/monkeyai/backend/internal/group/move.go @@ -8,6 +8,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/group/sqlc" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/rootgroup" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -55,7 +56,7 @@ func (s *Service) Move(ctx context.Context, actor string, in MoveInput) error { if len(in.GroupIDs) > 0 { err = moveGroups(ctx, tx, actor, target, in.GroupIDs) } else { - err = moveMembers(ctx, tx, actor, target, in.Members) + err = moveMembers(ctx, tx, s.members, actor, target, in.Members) } if err != nil { return err @@ -126,7 +127,7 @@ func moveGroups(ctx context.Context, tx pgx.Tx, actor string, target *string, id return nil } -func moveMembers(ctx context.Context, tx pgx.Tx, actor string, target *string, members []MoveMember) error { +func moveMembers(ctx context.Context, tx pgx.Tx, writer member.GroupWriter, actor string, target *string, members []MoveMember) error { ids := make([]string, 0, len(members)) seen := map[string]bool{} for _, member := range members { @@ -186,21 +187,34 @@ func moveMembers(ctx context.Context, tx pgx.Tx, actor string, target *string, m } } changed := map[string]bool{} - for _, member := range members { - if member.SourceGroupID != nil && *member.SourceGroupID != rootgroup.ID { - id := *member.SourceGroupID - if _, err := queries.RemoveMember(ctx, sqlc.RemoveMemberParams{GroupID: id, UserID: member.ID}); err != nil { - return err - } - changed[id] = true + input := member.MoveMembers{ActorID: actor, TargetGroupID: target, Members: make([]member.MoveMember, 0, len(members))} + for _, entry := range members { + input.Members = append(input.Members, member.MoveMember{ID: entry.ID, SourceGroupID: entry.SourceGroupID}) + if entry.SourceGroupID != nil && *entry.SourceGroupID != rootgroup.ID { + changed[*entry.SourceGroupID] = true } if target != nil { - if _, err := queries.AddMember(ctx, sqlc.AddMemberParams{GroupID: *target, UserID: member.ID, AssignedByUserID: actor}); err != nil { - return err - } changed[*target] = true } } + if writer != nil { + if err := writer.MoveMembers(ctx, tx, input); err != nil { + return err + } + } else { + for _, entry := range members { + if entry.SourceGroupID != nil && *entry.SourceGroupID != rootgroup.ID { + if _, err := queries.RemoveMember(ctx, sqlc.RemoveMemberParams{GroupID: *entry.SourceGroupID, UserID: entry.ID}); err != nil { + return err + } + } + if target != nil { + if _, err := queries.AddMember(ctx, sqlc.AddMemberParams{GroupID: *target, UserID: entry.ID, AssignedByUserID: actor}); err != nil { + return err + } + } + } + } for id := range changed { if _, err := queries.TouchGroup(ctx, id); err != nil { return err diff --git a/monkeyai/backend/internal/group/service.go b/monkeyai/backend/internal/group/service.go index edcd53230..54dfa3bdd 100644 --- a/monkeyai/backend/internal/group/service.go +++ b/monkeyai/backend/internal/group/service.go @@ -12,6 +12,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/group/sqlc" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/rootgroup" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -38,6 +39,12 @@ type AccountPreserver interface { type Service struct { pool *pgxpool.Pool accounts AccountPreserver + members member.GroupWriter +} + +func (s *Service) WithMemberWriter(writer member.GroupWriter) *Service { + s.members = writer + return s } func (s *Service) WithAccountPreserver(accounts AccountPreserver) *Service { @@ -220,10 +227,15 @@ func (s *Service) SetMembers(ctx context.Context, actor, id string, ids []string if count != len(ids) { return Group{}, resource.Invalid("所选成员不存在或已删除") } - if _, err = sqlc.New(tx).RemoveMembers(ctx, sqlc.RemoveMembersParams{GroupID: id, UserIds: ids}); err != nil { - return Group{}, err + if s.members != nil { + err = s.members.SetMembers(ctx, tx, member.GroupMembers{ActorID: actor, GroupID: id, UserIDs: ids}) + } else { + _, err = sqlc.New(tx).RemoveMembers(ctx, sqlc.RemoveMembersParams{GroupID: id, UserIds: ids}) + if err == nil { + _, err = sqlc.New(tx).AddMembers(ctx, sqlc.AddMembersParams{GroupID: id, UserIds: ids, AssignedByUserID: actor}) + } } - if _, err = sqlc.New(tx).AddMembers(ctx, sqlc.AddMembersParams{GroupID: id, UserIds: ids, AssignedByUserID: actor}); err != nil { + if err != nil { return Group{}, err } if _, err = sqlc.New(tx).TouchGroup(ctx, id); err != nil { @@ -261,7 +273,12 @@ func (s *Service) Delete(ctx context.Context, actor, id string) error { if _, err = sqlc.New(tx).DeleteGroup(ctx, id); err != nil { return err } - if _, err = sqlc.New(tx).RemoveAllMembers(ctx, id); err != nil { + if s.members != nil { + err = s.members.RemoveAllMembers(ctx, tx, id) + } else { + _, err = sqlc.New(tx).RemoveAllMembers(ctx, id) + } + if err != nil { return err } if _, err = sqlc.New(tx).DeleteGrants(ctx, new(id)); err != nil { diff --git a/monkeyai/backend/internal/identity/admin.go b/monkeyai/backend/internal/identity/admin.go index f4a5bb092..b60ff72df 100644 --- a/monkeyai/backend/internal/identity/admin.go +++ b/monkeyai/backend/internal/identity/admin.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -62,12 +63,17 @@ func (s *Service) patchUser(w http.ResponseWriter, r *http.Request) { } user, err := s.updateUser(r.Context(), chi.URLParam(r, "userID"), input.Name, input.Role, input.Status, "") if err != nil { - if !errors.Is(err, ErrNotFound) { + switch { + case errors.Is(err, ErrNotFound), errors.Is(err, pgx.ErrNoRows): + writeError(w, http.StatusNotFound, "user_not_found", "用户不存在") + case errors.Is(err, member.ErrSeatsExceeded): + writeError(w, http.StatusConflict, "seats_exceeded", "成员席位已满") + case errors.Is(err, member.ErrSeatsUnavailable): + writeError(w, http.StatusServiceUnavailable, "seats_unavailable", "成员席位授权不可用") + default: slog.ErrorContext(r.Context(), "更新用户失败", "user_id", chi.URLParam(r, "userID"), "error", err) writeError(w, http.StatusInternalServerError, "server_error", "更新用户失败") - return } - writeError(w, http.StatusNotFound, "user_not_found", "用户不存在") return } writeJSON(w, http.StatusOK, user) @@ -110,7 +116,12 @@ func (s *Service) resetUserPassword(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "server_error", "重置密码失败") return } - if _, err := q.ResetUserPassword(ctx, sqlc.ResetUserPasswordParams{ID: user.ID, PasswordHash: &hash}); err != nil { + if s.writer != nil { + err = s.writer.ResetUserPassword(ctx, tx, user.ID, hash) + } else { + _, err = q.ResetUserPassword(ctx, sqlc.ResetUserPasswordParams{ID: user.ID, PasswordHash: &hash}) + } + if err != nil { slog.ErrorContext(ctx, "更新用户密码失败", "user_id", user.ID, "error", err) writeError(w, http.StatusInternalServerError, "server_error", "重置密码失败") return @@ -170,13 +181,26 @@ func (s *Service) createUser(w http.ResponseWriter, r *http.Request) { passwordHash = &hash } actor, _ := UserFromContext(r.Context()) - user, err := s.insertUserWithGroups(r.Context(), actor.ID, sqlc.CreateUserParams{ - Name: input.Name, Email: input.Email, Role: input.Role, PasswordHash: passwordHash, - }, groupIDs) + var user User + if s.writer != nil { + result, writeErr := s.writer.CreateUser(r.Context(), member.CreateUser{ + ActorID: actor.ID, Name: input.Name, Email: input.Email, + Role: input.Role, Password: input.Password, GroupIDs: groupIDs, + }) + user, err = userFromMember(result), writeErr + } else { + user, err = s.insertUserWithGroups(r.Context(), actor.ID, sqlc.CreateUserParams{ + Name: input.Name, Email: input.Email, Role: input.Role, PasswordHash: passwordHash, + }, groupIDs) + } if err != nil { var dbError *pgconn.PgError switch { - case errors.Is(err, errCreationGroupUnavailable): + case errors.Is(err, member.ErrSeatsExceeded): + writeError(w, http.StatusConflict, "seats_exceeded", "成员席位已满") + case errors.Is(err, member.ErrSeatsUnavailable): + writeError(w, http.StatusServiceUnavailable, "seats_unavailable", "成员席位授权不可用") + case errors.Is(err, errCreationGroupUnavailable), errors.Is(err, member.ErrGroupUnavailable): writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) case errors.As(err, &dbError) && dbError.Code == "23505" && dbError.ConstraintName == "users_email_active_key": writeError(w, http.StatusConflict, "user_exists", "该邮箱已存在") diff --git a/monkeyai/backend/internal/identity/email.go b/monkeyai/backend/internal/identity/email.go index 59fb9d2de..46c881baa 100644 --- a/monkeyai/backend/internal/identity/email.go +++ b/monkeyai/backend/internal/identity/email.go @@ -15,6 +15,7 @@ import ( "time" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/jackc/pgx/v5" ) @@ -289,7 +290,13 @@ func (s *Service) completeEmail(w http.ResponseWriter, r *http.Request, purpose writeError(w, 500, "server_error", "密码重置失败") return } - id, resetErr := q.ResetPassword(ctx, sqlc.ResetPasswordParams{Email: input.Email, PasswordHash: &hash}) + var id string + var resetErr error + if s.writer != nil { + id, resetErr = s.writer.ResetPassword(ctx, tx, member.PasswordReset{Email: input.Email, PasswordHash: hash}) + } else { + id, resetErr = q.ResetPassword(ctx, sqlc.ResetPasswordParams{Email: input.Email, PasswordHash: &hash}) + } if resetErr == nil { resetErr = revokePasswordAccess(ctx, q, id, input.Email) } @@ -304,9 +311,22 @@ func (s *Service) completeEmail(w http.ResponseWriter, r *http.Request, purpose row, lookupErr := q.GetUserByEmail(ctx, input.Email) admin := strings.HasPrefix(r.URL.Path, "/admin/") || strings.Contains(r.URL.Path, "/v1/admin/") if errors.Is(lookupErr, pgx.ErrNoRows) && methods.EmailCodeAutoRegistrationEnabled && !admin { - if err := q.CreateEmailUser(ctx, sqlc.CreateEmailUserParams{Name: input.Email, Email: input.Email}); err != nil { - slog.ErrorContext(ctx, "自动注册邮件登录用户失败", "error", err) - writeError(w, 500, "server_error", "创建账号失败") + var createErr error + if s.writer != nil { + _, createErr = s.writer.RegisterEmailUser(ctx, tx, input.Email) + } else { + createErr = q.CreateEmailUser(ctx, sqlc.CreateEmailUserParams{Name: input.Email, Email: input.Email}) + } + if createErr != nil { + switch { + case errors.Is(createErr, member.ErrSeatsExceeded): + writeError(w, http.StatusConflict, "seats_exceeded", "成员席位已满") + case errors.Is(createErr, member.ErrSeatsUnavailable): + writeError(w, http.StatusServiceUnavailable, "seats_unavailable", "成员席位授权不可用") + default: + slog.ErrorContext(ctx, "自动注册邮件登录用户失败", "error", createErr) + writeError(w, http.StatusInternalServerError, "server_error", "创建账号失败") + } return } row, lookupErr = q.GetUserByEmail(ctx, input.Email) diff --git a/monkeyai/backend/internal/identity/memberwriter_test.go b/monkeyai/backend/internal/identity/memberwriter_test.go new file mode 100644 index 000000000..6c27f1148 --- /dev/null +++ b/monkeyai/backend/internal/identity/memberwriter_test.go @@ -0,0 +1,48 @@ +package identity + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" +) + +type createWriter struct { + member.UserWriter + input member.CreateUser + err error +} + +func (w *createWriter) CreateUser(_ context.Context, input member.CreateUser) (member.User, error) { + w.input = input + return member.User{ID: "created", Name: input.Name, Email: input.Email, Role: input.Role, Status: "active"}, w.err +} + +func TestCreateUserUsesInjectedWriter(t *testing.T) { + for _, tc := range []struct { + name string + err error + want int + }{ + {name: "正常创建", want: http.StatusCreated}, + {name: "席位已满", err: member.ErrSeatsExceeded, want: http.StatusConflict}, + {name: "授权不可用", err: member.ErrSeatsUnavailable, want: http.StatusServiceUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + writer := &createWriter{err: tc.err} + service := NewService(nil, nil, "").WithUserWriter(writer) + req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(`{"name":"测试成员","email":"test@example.com","role":"user"}`)) + recorder := httptest.NewRecorder() + service.createUser(recorder, req) + if recorder.Code != tc.want { + t.Fatalf("状态码=%d,期望=%d,响应=%s", recorder.Code, tc.want, recorder.Body.String()) + } + if writer.input.Email != "test@example.com" || writer.input.Name != "测试成员" { + t.Fatalf("未将创建请求传给 Pro 写入接口: %+v", writer.input) + } + }) + } +} diff --git a/monkeyai/backend/internal/identity/oauth.go b/monkeyai/backend/internal/identity/oauth.go index c1e687554..e19ffa982 100644 --- a/monkeyai/backend/internal/identity/oauth.go +++ b/monkeyai/backend/internal/identity/oauth.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) @@ -286,10 +287,14 @@ func (s *Service) upstreamCallback(w http.ResponseWriter, r *http.Request) { if err != nil { code := "user_unavailable" switch { - case errors.Is(err, ErrAdminRoleRequired): + case errors.Is(err, ErrAdminRoleRequired), errors.Is(err, member.ErrAdminRoleRequired): code = "admin_role_required" - case adminLogin && errors.Is(err, ErrUserDisabled): + case adminLogin && (errors.Is(err, ErrUserDisabled) || errors.Is(err, member.ErrUserDisabled)): code = "admin_role_required" + case errors.Is(err, member.ErrSeatsExceeded): + code = "seats_exceeded" + case errors.Is(err, member.ErrSeatsUnavailable): + code = "seats_unavailable" } http.Redirect(w, r, s.upstreamResultURL(state, code), http.StatusFound) return diff --git a/monkeyai/backend/internal/identity/password.go b/monkeyai/backend/internal/identity/password.go index aff06468f..9f17216c8 100644 --- a/monkeyai/backend/internal/identity/password.go +++ b/monkeyai/backend/internal/identity/password.go @@ -19,13 +19,13 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/audit" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/jackc/pgx/v5" ) var errPasswordNotSet = errors.New("未设置密码") const ( - passwordIterations = 600_000 dummyPasswordHash = "$pbkdf2-sha256$600000$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" passwordAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" generatedPasswordLength = 16 @@ -53,6 +53,9 @@ func generatePassword() (string, error) { } func (s *Service) EnsureInitialAdmin(ctx context.Context, name, email, password string) error { + if s.writer != nil { + return s.writer.EnsureInitialAdmin(ctx, member.InitialAdmin{Name: name, Email: email, Password: password}) + } tx, err := s.db.Begin(ctx) if err != nil { return err @@ -137,7 +140,13 @@ func (s *Service) passwordLogin(w http.ResponseWriter, r *http.Request) { } func (s *Service) loginSession(w http.ResponseWriter, r *http.Request, user User, method string) { - if _, err := sqlc.New(s.db).TouchLogin(r.Context(), user.ID); err != nil { + var err error + if s.writer != nil { + err = s.writer.TouchLogin(r.Context(), user.ID) + } else { + _, err = sqlc.New(s.db).TouchLogin(r.Context(), user.ID) + } + if err != nil { slog.ErrorContext(r.Context(), "更新登录时间失败", "user_id", user.ID, "error", err) writeError(w, http.StatusInternalServerError, "server_error", "登录失败") return @@ -163,15 +172,7 @@ func (s *Service) loginSession(w http.ResponseWriter, r *http.Request, user User } func hashPassword(password string) (string, error) { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return "", err - } - key, err := pbkdf2.Key(sha256.New, password, salt, passwordIterations, 32) - if err != nil { - return "", err - } - return fmt.Sprintf("$pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil + return member.HashPassword(password) } func verifyPassword(password, encoded string) bool { diff --git a/monkeyai/backend/internal/identity/postgres.go b/monkeyai/backend/internal/identity/postgres.go index 3197c8049..fb44d6a3f 100644 --- a/monkeyai/backend/internal/identity/postgres.go +++ b/monkeyai/backend/internal/identity/postgres.go @@ -8,6 +8,7 @@ import ( "time" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/jackc/pgx/v5" ) @@ -245,12 +246,20 @@ func (s *Service) updateUser(ctx context.Context, id, name, role, status, passwo return User{}, err } } - var disabledAt *time.Time - if status == "disabled" { - disabledAt = new(s.now()) + var user User + if s.writer != nil { + var result member.User + result, err = s.writer.UpdateUser(ctx, tx, member.UpdateUser{ID: id, Name: name, Role: role, Status: status, PasswordHash: passwordHash}) + user = userFromMember(result) + } else { + var disabledAt *time.Time + if status == "disabled" { + disabledAt = new(s.now()) + } + row, queryErr := sqlc.New(tx).UpdateUser(ctx, sqlc.UpdateUserParams{ID: id, Name: name, Role: role, Status: status, DisabledAt: disabledAt, PasswordHash: passwordHash}) + err = queryErr + user = User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt} } - row, err := sqlc.New(tx).UpdateUser(ctx, sqlc.UpdateUserParams{ID: id, Name: name, Role: role, Status: status, DisabledAt: disabledAt, PasswordHash: passwordHash}) - user := User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt} if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrNotFound } @@ -261,6 +270,14 @@ func (s *Service) updateUser(ctx context.Context, id, name, role, status, passwo } func (s *Service) upsertIdentity(ctx context.Context, profile upstreamProfile, adminOnly, autoRegistrationEnabled bool) (User, error) { + if s.writer != nil { + result, err := s.writer.UpsertIdentity(ctx, member.OAuthIdentity{ + Provider: profile.Provider, Issuer: profile.Issuer, Subject: profile.Subject, + Username: profile.Username, Name: profile.Name, Email: profile.Email, AvatarURL: profile.AvatarURL, + AdminOnly: adminOnly, AutoRegistrationEnabled: autoRegistrationEnabled, + }) + return userFromMember(result), err + } tx, err := s.db.Begin(ctx) if err != nil { return User{}, err diff --git a/monkeyai/backend/internal/identity/service.go b/monkeyai/backend/internal/identity/service.go index db017cab9..d5af23a89 100644 --- a/monkeyai/backend/internal/identity/service.go +++ b/monkeyai/backend/internal/identity/service.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -55,6 +56,13 @@ type User struct { LastLoginAt *time.Time `json:"last_login_at,omitempty"` } +func userFromMember(value member.User) User { + return User{ + ID: value.ID, Name: value.Name, Email: value.Email, AvatarURL: value.AvatarURL, + Role: value.Role, Status: value.Status, JoinedAt: value.JoinedAt, LastLoginAt: value.LastLoginAt, + } +} + type AuthorizationRequest struct { ID string ClientID string @@ -105,9 +113,15 @@ type AccountPreserver interface { func (s *Service) WithAccountPreserver(p AccountPreserver) *Service { s.accounts = p; return s } +func (s *Service) WithUserWriter(writer member.UserWriter) *Service { + s.writer = writer + return s +} + type Service struct { email EmailSender accounts AccountPreserver + writer member.UserWriter db *pgxpool.Pool settings SettingReader client *http.Client diff --git a/monkeyai/backend/member/member.go b/monkeyai/backend/member/member.go index 9781a02b9..80c238ad1 100644 --- a/monkeyai/backend/member/member.go +++ b/monkeyai/backend/member/member.go @@ -8,9 +8,15 @@ import ( "github.com/jackc/pgx/v5" ) +const RootGroupID = "00000000-0000-0000-0000-000000000000" + var ( - ErrSeatsExceeded = errors.New("成员席位已满") - ErrSeatsUnavailable = errors.New("成员席位授权不可用") + ErrSeatsExceeded = errors.New("成员席位已满") + ErrSeatsUnavailable = errors.New("成员席位授权不可用") + ErrGroupUnavailable = errors.New("所选分组不存在或已删除") + ErrUserDisabled = errors.New("用户已停用") + ErrAdminRoleRequired = errors.New("需要管理员角色") + ErrRegistrationClosed = errors.New("自动注册未开启") ) type User struct { @@ -40,10 +46,11 @@ type CreateUser struct { } type UpdateUser struct { - ID string - Name string - Role string - Status string + ID string + Name string + Role string + Status string + PasswordHash string } type OAuthIdentity struct { @@ -59,19 +66,19 @@ type OAuthIdentity struct { } type PasswordReset struct { - Email string - Password string + Email string + PasswordHash string } // 接收 pgx.Tx 的方法参与调用方事务,不自行提交或回滚。 type UserWriter interface { EnsureInitialAdmin(context.Context, InitialAdmin) error CreateUser(context.Context, CreateUser) (User, error) - UpdateUser(context.Context, UpdateUser) (User, error) + UpdateUser(context.Context, pgx.Tx, UpdateUser) (User, error) RegisterEmailUser(context.Context, pgx.Tx, string) (User, error) UpsertIdentity(context.Context, OAuthIdentity) (User, error) - ResetPassword(context.Context, pgx.Tx, PasswordReset) error - ResetUserPassword(context.Context, pgx.Tx, string) (string, error) + ResetPassword(context.Context, pgx.Tx, PasswordReset) (string, error) + ResetUserPassword(context.Context, pgx.Tx, string, string) error TouchLogin(context.Context, string) error } diff --git a/monkeyai/backend/member/password.go b/monkeyai/backend/member/password.go new file mode 100644 index 000000000..cc7307c9c --- /dev/null +++ b/monkeyai/backend/member/password.go @@ -0,0 +1,21 @@ +package member + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key, err := pbkdf2.Key(sha256.New, password, salt, 600_000, 32) + if err != nil { + return "", err + } + return fmt.Sprintf("$pbkdf2-sha256$%d$%s$%s", 600_000, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} diff --git a/monkeyai/backend/server/server.go b/monkeyai/backend/server/server.go new file mode 100644 index 000000000..d35859e32 --- /dev/null +++ b/monkeyai/backend/server/server.go @@ -0,0 +1,36 @@ +package server + +import ( + "context" + "errors" + "log/slog" + "os" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/app" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/config" + "github.com/chaitin/MonkeyCode/monkeyai/backend/member" + "github.com/jackc/pgx/v5/pgxpool" +) + +type MemberWriters func(*pgxpool.Pool) (member.UserWriter, member.GroupWriter, error) + +func Run(ctx context.Context, args []string, factory MemberWriters) error { + if factory == nil { + return errors.New("私有版成员实现不可为空") + } + cfg, err := config.Load(args) + if err != nil { + return err + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) + application, err := app.NewWithMembers(ctx, cfg, logger, app.MemberWriters(factory)) + if err != nil { + return err + } + logger.Info("服务启动", "addr", cfg.Addr, "pprof_addr", cfg.PprofAddr) + if err := application.Run(ctx); err != nil { + return err + } + logger.Info("服务已停止") + return nil +} From 8ae6b98cfe739ab551a083b0a2b4923f1b7bd46a Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 28 Sep 2026 18:03:24 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20License=20=E7=AE=A1=E7=90=86=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/backend/internal/app/app.go | 23 ++++++++++++++++------- monkeyai/backend/server/server.go | 12 ++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/monkeyai/backend/internal/app/app.go b/monkeyai/backend/internal/app/app.go index ae4b903f6..93595575d 100644 --- a/monkeyai/backend/internal/app/app.go +++ b/monkeyai/backend/internal/app/app.go @@ -54,18 +54,22 @@ type App struct { type MemberWriters func(*pgxpool.Pool) (member.UserWriter, member.GroupWriter, error) +type AdminRegistrar interface { + RegisterAdmin(chi.Router) +} + func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { - return newApp(ctx, cfg, logger, nil) + return newApp(ctx, cfg, logger, nil, nil) } -func NewWithMembers(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters) (*App, error) { +func NewWithMembers(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters, registrars ...AdminRegistrar) (*App, error) { if factory == nil { return nil, errors.New("私有版成员实现不可为空") } - return newApp(ctx, cfg, logger, factory) + return newApp(ctx, cfg, logger, factory, registrars) } -func newApp(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters) (*App, error) { +func newApp(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters, registrars []AdminRegistrar) (*App, error) { pool, err := database.Open(ctx, cfg.URL) if err != nil { return nil, err @@ -83,7 +87,7 @@ func newApp(ctx context.Context, cfg config.Config, logger *slog.Logger, factory return nil, errors.New("私有版成员实现不可为空") } } - handler, err := newApplicationHandlerWithMembers(ctx, logger, pool, cfg, users, groups) + handler, err := newApplicationHandlerWithMembers(ctx, logger, pool, cfg, users, groups, registrars) if err != nil { pool.Close() return nil, err @@ -130,10 +134,10 @@ func newHandler(logger *slog.Logger, database httpapi.Pinger) http.Handler { } func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, cfg config.Config) (http.Handler, error) { - return newApplicationHandlerWithMembers(ctx, logger, pool, cfg, nil, nil) + return newApplicationHandlerWithMembers(ctx, logger, pool, cfg, nil, nil, nil) } -func newApplicationHandlerWithMembers(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, cfg config.Config, users member.UserWriter, groups member.GroupWriter) (http.Handler, error) { +func newApplicationHandlerWithMembers(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, cfg config.Config, users member.UserWriter, groups member.GroupWriter, registrars []AdminRegistrar) (http.Handler, error) { settings := setting.NewService(setting.NewPostgres(pool)) identities := identity.NewService(pool, settings, cfg.PublicURL).WithEmailSender(settings) if users != nil { @@ -197,6 +201,11 @@ func newApplicationHandlerWithMembers(ctx context.Context, logger *slog.Logger, groupService.WithMemberWriter(groups) } groupService.RegisterAdmin(admin) + for _, registrar := range registrars { + if registrar != nil { + registrar.RegisterAdmin(admin) + } + } settings.RegisterAdmin(admin) charges.RegisterAdmin(admin) stats.NewService(pool).RegisterAdmin(admin) diff --git a/monkeyai/backend/server/server.go b/monkeyai/backend/server/server.go index d35859e32..132853a95 100644 --- a/monkeyai/backend/server/server.go +++ b/monkeyai/backend/server/server.go @@ -9,12 +9,16 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/app" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/config" "github.com/chaitin/MonkeyCode/monkeyai/backend/member" + "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" ) type MemberWriters func(*pgxpool.Pool) (member.UserWriter, member.GroupWriter, error) +type AdminRegistrar interface { + RegisterAdmin(chi.Router) +} -func Run(ctx context.Context, args []string, factory MemberWriters) error { +func Run(ctx context.Context, args []string, factory MemberWriters, registrars ...AdminRegistrar) error { if factory == nil { return errors.New("私有版成员实现不可为空") } @@ -23,7 +27,11 @@ func Run(ctx context.Context, args []string, factory MemberWriters) error { return err } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) - application, err := app.NewWithMembers(ctx, cfg, logger, app.MemberWriters(factory)) + appRegistrars := make([]app.AdminRegistrar, 0, len(registrars)) + for _, registrar := range registrars { + appRegistrars = append(appRegistrars, registrar) + } + application, err := app.NewWithMembers(ctx, cfg, logger, app.MemberWriters(factory), appRegistrars...) if err != nil { return err } From 494c6467ceaeb110813499261d7de957adb4df55 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Tue, 29 Sep 2026 11:46:05 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E9=83=A8=E7=BD=B2=EF=BC=9A=E5=88=87?= =?UTF-8?q?=E6=8D=A2=20MonkeyAI=20=E7=A7=81=E6=9C=89=E7=89=88=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/Makefile | 7 +++++-- monkeyai/docker-compose.yml | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/monkeyai/Makefile b/monkeyai/Makefile index 4b48464f1..90e2b63b2 100644 --- a/monkeyai/Makefile +++ b/monkeyai/Makefile @@ -6,6 +6,7 @@ ADMIN_IMAGE ?= $(REGISTRY)/monkeyai-admin:$(TAG) BACKEND_IMAGE ?= $(REGISTRY)/monkeyai-backend:$(TAG) MIGRATE_IMAGE ?= $(REGISTRY)/monkeyai-migrate:$(TAG) NGINX_ACME_IMAGE ?= $(REGISTRY)/nginx-acme:1.31.6-acme0.4.1-r1 +PRIVATE_ROOT ?= ../.. .PHONY: image image-admin image-backend image-migrate image-nginx-acme push push-admin push-backend push-migrate push-nginx-acme images @@ -24,10 +25,11 @@ image-admin: image-backend: docker buildx build \ --platform $(PLATFORM) \ + --file $(PRIVATE_ROOT)/monkeyai/Dockerfile \ --tag $(BACKEND_IMAGE) \ --secret id=netrc,src=${HOME}/.netrc \ --load \ - ./backend + $(PRIVATE_ROOT) @echo "Backend image: $(BACKEND_IMAGE)" image-migrate: @@ -63,10 +65,11 @@ push-admin: push-backend: docker buildx build \ --platform $(PLATFORM) \ + --file $(PRIVATE_ROOT)/monkeyai/Dockerfile \ --tag $(BACKEND_IMAGE) \ --secret id=netrc,src=${HOME}/.netrc \ --push \ - ./backend + $(PRIVATE_ROOT) @echo "Pushed: $(BACKEND_IMAGE)" push-migrate: diff --git a/monkeyai/docker-compose.yml b/monkeyai/docker-compose.yml index 023bb0819..47be66699 100644 --- a/monkeyai/docker-compose.yml +++ b/monkeyai/docker-compose.yml @@ -53,7 +53,8 @@ services: backend: image: ${BACKEND_IMAGE:-monkeyai-backend:local} build: - context: ./backend + context: ../.. + dockerfile: monkeyai/Dockerfile restart: unless-stopped init: true environment: @@ -76,6 +77,12 @@ services: MONKEYAI_INITIAL_ADMIN_NAME: ${MONKEYAI_INITIAL_ADMIN_NAME:-MonkeyAI Admin} MONKEYAI_INITIAL_ADMIN_EMAIL: ${MONKEYAI_INITIAL_ADMIN_EMAIL:-} MONKEYAI_INITIAL_ADMIN_PASSWORD: ${MONKEYAI_INITIAL_ADMIN_PASSWORD:-} + MONKEYAI_LICENSE_PUBLIC_KEY: ${MONKEYAI_LICENSE_PUBLIC_KEY:-/app/license/public.key} + MONKEYAI_LICENSE_AES_KEY: ${MONKEYAI_LICENSE_AES_KEY:-/app/license/aes.key} + MONKEYAI_LICENSE_FILE: ${MONKEYAI_LICENSE_FILE:-/app/license/license.lic} + MONKEYAI_MACHINE_ID_FILE: ${MONKEYAI_MACHINE_ID_FILE:-/app/license/machine-id} + volumes: + - ./data/license:/app/license depends_on: rustfs: condition: service_healthy From a93db13b6075731e96e97090e8d25a05b73ee148 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Tue, 29 Sep 2026 14:24:56 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20License=20=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/admin/src/App.tsx | 3 + monkeyai/admin/src/components/app-sidebar.tsx | 4 + .../admin/src/components/ui/file-input.tsx | 43 ++++ monkeyai/admin/src/i18n/locales/ar.ts | 1 + monkeyai/admin/src/i18n/locales/de-DE.ts | 1 + monkeyai/admin/src/i18n/locales/en-US.ts | 27 ++ monkeyai/admin/src/i18n/locales/es-419.ts | 1 + monkeyai/admin/src/i18n/locales/fr-FR.ts | 1 + monkeyai/admin/src/i18n/locales/ja-JP.ts | 1 + monkeyai/admin/src/i18n/locales/ko-KR.ts | 1 + monkeyai/admin/src/i18n/locales/ru-RU.ts | 1 + monkeyai/admin/src/i18n/locales/zh-CN.ts | 27 ++ monkeyai/admin/src/i18n/locales/zh-TW.ts | 1 + monkeyai/admin/src/lib/routes.ts | 8 + monkeyai/admin/src/pages/license-page.tsx | 233 ++++++++++++++++++ monkeyai/admin/test/license-page.test.mjs | 39 +++ 16 files changed, 392 insertions(+) create mode 100644 monkeyai/admin/src/components/ui/file-input.tsx create mode 100644 monkeyai/admin/src/pages/license-page.tsx create mode 100644 monkeyai/admin/test/license-page.test.mjs diff --git a/monkeyai/admin/src/App.tsx b/monkeyai/admin/src/App.tsx index 5d6046842..5964bfccc 100644 --- a/monkeyai/admin/src/App.tsx +++ b/monkeyai/admin/src/App.tsx @@ -18,6 +18,7 @@ import { BillingSettingsPage } from "@/pages/billing-settings-page" import { ExpertsPage } from "@/pages/experts-page" import { KnowledgeBasesPage } from "@/pages/knowledge-bases-page" import { LoginPage } from "@/pages/login-page" +import { LicensePage } from "@/pages/license-page" import { ClientLoginPage } from "@/pages/client-login-page" import { MembersAndGroupsPage } from "@/pages/members-and-groups-page" import { ModelStatisticsPage } from "@/pages/model-statistics-page" @@ -133,6 +134,8 @@ export function App() { ) : page.path === CONSOLE_ROUTES.otherSettings ? ( + ) : page.path === CONSOLE_ROUTES.license ? ( + ) : ( ) diff --git a/monkeyai/admin/src/components/app-sidebar.tsx b/monkeyai/admin/src/components/app-sidebar.tsx index 882d6dc82..82114e97c 100644 --- a/monkeyai/admin/src/components/app-sidebar.tsx +++ b/monkeyai/admin/src/components/app-sidebar.tsx @@ -118,6 +118,10 @@ export function AppSidebar({ title: t("pages.otherSettings.title"), url: CONSOLE_ROUTES.otherSettings, }, + { + title: t("pages.license.title"), + url: CONSOLE_ROUTES.license, + }, ], }, ] diff --git a/monkeyai/admin/src/components/ui/file-input.tsx b/monkeyai/admin/src/components/ui/file-input.tsx new file mode 100644 index 000000000..82fcd0b3f --- /dev/null +++ b/monkeyai/admin/src/components/ui/file-input.tsx @@ -0,0 +1,43 @@ +import { useId, useRef } from "react" + +import { Button } from "@/components/ui/button" + +type FileInputProps = { + accept?: string + chooseLabel: string + file: File | null + onChange: (file: File | null) => void +} + +export function FileInput({ + accept, + chooseLabel, + file, + onChange, +}: FileInputProps) { + const inputId = useId() + const inputRef = useRef(null) + + return ( +
+ onChange(event.target.files?.[0] ?? null)} + /> + + + {file?.name ?? "—"} + +
+ ) +} diff --git a/monkeyai/admin/src/i18n/locales/ar.ts b/monkeyai/admin/src/i18n/locales/ar.ts index d46d8ba1d..493ef797b 100644 --- a/monkeyai/admin/src/i18n/locales/ar.ts +++ b/monkeyai/admin/src/i18n/locales/ar.ts @@ -109,6 +109,7 @@ export const ar = { systemSettings: "إعدادات النظام", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "الحالة المباشرة", description: "راقب أداء النماذج ونشاط المنتج خلال الساعة الأخيرة.", diff --git a/monkeyai/admin/src/i18n/locales/de-DE.ts b/monkeyai/admin/src/i18n/locales/de-DE.ts index 701513b59..c5bee2d54 100644 --- a/monkeyai/admin/src/i18n/locales/de-DE.ts +++ b/monkeyai/admin/src/i18n/locales/de-DE.ts @@ -115,6 +115,7 @@ export const deDE = { systemSettings: "Systemeinstellungen", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "Live-Status", description: diff --git a/monkeyai/admin/src/i18n/locales/en-US.ts b/monkeyai/admin/src/i18n/locales/en-US.ts index 9d57b2878..81fa45471 100644 --- a/monkeyai/admin/src/i18n/locales/en-US.ts +++ b/monkeyai/admin/src/i18n/locales/en-US.ts @@ -239,6 +239,33 @@ export const enUS = { systemSettings: "System Settings", }, pages: { + license: { + title: "License", + description: "View seats and validity, and upload a new license file.", + status: "License status", + states: { missing: "Missing", valid: "Valid", expired: "Expired", invalid: "Invalid" }, + seats: "Member seats", + client: "Licensed customer", + licenseId: "License ID", + validFrom: "Valid from", + validUntil: "Valid until", + loading: "Loading license information…", + refresh: "Refresh", + machineTitle: "Machine code", + machineDescription: "Provide this machine code when requesting a license.", + machineCode: "Current machine code", + machineHint: "This code identifies the current deployment.", + copy: "Copy machine code", + machineCodeCopied: "Machine code copied", + copyFailed: "Could not copy. Please copy the code manually.", + importTitle: "Import license", + importDescription: "Choose a platform-issued .lic file to replace the current license after validation.", + file: "License file", + chooseFile: "Choose file", + import: "Import", + importing: "Validating and importing…", + importSuccess: "License imported", + }, realtimeStatus: { title: "Live Status", description: diff --git a/monkeyai/admin/src/i18n/locales/es-419.ts b/monkeyai/admin/src/i18n/locales/es-419.ts index 6acda0a84..789062062 100644 --- a/monkeyai/admin/src/i18n/locales/es-419.ts +++ b/monkeyai/admin/src/i18n/locales/es-419.ts @@ -114,6 +114,7 @@ export const es419 = { systemSettings: "Configuración del sistema", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "Estado en tiempo real", description: diff --git a/monkeyai/admin/src/i18n/locales/fr-FR.ts b/monkeyai/admin/src/i18n/locales/fr-FR.ts index 3957f0fd0..54e3da1d9 100644 --- a/monkeyai/admin/src/i18n/locales/fr-FR.ts +++ b/monkeyai/admin/src/i18n/locales/fr-FR.ts @@ -115,6 +115,7 @@ export const frFR = { systemSettings: "Paramètres système", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "État en temps réel", description: diff --git a/monkeyai/admin/src/i18n/locales/ja-JP.ts b/monkeyai/admin/src/i18n/locales/ja-JP.ts index 7280f456b..f8e251f9e 100644 --- a/monkeyai/admin/src/i18n/locales/ja-JP.ts +++ b/monkeyai/admin/src/i18n/locales/ja-JP.ts @@ -112,6 +112,7 @@ export const jaJP = { systemSettings: "システム設定", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "リアルタイム状態", description: diff --git a/monkeyai/admin/src/i18n/locales/ko-KR.ts b/monkeyai/admin/src/i18n/locales/ko-KR.ts index e60507009..5061acc3a 100644 --- a/monkeyai/admin/src/i18n/locales/ko-KR.ts +++ b/monkeyai/admin/src/i18n/locales/ko-KR.ts @@ -110,6 +110,7 @@ export const koKR = { systemSettings: "시스템 설정", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "실시간 상태", description: "최근 1시간의 모델 성능과 서비스 활동을 확인합니다.", diff --git a/monkeyai/admin/src/i18n/locales/ru-RU.ts b/monkeyai/admin/src/i18n/locales/ru-RU.ts index 03c6f20e6..1a259fa6b 100644 --- a/monkeyai/admin/src/i18n/locales/ru-RU.ts +++ b/monkeyai/admin/src/i18n/locales/ru-RU.ts @@ -113,6 +113,7 @@ export const ruRU = { systemSettings: "Системные настройки", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "Состояние в реальном времени", description: diff --git a/monkeyai/admin/src/i18n/locales/zh-CN.ts b/monkeyai/admin/src/i18n/locales/zh-CN.ts index 24058ca34..8d0031544 100644 --- a/monkeyai/admin/src/i18n/locales/zh-CN.ts +++ b/monkeyai/admin/src/i18n/locales/zh-CN.ts @@ -225,6 +225,33 @@ export const zhCN = { systemSettings: "系统设置", }, pages: { + license: { + title: "License 管理", + description: "查看授权席位与有效期,上传新的 License 文件。", + status: "授权状态", + states: { missing: "未授权", valid: "有效", expired: "已过期", invalid: "无效" }, + seats: "成员席位数", + client: "授权客户", + licenseId: "License ID", + validFrom: "生效时间", + validUntil: "到期时间", + loading: "正在读取授权信息…", + refresh: "刷新", + machineTitle: "机器码", + machineDescription: "申请授权时请提供此机器码。", + machineCode: "当前机器码", + machineHint: "机器码与此部署实例绑定。", + copy: "复制机器码", + machineCodeCopied: "机器码已复制", + copyFailed: "复制失败,请手动复制机器码", + importTitle: "导入 License", + importDescription: "请选择平台签发的 .lic 文件,验证通过后替换现有授权。", + file: "License 文件", + chooseFile: "选择文件", + import: "导入", + importing: "正在验证并导入…", + importSuccess: "License 导入成功", + }, realtimeStatus: { title: "实时状态", description: "查看最近一小时内的模型运行与业务活跃状态。", diff --git a/monkeyai/admin/src/i18n/locales/zh-TW.ts b/monkeyai/admin/src/i18n/locales/zh-TW.ts index ade520d7c..3a49689a9 100644 --- a/monkeyai/admin/src/i18n/locales/zh-TW.ts +++ b/monkeyai/admin/src/i18n/locales/zh-TW.ts @@ -102,6 +102,7 @@ export const zhTW = { systemSettings: "系統設定", }, pages: { + license: enUS.pages.license, realtimeStatus: { title: "即時狀態", description: "查看最近一小時內的模型運行與業務活躍狀態。", diff --git a/monkeyai/admin/src/lib/routes.ts b/monkeyai/admin/src/lib/routes.ts index 423cdaa83..b53d85cdc 100644 --- a/monkeyai/admin/src/lib/routes.ts +++ b/monkeyai/admin/src/lib/routes.ts @@ -17,6 +17,7 @@ export const CONSOLE_ROUTES = { membersAndGroups: "/console/settings/members", operationLogs: "/console/settings/operation-logs", otherSettings: "/console/settings/other", + license: "/console/settings/license", } as const export const DEFAULT_CONSOLE_PATH = CONSOLE_ROUTES.realtimeStatus @@ -127,6 +128,13 @@ export const CONSOLE_PAGES = [ titleKey: "pages.otherSettings.title", descriptionKey: "pages.otherSettings.description", }, + { + path: CONSOLE_ROUTES.license, + sectionKey: "sections.systemSettings", + sectionPath: CONSOLE_ROUTES.membersAndGroups, + titleKey: "pages.license.title", + descriptionKey: "pages.license.description", + }, ] as const export function getConsolePage(pathname: string) { diff --git a/monkeyai/admin/src/pages/license-page.tsx b/monkeyai/admin/src/pages/license-page.tsx new file mode 100644 index 000000000..0d88d26eb --- /dev/null +++ b/monkeyai/admin/src/pages/license-page.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react" +import { useTranslation } from "react-i18next" + +import { useAppToast } from "@/components/animated-toast-provider" +import { FileInput } from "@/components/ui/file-input" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" +import { api } from "@/lib/api" + +type LicenseInfo = { + state: "missing" | "valid" | "expired" | "invalid" + product_type?: string + client_name?: string + client_id?: string + license_id?: string + custom_product_name?: string + machine_ids?: string[] + max_members?: number + not_valid_before?: number + not_valid_after?: number +} + +type MachineCodeResponse = { + machine_code: string +} + +function formatTimestamp(value: number | undefined, locale: string) { + if (!value) return "—" + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value * 1000)) +} + +export function LicensePage() { + const { i18n, t } = useTranslation() + const { showToast } = useAppToast() + const [info, setInfo] = useState(null) + const [machineCode, setMachineCode] = useState("") + const [file, setFile] = useState(null) + const [loading, setLoading] = useState(true) + const [importing, setImporting] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + try { + const [status, machine] = await Promise.all([ + api("/api/admin/v1/license/status"), + api("/api/admin/v1/license/machine-code"), + ]) + setInfo(status) + setMachineCode(machine.machine_code) + } catch (error) { + showToast({ status: "error", title: (error as Error).message }) + } finally { + setLoading(false) + } + }, [showToast]) + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0) + return () => window.clearTimeout(timer) + }, [load]) + + async function importLicense(event: FormEvent) { + event.preventDefault() + if (!file) return + + setImporting(true) + try { + const form = new FormData() + form.append("file", file) + const next = await api("/api/admin/v1/license/import", { + method: "POST", + body: form, + }) + setInfo(next) + setFile(null) + event.currentTarget.reset() + showToast({ + status: "success", + title: t("pages.license.importSuccess"), + }) + } catch (error) { + showToast({ status: "error", title: (error as Error).message }) + } finally { + setImporting(false) + } + } + + async function copyMachineCode() { + try { + await navigator.clipboard.writeText(machineCode) + showToast({ + status: "success", + title: t("pages.license.machineCodeCopied"), + }) + } catch { + showToast({ + status: "error", + title: t("pages.license.copyFailed"), + }) + } + } + + const stateLabel = info + ? t(`pages.license.states.${info.state}`) + : t("pages.license.loading") + + return ( +
+ + + {t("pages.license.title")} + {t("pages.license.description")} + + + + {loading ? ( +

+ {t("pages.license.loading")} +

+ ) : ( +
+
+

+ {t("pages.license.status")} +

+ + {stateLabel} + +
+
+

+ {t("pages.license.seats")} +

+

{info?.max_members ?? "—"}

+
+
+

+ {t("pages.license.client")} +

+

{info?.client_name || info?.client_id || "—"}

+
+
+

+ {t("pages.license.licenseId")} +

+

{info?.license_id || "—"}

+
+
+

+ {t("pages.license.validFrom")} +

+

+ {formatTimestamp(info?.not_valid_before, i18n.language)} +

+
+
+

+ {t("pages.license.validUntil")} +

+

+ {formatTimestamp(info?.not_valid_after, i18n.language)} +

+
+
+ )} +
+
+ + + + {t("pages.license.machineTitle")} + {t("pages.license.machineDescription")} + + + + + {t("pages.license.machineCode")} + +
+ {machineCode || "—"} +
+ {t("pages.license.machineHint")} +
+ +
+
+ + + + {t("pages.license.importTitle")} + {t("pages.license.importDescription")} + + +
+ + {t("pages.license.file")} + + + +
+
+
+
+ ) +} diff --git a/monkeyai/admin/test/license-page.test.mjs b/monkeyai/admin/test/license-page.test.mjs new file mode 100644 index 000000000..0fc3a5c9f --- /dev/null +++ b/monkeyai/admin/test/license-page.test.mjs @@ -0,0 +1,39 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" + +const root = new URL("../", import.meta.url) +const source = async (path) => + readFile(new URL(path, root), "utf8") + +test("license page exposes all license management operations", async () => { + const page = await source("src/pages/license-page.tsx") + assert.match(page, /\/api\/admin\/v1\/license\/status/) + assert.match(page, /\/api\/admin\/v1\/license\/machine-code/) + assert.match(page, /\/api\/admin\/v1\/license\/import/) + assert.match(page, /FormData/) + assert.match(page, /max_members/) +}) + +test("license page is routed and visible in system settings", async () => { + const [routes, app, sidebar] = await Promise.all([ + source("src/lib/routes.ts"), + source("src/App.tsx"), + source("src/components/app-sidebar.tsx"), + ]) + assert.match(routes, /license: "\/console\/settings\/license"/) + assert.match(app, //) + assert.match(sidebar, /pages\.license\.title/) +}) + +test("license page has primary locale translations", async () => { + const [en, zh] = await Promise.all([ + source("src/i18n/locales/en-US.ts"), + source("src/i18n/locales/zh-CN.ts"), + ]) + for (const messages of [en, zh]) { + assert.match(messages, /license: \{/) + assert.match(messages, /importSuccess:/) + assert.match(messages, /machineCodeCopied:/) + } +}) From b773ba6ab23a0df3f523d733a975c507411a581c Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Tue, 29 Sep 2026 15:26:32 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=88=90=E5=91=98=E5=86=99=E5=85=A5=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/backend/internal/app/app.go | 15 +- monkeyai/backend/internal/group/move.go | 19 +-- monkeyai/backend/internal/group/service.go | 22 +-- monkeyai/backend/internal/identity/admin.go | 54 +++---- .../backend/internal/identity/admincreate.go | 50 ------ monkeyai/backend/internal/identity/email.go | 27 ++-- monkeyai/backend/internal/identity/oauth.go | 2 + .../backend/internal/identity/password.go | 48 +----- .../backend/internal/identity/postgres.go | 142 +----------------- monkeyai/backend/internal/identity/service.go | 4 + .../internal/identity/test_helpers_test.go | 18 +++ monkeyai/backend/member/empty.go | 58 +++++++ monkeyai/backend/member/empty_test.go | 116 ++++++++++++++ 13 files changed, 253 insertions(+), 322 deletions(-) create mode 100644 monkeyai/backend/internal/identity/test_helpers_test.go create mode 100644 monkeyai/backend/member/empty.go create mode 100644 monkeyai/backend/member/empty_test.go diff --git a/monkeyai/backend/internal/app/app.go b/monkeyai/backend/internal/app/app.go index 93595575d..0fe5f162c 100644 --- a/monkeyai/backend/internal/app/app.go +++ b/monkeyai/backend/internal/app/app.go @@ -59,7 +59,9 @@ type AdminRegistrar interface { } func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { - return newApp(ctx, cfg, logger, nil, nil) + return newApp(ctx, cfg, logger, func(*pgxpool.Pool) (member.UserWriter, member.GroupWriter, error) { + return member.EmptyUserWriter{}, member.EmptyGroupWriter{}, nil + }, nil) } func NewWithMembers(ctx context.Context, cfg config.Config, logger *slog.Logger, factory MemberWriters, registrars ...AdminRegistrar) (*App, error) { @@ -140,10 +142,8 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo func newApplicationHandlerWithMembers(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, cfg config.Config, users member.UserWriter, groups member.GroupWriter, registrars []AdminRegistrar) (http.Handler, error) { settings := setting.NewService(setting.NewPostgres(pool)) identities := identity.NewService(pool, settings, cfg.PublicURL).WithEmailSender(settings) - if users != nil { - identities.WithUserWriter(users) - } - if err := identities.EnsureInitialAdmin(ctx, cfg.InitialAdminName, cfg.InitialAdminEmail, cfg.InitialAdminPassword); err != nil { + identities.WithUserWriter(users) + if err := identities.EnsureInitialAdmin(ctx, cfg.InitialAdminName, cfg.InitialAdminEmail, cfg.InitialAdminPassword); err != nil && !errors.Is(err, member.ErrWriterUnavailable) { return nil, fmt.Errorf("初始化管理员: %w", err) } charges := billing.NewService(pool) @@ -196,10 +196,7 @@ func newApplicationHandlerWithMembers(ctx context.Context, logger *slog.Logger, })) audits.RegisterAdmin(admin) identities.RegisterAdmin(admin) - groupService := group.NewService(pool).WithAccountPreserver(charges) - if groups != nil { - groupService.WithMemberWriter(groups) - } + groupService := group.NewService(pool).WithAccountPreserver(charges).WithMemberWriter(groups) groupService.RegisterAdmin(admin) for _, registrar := range registrars { if registrar != nil { diff --git a/monkeyai/backend/internal/group/move.go b/monkeyai/backend/internal/group/move.go index 2c8f316f3..b754d3d20 100644 --- a/monkeyai/backend/internal/group/move.go +++ b/monkeyai/backend/internal/group/move.go @@ -197,23 +197,8 @@ func moveMembers(ctx context.Context, tx pgx.Tx, writer member.GroupWriter, acto changed[*target] = true } } - if writer != nil { - if err := writer.MoveMembers(ctx, tx, input); err != nil { - return err - } - } else { - for _, entry := range members { - if entry.SourceGroupID != nil && *entry.SourceGroupID != rootgroup.ID { - if _, err := queries.RemoveMember(ctx, sqlc.RemoveMemberParams{GroupID: *entry.SourceGroupID, UserID: entry.ID}); err != nil { - return err - } - } - if target != nil { - if _, err := queries.AddMember(ctx, sqlc.AddMemberParams{GroupID: *target, UserID: entry.ID, AssignedByUserID: actor}); err != nil { - return err - } - } - } + if err := writer.MoveMembers(ctx, tx, input); err != nil { + return err } for id := range changed { if _, err := queries.TouchGroup(ctx, id); err != nil { diff --git a/monkeyai/backend/internal/group/service.go b/monkeyai/backend/internal/group/service.go index 54dfa3bdd..49ca34be1 100644 --- a/monkeyai/backend/internal/group/service.go +++ b/monkeyai/backend/internal/group/service.go @@ -43,6 +43,9 @@ type Service struct { } func (s *Service) WithMemberWriter(writer member.GroupWriter) *Service { + if writer == nil { + writer = member.EmptyGroupWriter{} + } s.members = writer return s } @@ -52,7 +55,9 @@ func (s *Service) WithAccountPreserver(accounts AccountPreserver) *Service { return s } -func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } +func NewService(pool *pgxpool.Pool) *Service { + return &Service{pool: pool, members: member.EmptyGroupWriter{}} +} func present(group Group) Group { group.ParentID = rootgroup.ParentID(group.ParentID) @@ -227,14 +232,7 @@ func (s *Service) SetMembers(ctx context.Context, actor, id string, ids []string if count != len(ids) { return Group{}, resource.Invalid("所选成员不存在或已删除") } - if s.members != nil { - err = s.members.SetMembers(ctx, tx, member.GroupMembers{ActorID: actor, GroupID: id, UserIDs: ids}) - } else { - _, err = sqlc.New(tx).RemoveMembers(ctx, sqlc.RemoveMembersParams{GroupID: id, UserIds: ids}) - if err == nil { - _, err = sqlc.New(tx).AddMembers(ctx, sqlc.AddMembersParams{GroupID: id, UserIds: ids, AssignedByUserID: actor}) - } - } + err = s.members.SetMembers(ctx, tx, member.GroupMembers{ActorID: actor, GroupID: id, UserIDs: ids}) if err != nil { return Group{}, err } @@ -273,11 +271,7 @@ func (s *Service) Delete(ctx context.Context, actor, id string) error { if _, err = sqlc.New(tx).DeleteGroup(ctx, id); err != nil { return err } - if s.members != nil { - err = s.members.RemoveAllMembers(ctx, tx, id) - } else { - _, err = sqlc.New(tx).RemoveAllMembers(ctx, id) - } + err = s.members.RemoveAllMembers(ctx, tx, id) if err != nil { return err } diff --git a/monkeyai/backend/internal/identity/admin.go b/monkeyai/backend/internal/identity/admin.go index b60ff72df..d9c2e3f8b 100644 --- a/monkeyai/backend/internal/identity/admin.go +++ b/monkeyai/backend/internal/identity/admin.go @@ -68,8 +68,8 @@ func (s *Service) patchUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "user_not_found", "用户不存在") case errors.Is(err, member.ErrSeatsExceeded): writeError(w, http.StatusConflict, "seats_exceeded", "成员席位已满") - case errors.Is(err, member.ErrSeatsUnavailable): - writeError(w, http.StatusServiceUnavailable, "seats_unavailable", "成员席位授权不可用") + case errors.Is(err, member.ErrSeatsUnavailable), errors.Is(err, member.ErrWriterUnavailable): + writeError(w, http.StatusServiceUnavailable, "member_writer_unavailable", "成员写入能力不可用") default: slog.ErrorContext(r.Context(), "更新用户失败", "user_id", chi.URLParam(r, "userID"), "error", err) writeError(w, http.StatusInternalServerError, "server_error", "更新用户失败") @@ -116,14 +116,14 @@ func (s *Service) resetUserPassword(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "server_error", "重置密码失败") return } - if s.writer != nil { - err = s.writer.ResetUserPassword(ctx, tx, user.ID, hash) - } else { - _, err = q.ResetUserPassword(ctx, sqlc.ResetUserPasswordParams{ID: user.ID, PasswordHash: &hash}) - } + err = s.writer.ResetUserPassword(ctx, tx, user.ID, hash) if err != nil { - slog.ErrorContext(ctx, "更新用户密码失败", "user_id", user.ID, "error", err) - writeError(w, http.StatusInternalServerError, "server_error", "重置密码失败") + if errors.Is(err, member.ErrWriterUnavailable) { + writeError(w, http.StatusServiceUnavailable, "member_writer_unavailable", "成员写入能力不可用") + } else { + slog.ErrorContext(ctx, "更新用户密码失败", "user_id", user.ID, "error", err) + writeError(w, http.StatusInternalServerError, "server_error", "重置密码失败") + } return } if err := revokePasswordAccess(ctx, q, user.ID, user.Email); err != nil { @@ -167,39 +167,23 @@ func (s *Service) createUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - var passwordHash *string - if input.Role == "admin" { - if len(input.Password) < 12 { - writeError(w, http.StatusBadRequest, "invalid_request", "管理员密码不能少于 12 个字符") - return - } - hash, err := hashPassword(input.Password) - if err != nil { - writeError(w, http.StatusInternalServerError, "server_error", "创建用户失败") - return - } - passwordHash = &hash + if input.Role == "admin" && len(input.Password) < 12 { + writeError(w, http.StatusBadRequest, "invalid_request", "管理员密码不能少于 12 个字符") + return } actor, _ := UserFromContext(r.Context()) - var user User - if s.writer != nil { - result, writeErr := s.writer.CreateUser(r.Context(), member.CreateUser{ - ActorID: actor.ID, Name: input.Name, Email: input.Email, - Role: input.Role, Password: input.Password, GroupIDs: groupIDs, - }) - user, err = userFromMember(result), writeErr - } else { - user, err = s.insertUserWithGroups(r.Context(), actor.ID, sqlc.CreateUserParams{ - Name: input.Name, Email: input.Email, Role: input.Role, PasswordHash: passwordHash, - }, groupIDs) - } + result, writeErr := s.writer.CreateUser(r.Context(), member.CreateUser{ + ActorID: actor.ID, Name: input.Name, Email: input.Email, + Role: input.Role, Password: input.Password, GroupIDs: groupIDs, + }) + user, err := userFromMember(result), writeErr if err != nil { var dbError *pgconn.PgError switch { case errors.Is(err, member.ErrSeatsExceeded): writeError(w, http.StatusConflict, "seats_exceeded", "成员席位已满") - case errors.Is(err, member.ErrSeatsUnavailable): - writeError(w, http.StatusServiceUnavailable, "seats_unavailable", "成员席位授权不可用") + case errors.Is(err, member.ErrSeatsUnavailable), errors.Is(err, member.ErrWriterUnavailable): + writeError(w, http.StatusServiceUnavailable, "member_writer_unavailable", "成员写入能力不可用") case errors.Is(err, errCreationGroupUnavailable), errors.Is(err, member.ErrGroupUnavailable): writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) case errors.As(err, &dbError) && dbError.Code == "23505" && dbError.ConstraintName == "users_email_active_key": diff --git a/monkeyai/backend/internal/identity/admincreate.go b/monkeyai/backend/internal/identity/admincreate.go index 5699e89a9..0ca62ab43 100644 --- a/monkeyai/backend/internal/identity/admincreate.go +++ b/monkeyai/backend/internal/identity/admincreate.go @@ -1,13 +1,9 @@ package identity import ( - "context" "errors" - "github.com/jackc/pgx/v5" - "log/slog" "slices" - "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity/sqlc" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/rootgroup" "github.com/jackc/pgx/v5/pgtype" ) @@ -35,49 +31,3 @@ func normalizeCreationGroups(ids []string) ([]string, error) { slices.Sort(normalized) return slices.Compact(normalized), nil } - -// The caller validates and canonicalizes groupIDs before starting the transaction. -func (s *Service) insertUserWithGroups(ctx context.Context, actor string, input sqlc.CreateUserParams, groupIDs []string) (User, error) { - tx, err := s.db.Begin(ctx) - if err != nil { - return User{}, err - } - defer func() { - if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) && ctx.Err() == nil { - slog.ErrorContext(ctx, "回滚创建用户事务失败", "actor_id", actor, "error", err) - } - }() - q := sqlc.New(tx) - if len(groupIDs) > 0 { - // Coordinate with group deletion and membership replacement so validation - // and assignment cannot observe different group states. - if err := q.LockUserCreationGroups(ctx); err != nil { - return User{}, err - } - groups, err := q.GetUserCreationGroups(ctx, groupIDs) - if err != nil { - return User{}, err - } - if len(groups) != len(groupIDs) { - return User{}, errCreationGroupUnavailable - } - } - row, err := q.CreateUser(ctx, input) - if err != nil { - return User{}, err - } - if len(groupIDs) > 0 { - if err := q.AssignCreatedUserGroups(ctx, sqlc.AssignCreatedUserGroupsParams{GroupIds: groupIDs, UserID: row.ID, ActorID: actor}); err != nil { - return User{}, err - } - if err := q.TouchUserCreationGroups(ctx, groupIDs); err != nil { - return User{}, err - } - } - // This user has no prior billing account to preserve. Its initial group - // membership becomes visible atomically with the user, before any allocation. - if err := tx.Commit(ctx); err != nil { - return User{}, err - } - return User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt}, nil -} diff --git a/monkeyai/backend/internal/identity/email.go b/monkeyai/backend/internal/identity/email.go index 46c881baa..8f09f257d 100644 --- a/monkeyai/backend/internal/identity/email.go +++ b/monkeyai/backend/internal/identity/email.go @@ -292,37 +292,32 @@ func (s *Service) completeEmail(w http.ResponseWriter, r *http.Request, purpose } var id string var resetErr error - if s.writer != nil { - id, resetErr = s.writer.ResetPassword(ctx, tx, member.PasswordReset{Email: input.Email, PasswordHash: hash}) - } else { - id, resetErr = q.ResetPassword(ctx, sqlc.ResetPasswordParams{Email: input.Email, PasswordHash: &hash}) - } + id, resetErr = s.writer.ResetPassword(ctx, tx, member.PasswordReset{Email: input.Email, PasswordHash: hash}) if resetErr == nil { resetErr = revokePasswordAccess(ctx, q, id, input.Email) } if resetErr != nil { - if !errors.Is(resetErr, pgx.ErrNoRows) { - slog.ErrorContext(ctx, "重置密码并撤销旧凭据失败", "error", resetErr) + if errors.Is(resetErr, member.ErrWriterUnavailable) { + writeError(w, http.StatusServiceUnavailable, "member_writer_unavailable", "成员写入能力不可用") + } else { + if !errors.Is(resetErr, pgx.ErrNoRows) { + slog.ErrorContext(ctx, "重置密码并撤销旧凭据失败", "error", resetErr) + } + writeError(w, 400, "reset_failed", "密码重置失败,请重新获取验证码") } - writeError(w, 400, "reset_failed", "密码重置失败,请重新获取验证码") return } default: row, lookupErr := q.GetUserByEmail(ctx, input.Email) admin := strings.HasPrefix(r.URL.Path, "/admin/") || strings.Contains(r.URL.Path, "/v1/admin/") if errors.Is(lookupErr, pgx.ErrNoRows) && methods.EmailCodeAutoRegistrationEnabled && !admin { - var createErr error - if s.writer != nil { - _, createErr = s.writer.RegisterEmailUser(ctx, tx, input.Email) - } else { - createErr = q.CreateEmailUser(ctx, sqlc.CreateEmailUserParams{Name: input.Email, Email: input.Email}) - } + _, createErr := s.writer.RegisterEmailUser(ctx, tx, input.Email) if createErr != nil { switch { case errors.Is(createErr, member.ErrSeatsExceeded): writeError(w, http.StatusConflict, "seats_exceeded", "成员席位已满") - case errors.Is(createErr, member.ErrSeatsUnavailable): - writeError(w, http.StatusServiceUnavailable, "seats_unavailable", "成员席位授权不可用") + case errors.Is(createErr, member.ErrSeatsUnavailable), errors.Is(createErr, member.ErrWriterUnavailable): + writeError(w, http.StatusServiceUnavailable, "member_writer_unavailable", "成员写入能力不可用") default: slog.ErrorContext(ctx, "自动注册邮件登录用户失败", "error", createErr) writeError(w, http.StatusInternalServerError, "server_error", "创建账号失败") diff --git a/monkeyai/backend/internal/identity/oauth.go b/monkeyai/backend/internal/identity/oauth.go index e19ffa982..6af124bd9 100644 --- a/monkeyai/backend/internal/identity/oauth.go +++ b/monkeyai/backend/internal/identity/oauth.go @@ -295,6 +295,8 @@ func (s *Service) upstreamCallback(w http.ResponseWriter, r *http.Request) { code = "seats_exceeded" case errors.Is(err, member.ErrSeatsUnavailable): code = "seats_unavailable" + case errors.Is(err, member.ErrWriterUnavailable): + code = "member_writer_unavailable" } http.Redirect(w, r, s.upstreamResultURL(state, code), http.StatusFound) return diff --git a/monkeyai/backend/internal/identity/password.go b/monkeyai/backend/internal/identity/password.go index 9f17216c8..0c74bef25 100644 --- a/monkeyai/backend/internal/identity/password.go +++ b/monkeyai/backend/internal/identity/password.go @@ -9,7 +9,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "log/slog" "math/big" "net/http" @@ -53,45 +52,7 @@ func generatePassword() (string, error) { } func (s *Service) EnsureInitialAdmin(ctx context.Context, name, email, password string) error { - if s.writer != nil { - return s.writer.EnsureInitialAdmin(ctx, member.InitialAdmin{Name: name, Email: email, Password: password}) - } - tx, err := s.db.Begin(ctx) - if err != nil { - return err - } - defer func() { - if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) && ctx.Err() == nil { - slog.ErrorContext(ctx, "回滚初始化管理员事务失败", "error", err) - } - }() - if _, err := sqlc.New(tx).LockInitialAdmin(ctx); err != nil { - return err - } - var count int - record, err := sqlc.New(tx).CountUsers(ctx) - if err != nil { - return err - } - count = int(record) - - if count > 0 { - return tx.Commit(ctx) - } - email = strings.ToLower(strings.TrimSpace(email)) - name = strings.TrimSpace(name) - if name == "" || !validEmail(email) || len(password) < 12 { - return errors.New("用户表为空,必须配置有效的首次管理员姓名、邮箱和密码") - } - hash, err := hashPassword(password) - if err != nil { - return err - } - _, err = sqlc.New(tx).CreateInitialAdmin(ctx, sqlc.CreateInitialAdminParams{Name: name, Email: email, PasswordHash: new(hash)}) - if err != nil { - return fmt.Errorf("创建首次管理员: %w", err) - } - return tx.Commit(ctx) + return s.writer.EnsureInitialAdmin(ctx, member.InitialAdmin{Name: name, Email: email, Password: password}) } func (s *Service) passwordLogin(w http.ResponseWriter, r *http.Request) { @@ -140,12 +101,7 @@ func (s *Service) passwordLogin(w http.ResponseWriter, r *http.Request) { } func (s *Service) loginSession(w http.ResponseWriter, r *http.Request, user User, method string) { - var err error - if s.writer != nil { - err = s.writer.TouchLogin(r.Context(), user.ID) - } else { - _, err = sqlc.New(s.db).TouchLogin(r.Context(), user.ID) - } + err := s.writer.TouchLogin(r.Context(), user.ID) if err != nil { slog.ErrorContext(r.Context(), "更新登录时间失败", "user_id", user.ID, "error", err) writeError(w, http.StatusInternalServerError, "server_error", "登录失败") diff --git a/monkeyai/backend/internal/identity/postgres.go b/monkeyai/backend/internal/identity/postgres.go index fb44d6a3f..00c2dd547 100644 --- a/monkeyai/backend/internal/identity/postgres.go +++ b/monkeyai/backend/internal/identity/postgres.go @@ -216,16 +216,6 @@ func (s *Service) listUsers(ctx context.Context) ([]User, error) { return users, nil } -func (s *Service) insertUser(ctx context.Context, name, email, role, passwordHash string) (User, error) { - var password *string - if passwordHash != "" { - password = new(passwordHash) - } - - row, queryErr := sqlc.New(s.db).CreateUser(ctx, sqlc.CreateUserParams{Name: name, Email: email, Role: role, PasswordHash: password}) - return User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt}, queryErr -} - func (s *Service) userByID(ctx context.Context, id string) (User, error) { row, queryErr := sqlc.New(s.db).GetUser(ctx, id) return User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt}, queryErr @@ -246,20 +236,8 @@ func (s *Service) updateUser(ctx context.Context, id, name, role, status, passwo return User{}, err } } - var user User - if s.writer != nil { - var result member.User - result, err = s.writer.UpdateUser(ctx, tx, member.UpdateUser{ID: id, Name: name, Role: role, Status: status, PasswordHash: passwordHash}) - user = userFromMember(result) - } else { - var disabledAt *time.Time - if status == "disabled" { - disabledAt = new(s.now()) - } - row, queryErr := sqlc.New(tx).UpdateUser(ctx, sqlc.UpdateUserParams{ID: id, Name: name, Role: role, Status: status, DisabledAt: disabledAt, PasswordHash: passwordHash}) - err = queryErr - user = User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt} - } + result, err := s.writer.UpdateUser(ctx, tx, member.UpdateUser{ID: id, Name: name, Role: role, Status: status, PasswordHash: passwordHash}) + user := userFromMember(result) if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrNotFound } @@ -270,118 +248,12 @@ func (s *Service) updateUser(ctx context.Context, id, name, role, status, passwo } func (s *Service) upsertIdentity(ctx context.Context, profile upstreamProfile, adminOnly, autoRegistrationEnabled bool) (User, error) { - if s.writer != nil { - result, err := s.writer.UpsertIdentity(ctx, member.OAuthIdentity{ - Provider: profile.Provider, Issuer: profile.Issuer, Subject: profile.Subject, - Username: profile.Username, Name: profile.Name, Email: profile.Email, AvatarURL: profile.AvatarURL, - AdminOnly: adminOnly, AutoRegistrationEnabled: autoRegistrationEnabled, - }) - return userFromMember(result), err - } - tx, err := s.db.Begin(ctx) - if err != nil { - return User{}, err - } - defer func() { - if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) && ctx.Err() == nil { - slog.ErrorContext(ctx, "回滚上游身份事务失败", "provider", profile.Provider, "error", err) - } - }() - - identityRow, err := sqlc.New(tx).GetIdentityUser(ctx, sqlc.GetIdentityUserParams{Provider: profile.Provider, Issuer: profile.Issuer, ProviderSubject: profile.Subject}) - user := User{ID: identityRow.ID, Name: identityRow.Name, Email: identityRow.Email, AvatarURL: identityRow.AvatarUrl, Role: identityRow.Role, Status: identityRow.Status, JoinedAt: identityRow.JoinedAt, LastLoginAt: identityRow.LastLoginAt} - if err == nil { - if err := validateUpstreamUser(user, adminOnly); err != nil { - return User{}, err - } - - if profile.Provider == "baizhiyun" && profile.Email != "" { - if err := sqlc.New(tx).UpdateBaizhiyunEmail(ctx, sqlc.UpdateBaizhiyunEmailParams{UserID: user.ID, Issuer: profile.Issuer, ProviderSubject: profile.Subject, Email: profile.Email}); err != nil { - return User{}, err - } - } - - var record sqlc.UpdateIdentityUserRow - record, err = sqlc.New(tx).UpdateIdentityUser(ctx, sqlc.UpdateIdentityUserParams{ID: user.ID, Name: profile.Name, AvatarUrl: profile.AvatarURL}) - - if err != nil { - return User{}, err - } - user.ID, user.Name, user.Email, user.AvatarURL, user.Role, user.Status, user.JoinedAt, user.LastLoginAt = record.ID, record.Name, record.Email, record.AvatarUrl, record.Role, record.Status, record.JoinedAt, record.LastLoginAt - - if err := tx.Commit(ctx); err != nil { - return User{}, err - } - return user, nil - } - if !errors.Is(err, pgx.ErrNoRows) { - return User{}, err - } - - if profile.Email == "" { - if adminOnly { - return User{}, ErrAdminRoleRequired - } - profile.Email = fmt.Sprintf("%s@%s.oauth.local", profile.Subject, profile.Provider) - } - if profile.Name == "" { - profile.Name = profile.Username - } - if profile.Name == "" { - profile.Name = profile.Email - } - emailRow, err := sqlc.New(tx).GetUserByEmail(ctx, profile.Email) - user = User{ID: emailRow.ID, Name: emailRow.Name, Email: emailRow.Email, AvatarURL: emailRow.AvatarUrl, Role: emailRow.Role, Status: emailRow.Status, JoinedAt: emailRow.JoinedAt, LastLoginAt: emailRow.LastLoginAt} - switch { - case err == nil: - if err := validateUpstreamUser(user, adminOnly); err != nil { - return User{}, err - } - var row sqlc.UpdateIdentityUserRow - row, err = sqlc.New(tx).UpdateIdentityUser(ctx, sqlc.UpdateIdentityUserParams{ID: user.ID, Name: profile.Name, AvatarUrl: profile.AvatarURL}) - if err == nil { - user.ID, user.Name, user.Email, user.AvatarURL, user.Role, user.Status, user.JoinedAt, user.LastLoginAt = row.ID, row.Name, row.Email, row.AvatarUrl, row.Role, row.Status, row.JoinedAt, row.LastLoginAt - } - if err != nil { - return User{}, err - } - case errors.Is(err, pgx.ErrNoRows): - if adminOnly { - return User{}, ErrAdminRoleRequired - } - if !autoRegistrationEnabled { - return User{}, ErrRegistrationDisabled - } - var row sqlc.CreateIdentityUserRow - row, err = sqlc.New(tx).CreateIdentityUser(ctx, sqlc.CreateIdentityUserParams{Name: profile.Name, Email: profile.Email, AvatarUrl: profile.AvatarURL}) - if err == nil { - user.ID, user.Name, user.Email, user.AvatarURL, user.Role, user.Status, user.JoinedAt, user.LastLoginAt = row.ID, row.Name, row.Email, row.AvatarUrl, row.Role, row.Status, row.JoinedAt, row.LastLoginAt - } - if errors.Is(err, pgx.ErrNoRows) { - return User{}, ErrUserDisabled - } - if err != nil { - return User{}, err - } - case err != nil: - return User{}, err - } - _, err = sqlc.New(tx).UpsertIdentity(ctx, sqlc.UpsertIdentityParams{ - UserID: user.ID, - Provider: profile.Provider, - Issuer: profile.Issuer, - ProviderSubject: profile.Subject, - ProviderUsername: profile.Username, - ProviderEmail: profile.Email, - ProviderAvatarUrl: profile.AvatarURL, + result, err := s.writer.UpsertIdentity(ctx, member.OAuthIdentity{ + Provider: profile.Provider, Issuer: profile.Issuer, Subject: profile.Subject, + Username: profile.Username, Name: profile.Name, Email: profile.Email, AvatarURL: profile.AvatarURL, + AdminOnly: adminOnly, AutoRegistrationEnabled: autoRegistrationEnabled, }) - if err != nil { - return User{}, err - } - if err := tx.Commit(ctx); err != nil { - return User{}, err - } - return user, nil + return userFromMember(result), err } func validateUpstreamUser(user User, adminOnly bool) error { diff --git a/monkeyai/backend/internal/identity/service.go b/monkeyai/backend/internal/identity/service.go index d5af23a89..b70f89b1e 100644 --- a/monkeyai/backend/internal/identity/service.go +++ b/monkeyai/backend/internal/identity/service.go @@ -114,6 +114,9 @@ type AccountPreserver interface { func (s *Service) WithAccountPreserver(p AccountPreserver) *Service { s.accounts = p; return s } func (s *Service) WithUserWriter(writer member.UserWriter) *Service { + if writer == nil { + writer = member.EmptyUserWriter{} + } s.writer = writer return s } @@ -148,6 +151,7 @@ func NewService(db *pgxpool.Pool, settings SettingReader, publicURL string) *Ser codeTTL: 2 * time.Minute, accessTTL: time.Hour, refreshTTL: 30 * 24 * time.Hour, + writer: member.EmptyUserWriter{}, } } diff --git a/monkeyai/backend/internal/identity/test_helpers_test.go b/monkeyai/backend/internal/identity/test_helpers_test.go new file mode 100644 index 000000000..752e2bb15 --- /dev/null +++ b/monkeyai/backend/internal/identity/test_helpers_test.go @@ -0,0 +1,18 @@ +package identity + +import ( + "context" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity/sqlc" +) + +func (s *Service) insertUser(ctx context.Context, name, email, role, passwordHash string) (User, error) { + var password *string + if passwordHash != "" { + password = new(passwordHash) + } + row, err := sqlc.New(s.db).CreateUser(ctx, sqlc.CreateUserParams{ + Name: name, Email: email, Role: role, PasswordHash: password, + }) + return User{ID: row.ID, Name: row.Name, Email: row.Email, AvatarURL: row.AvatarUrl, Role: row.Role, Status: row.Status, JoinedAt: row.JoinedAt, LastLoginAt: row.LastLoginAt}, err +} diff --git a/monkeyai/backend/member/empty.go b/monkeyai/backend/member/empty.go new file mode 100644 index 000000000..89335363e --- /dev/null +++ b/monkeyai/backend/member/empty.go @@ -0,0 +1,58 @@ +package member + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +var ErrWriterUnavailable = errors.New("成员写入实现不可用") + +type EmptyUserWriter struct{} + +func (EmptyUserWriter) EnsureInitialAdmin(context.Context, InitialAdmin) error { + return ErrWriterUnavailable +} + +func (EmptyUserWriter) CreateUser(context.Context, CreateUser) (User, error) { + return User{}, ErrWriterUnavailable +} + +func (EmptyUserWriter) UpdateUser(context.Context, pgx.Tx, UpdateUser) (User, error) { + return User{}, ErrWriterUnavailable +} + +func (EmptyUserWriter) RegisterEmailUser(context.Context, pgx.Tx, string) (User, error) { + return User{}, ErrWriterUnavailable +} + +func (EmptyUserWriter) UpsertIdentity(context.Context, OAuthIdentity) (User, error) { + return User{}, ErrWriterUnavailable +} + +func (EmptyUserWriter) ResetPassword(context.Context, pgx.Tx, PasswordReset) (string, error) { + return "", ErrWriterUnavailable +} + +func (EmptyUserWriter) ResetUserPassword(context.Context, pgx.Tx, string, string) error { + return ErrWriterUnavailable +} + +func (EmptyUserWriter) TouchLogin(context.Context, string) error { + return ErrWriterUnavailable +} + +type EmptyGroupWriter struct{} + +func (EmptyGroupWriter) SetMembers(context.Context, pgx.Tx, GroupMembers) error { + return ErrWriterUnavailable +} + +func (EmptyGroupWriter) MoveMembers(context.Context, pgx.Tx, MoveMembers) error { + return ErrWriterUnavailable +} + +func (EmptyGroupWriter) RemoveAllMembers(context.Context, pgx.Tx, string) error { + return ErrWriterUnavailable +} diff --git a/monkeyai/backend/member/empty_test.go b/monkeyai/backend/member/empty_test.go new file mode 100644 index 000000000..50e18fe46 --- /dev/null +++ b/monkeyai/backend/member/empty_test.go @@ -0,0 +1,116 @@ +package member + +import ( + "context" + "testing" +) + +var ( + _ UserWriter = EmptyUserWriter{} + _ GroupWriter = EmptyGroupWriter{} +) + +func TestEmptyUserWriter(t *testing.T) { + writer := EmptyUserWriter{} + tests := []struct { + name string + call func() error + }{ + { + name: "EnsureInitialAdmin", + call: func() error { + return writer.EnsureInitialAdmin(context.Background(), InitialAdmin{}) + }, + }, + { + name: "CreateUser", + call: func() error { + _, err := writer.CreateUser(context.Background(), CreateUser{}) + return err + }, + }, + { + name: "UpdateUser", + call: func() error { + _, err := writer.UpdateUser(context.Background(), nil, UpdateUser{}) + return err + }, + }, + { + name: "RegisterEmailUser", + call: func() error { + _, err := writer.RegisterEmailUser(context.Background(), nil, "") + return err + }, + }, + { + name: "UpsertIdentity", + call: func() error { + _, err := writer.UpsertIdentity(context.Background(), OAuthIdentity{}) + return err + }, + }, + { + name: "ResetPassword", + call: func() error { + _, err := writer.ResetPassword(context.Background(), nil, PasswordReset{}) + return err + }, + }, + { + name: "ResetUserPassword", + call: func() error { + return writer.ResetUserPassword(context.Background(), nil, "", "") + }, + }, + { + name: "TouchLogin", + call: func() error { + return writer.TouchLogin(context.Background(), "") + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err != ErrWriterUnavailable { + t.Fatalf("got error %v, want %v", err, ErrWriterUnavailable) + } + }) + } +} + +func TestEmptyGroupWriter(t *testing.T) { + writer := EmptyGroupWriter{} + tests := []struct { + name string + call func() error + }{ + { + name: "SetMembers", + call: func() error { + return writer.SetMembers(context.Background(), nil, GroupMembers{}) + }, + }, + { + name: "MoveMembers", + call: func() error { + return writer.MoveMembers(context.Background(), nil, MoveMembers{}) + }, + }, + { + name: "RemoveAllMembers", + call: func() error { + return writer.RemoveAllMembers(context.Background(), nil, "") + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err != ErrWriterUnavailable { + t.Fatalf("got error %v, want %v", err, ErrWriterUnavailable) + } + }) + } +}