From e6b1345f860ba6635c79eeeb6b07416814f888e4 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 28 Aug 2026 21:38:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=88=9B=E5=BB=BA=E7=BB=88=E7=AB=AF=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-ui/src/api/chorus/index.js | 2 + admin-ui/src/views/chorus/users/index.vue | 118 ++++++++++++++-- .../tests/unit/chorus/users-account.spec.js | 9 ++ admin/app/chorus/errors.go | 4 + admin/app/chorus/router.go | 10 ++ admin/app/chorus/router_test.go | 4 + admin/app/chorus/service.go | 127 ++++++++++++++++-- .../chorus/service_mysql_integration_test.go | 54 +++++++- admin/app/chorus/service_test.go | 27 ++++ admin/app/chorus/types.go | 12 ++ docs/02-architecture-and-code-map.md | 14 +- docs/03-business-rules-and-glossary.md | 14 +- docs/04-local-development-and-verification.md | 24 +++- docs/09-product-requirements-overview.md | 10 +- ...0011_admin_portal_user_management.down.sql | 18 +++ ...000011_admin_portal_user_management.up.sql | 17 +++ migrations/migrations_test.go | 29 ++++ migrations/mysql_integration_test.go | 7 + 18 files changed, 459 insertions(+), 41 deletions(-) create mode 100644 migrations/000011_admin_portal_user_management.down.sql create mode 100644 migrations/000011_admin_portal_user_management.up.sql diff --git a/admin-ui/src/api/chorus/index.js b/admin-ui/src/api/chorus/index.js index 0da4ad6..6508986 100644 --- a/admin-ui/src/api/chorus/index.js +++ b/admin-ui/src/api/chorus/index.js @@ -23,7 +23,9 @@ export const createRoutePool = data => request({ url: `${base}/route-pools`, met export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools/${id}`, method: 'put', data }) export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' }) +export const createPortalUser = data => request({ url: `${base}/users`, method: 'post', data }) export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }}) +export const resetPortalUserPassword = (id, password) => request({ url: `${base}/users/${id}/password`, method: 'put', data: { password }}) export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' }) export const getGeneration = id => request({ url: `${base}/generations/${id}`, method: 'get' }) export const getGenerationMedia = url => request({ url, method: 'get', responseType: 'blob' }) diff --git a/admin-ui/src/views/chorus/users/index.vue b/admin-ui/src/views/chorus/users/index.vue index 50679f5..72ee588 100644 --- a/admin-ui/src/views/chorus/users/index.vue +++ b/admin-ui/src/views/chorus/users/index.vue @@ -1,30 +1,120 @@ + + diff --git a/admin-ui/tests/unit/chorus/users-account.spec.js b/admin-ui/tests/unit/chorus/users-account.spec.js index e7dba2f..856039b 100644 --- a/admin-ui/tests/unit/chorus/users-account.spec.js +++ b/admin-ui/tests/unit/chorus/users-account.spec.js @@ -12,4 +12,13 @@ describe('portal user account column', () => { expect(source).toContain('[row.id, row.username, row.display_name, row.email]') expect(source).toContain('prop="email" label="邮箱"') }) + + test('supports controlled creation and password reset without public registration copy', () => { + expect(source).toContain('新增终端用户') + expect(source).toContain('createPortalUser') + expect(source).toContain('resetPortalUserPassword') + expect(source).toContain('autocomplete="new-password"') + expect(source).toContain('label="创建时间"') + expect(source).not.toContain('label="注册时间"') + }) }) diff --git a/admin/app/chorus/errors.go b/admin/app/chorus/errors.go index d5ede58..77bc344 100644 --- a/admin/app/chorus/errors.go +++ b/admin/app/chorus/errors.go @@ -16,6 +16,10 @@ type FieldError struct { Message string `json:"message"` } +type UserIdentityConflictError struct{ Field string } + +func (e UserIdentityConflictError) Error() string { return "portal user identity already exists" } + func (e FieldError) Error() string { return fmt.Sprintf("invalid %s: %s", e.Field, e.Message) } diff --git a/admin/app/chorus/router.go b/admin/app/chorus/router.go index ec0763c..5d02404 100644 --- a/admin/app/chorus/router.go +++ b/admin/app/chorus/router.go @@ -126,9 +126,15 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc r.GET("/users", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) { return service.Users(c.Request.Context()) })) + r.POST("/users", withBody[UserCreateInput](factory, func(c *gin.Context, service *Service, actor uint64, requestID string, input UserCreateInput) (any, error) { + return service.CreateUser(c.Request.Context(), actor, requestID, input) + })) r.PUT("/users/:id", withIDBody[UserStatusInput](factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64, input UserStatusInput) (any, error) { return service.UpdateUserStatus(c.Request.Context(), actor, requestID, id, input) })) + r.PUT("/users/:id/password", withIDBody[UserPasswordInput](factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64, input UserPasswordInput) (any, error) { + return service.ResetUserPassword(c.Request.Context(), actor, requestID, id, input) + })) r.GET("/generations", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) { return service.Generations(c.Request.Context()) })) @@ -382,11 +388,15 @@ func respond(c *gin.Context, data any, err error) { status, code, message := http.StatusInternalServerError, "internal_error", "request could not be completed" response := gin.H{"code": status, "error": code, "msg": message} var field FieldError + var userConflict UserIdentityConflictError var cooldown CooldownError switch { case errors.As(err, &field): status, code, message = http.StatusUnprocessableEntity, "validation_failed", "request fields are invalid" response["field"] = field.Field + case errors.As(err, &userConflict): + status, code, message = http.StatusConflict, "user_identity_conflict", "username or email is already in use" + response["field"] = userConflict.Field case errors.Is(err, ErrNotFound): status, code, message = http.StatusNotFound, "not_found", "resource was not found" case errors.Is(err, ErrConflict): diff --git a/admin/app/chorus/router_test.go b/admin/app/chorus/router_test.go index 3a397a9..74d0f91 100644 --- a/admin/app/chorus/router_test.go +++ b/admin/app/chorus/router_test.go @@ -43,6 +43,10 @@ func TestChorusRoutesRequireAuthenticationAuthorizationAndHaveNoDeleteEndpoint(t {name: "generation input not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/inputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, {name: "generation output not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, {name: "generation thumbnail not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1/thumbnail", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, + {name: "user create unauthenticated", method: http.MethodPost, path: "/api/v1/chorus/users", want: http.StatusUnauthorized}, + {name: "user create not authorized", method: http.MethodPost, path: "/api/v1/chorus/users", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, + {name: "password reset unauthenticated", method: http.MethodPut, path: "/api/v1/chorus/users/1/password", want: http.StatusUnauthorized}, + {name: "password reset not authorized", method: http.MethodPut, path: "/api/v1/chorus/users/1/password", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, {name: "delete is not exposed", method: http.MethodDelete, path: "/api/v1/chorus/providers/1", headers: map[string]string{"Authorization": "Bearer test", "X-Chorus-Role": "operator"}, want: http.StatusNotFound}, } { t.Run(test.name, func(t *testing.T) { diff --git a/admin/app/chorus/service.go b/admin/app/chorus/service.go index 3b48dae..f6588ba 100644 --- a/admin/app/chorus/service.go +++ b/admin/app/chorus/service.go @@ -7,14 +7,19 @@ import ( "fmt" "io" "math" + "net/mail" "net/url" + "regexp" "slices" "strings" "time" + "unicode/utf8" "git.ilapage.cn/OPC/chorus/internal/core/model" "git.ilapage.cn/OPC/chorus/internal/core/provider" corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" + passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" + mysqldriver "github.com/go-sql-driver/mysql" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -220,17 +225,20 @@ type auditRow struct { func (auditRow) TableName() string { return "admin_audit_events" } type portalUserRow struct { - ID uint64 `gorm:"column:id;primaryKey"` - Username string `gorm:"column:username"` - Email string `gorm:"column:email"` - DisplayName string `gorm:"column:display_name"` - Status string `gorm:"column:status"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + ID uint64 `gorm:"column:id;primaryKey"` + Username string `gorm:"column:username"` + Email string `gorm:"column:email"` + PasswordHash string `gorm:"column:password_hash"` + DisplayName string `gorm:"column:display_name"` + Status string `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` } func (portalUserRow) TableName() string { return "users" } +var portalUserPublicColumns = []string{"id", "username", "email", "display_name", "status", "created_at", "updated_at"} + type generationRow struct { ID uint64 `gorm:"column:id;primaryKey"` UserID uint64 `gorm:"column:user_id"` @@ -693,7 +701,7 @@ func (s *Service) RoutePool(ctx context.Context, id uint64) (RoutePoolView, erro func (s *Service) Users(ctx context.Context) ([]UserView, error) { var rows []portalUserRow - if err := s.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil { + if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).Order("id ASC").Find(&rows).Error; err != nil { return nil, fmt.Errorf("list users: %w", err) } items := make([]UserView, 0, len(rows)) @@ -703,6 +711,79 @@ func (s *Service) Users(ctx context.Context) ([]UserView, error) { return items, nil } +func (s *Service) CreateUser(ctx context.Context, actor uint64, requestID string, input UserCreateInput) (UserView, error) { + input.Username = strings.ToLower(strings.TrimSpace(input.Username)) + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Status = strings.TrimSpace(input.Status) + if err := validateUserCreate(input); err != nil { + return UserView{}, err + } + passwordHash, err := passwordpkg.Encode(input.Password) + if err != nil { + return UserView{}, FieldError{Field: "password", Message: "must be 6 to 1024 characters"} + } + row := portalUserRow{ + Username: input.Username, Email: input.Email, PasswordHash: passwordHash, + DisplayName: input.DisplayName, Status: input.Status, + } + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&portalUserRow{}).Where("username = ?", input.Username).Count(&count).Error; err != nil { + return fmt.Errorf("check portal username: %w", err) + } + if count > 0 { + return UserIdentityConflictError{Field: "username"} + } + if err := tx.Model(&portalUserRow{}).Where("email = ?", input.Email).Count(&count).Error; err != nil { + return fmt.Errorf("check portal user email: %w", err) + } + if count > 0 { + return UserIdentityConflictError{Field: "email"} + } + if err := tx.Create(&row).Error; err != nil { + var mysqlErr *mysqldriver.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return UserIdentityConflictError{Field: "username_or_email"} + } + return fmt.Errorf("create portal user: %w", err) + } + return s.audit(tx, actor, requestID, "user.create", "user", row.ID, "succeeded", map[string]any{ + "username": row.Username, "status": row.Status, + }) + }) + if err != nil { + return UserView{}, err + } + return portalUserView(row), nil +} + +func (s *Service) ResetUserPassword(ctx context.Context, actor uint64, requestID string, id uint64, input UserPasswordInput) (UserView, error) { + passwordHash, err := passwordpkg.Encode(input.Password) + if err != nil { + return UserView{}, FieldError{Field: "password", Message: "must be 6 to 1024 characters"} + } + var row portalUserRow + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Select(portalUserPublicColumns).Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { + return translateNotFound(err) + } + if err := tx.Model(&portalUserRow{}).Where("id = ?", id).Update("password_hash", passwordHash).Error; err != nil { + return fmt.Errorf("reset portal user password: %w", err) + } + return s.audit(tx, actor, requestID, "user.password.reset", "user", id, "succeeded", map[string]any{ + "username": row.Username, + }) + }) + if err != nil { + return UserView{}, err + } + if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).First(&row, id).Error; err != nil { + return UserView{}, translateNotFound(err) + } + return portalUserView(row), nil +} + func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID string, id uint64, input UserStatusInput) (UserView, error) { input.Status = strings.TrimSpace(input.Status) if input.Status != "active" && input.Status != "disabled" { @@ -710,7 +791,7 @@ func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID } var row portalUserRow err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { + if err := tx.Select(portalUserPublicColumns).Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { return translateNotFound(err) } if err := tx.Model(&portalUserRow{}).Where("id = ?", id).Update("status", input.Status).Error; err != nil { @@ -722,7 +803,7 @@ func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID if err != nil { return UserView{}, err } - if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).First(&row, id).Error; err != nil { return UserView{}, translateNotFound(err) } return portalUserView(row), nil @@ -762,7 +843,7 @@ func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) { users := make(map[uint64]portalUserRow, len(userIDs)) if len(userIDs) > 0 { var userRows []portalUserRow - if err := s.db.WithContext(ctx).Where("id IN ?", userIDs).Find(&userRows).Error; err != nil { + if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).Where("id IN ?", userIDs).Find(&userRows).Error; err != nil { return nil, fmt.Errorf("list generation users: %w", err) } for _, row := range userRows { @@ -866,7 +947,7 @@ func (s *Service) Generation(ctx context.Context, id uint64) (GenerationDetailVi func (s *Service) generationView(ctx context.Context, row generationRow) (GenerationView, error) { var user portalUserRow - if err := s.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).First(&user, row.UserID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return GenerationView{}, fmt.Errorf("get generation user: %w", err) } attempts := parseGenerationAttempts(row.Attempts) @@ -1388,6 +1469,28 @@ func validateProvider(input ProviderInput, creating bool) error { return nil } +var portalUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`) + +func validateUserCreate(input UserCreateInput) error { + if !portalUsernamePattern.MatchString(input.Username) { + return FieldError{Field: "username", Message: "must be 3 to 64 lowercase letters, digits, dots, underscores, or hyphens and start with a letter or digit"} + } + parsedEmail, err := mail.ParseAddress(input.Email) + if err != nil || parsedEmail.Address != input.Email || len(input.Email) > 320 { + return FieldError{Field: "email", Message: "must be a valid email address up to 320 characters"} + } + if utf8.RuneCountInString(input.DisplayName) < 1 || utf8.RuneCountInString(input.DisplayName) > 120 { + return FieldError{Field: "display_name", Message: "must be 1 to 120 characters"} + } + if input.Status != "active" && input.Status != "disabled" { + return FieldError{Field: "status", Message: "must be active or disabled"} + } + if len(input.Password) < 6 || len(input.Password) > 1024 { + return FieldError{Field: "password", Message: "must be 6 to 1024 characters"} + } + return nil +} + func validateBaseURL(raw string) error { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { diff --git a/admin/app/chorus/service_mysql_integration_test.go b/admin/app/chorus/service_mysql_integration_test.go index 026e5a3..6c27474 100644 --- a/admin/app/chorus/service_mysql_integration_test.go +++ b/admin/app/chorus/service_mysql_integration_test.go @@ -15,6 +15,7 @@ import ( "time" "git.ilapage.cn/OPC/chorus/internal/core/model" + passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" "github.com/gin-gonic/gin" jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" mysqldriver "github.com/go-sql-driver/mysql" @@ -123,15 +124,49 @@ func TestAdminServiceMySQL(t *testing.T) { } userEmail := "user-" + suffix + "@chorus.invalid" userUsername := "user-" + suffix - if err := db.Exec("INSERT INTO users (username, email, password_hash, display_name, status) VALUES (?, ?, 'synthetic', 'Admin Test User', 'active')", userUsername, userEmail).Error; err != nil { - t.Fatalf("create user status fixture: %v", err) + initialPassword := "initial-" + suffix + createdUser, err := service.CreateUser(ctx, 7, "user-create-"+suffix, UserCreateInput{ + Username: userUsername, Email: userEmail, DisplayName: "Admin Test User", + Password: initialPassword, Status: "active", + }) + if err != nil || createdUser.Username != userUsername || createdUser.Status != "active" { + t.Fatalf("created user=%#v error=%v", createdUser, err) } defer db.Exec("DELETE FROM users WHERE email = ?", userEmail) - var userID uint64 - if err := db.Raw("SELECT id FROM users WHERE email = ?", userEmail).Scan(&userID).Error; err != nil || userID == 0 { - t.Fatalf("user fixture id=%d error=%v", userID, err) + if _, err := service.CreateUser(ctx, 7, "user-duplicate-"+suffix, UserCreateInput{ + Username: userUsername, Email: "other-" + userEmail, DisplayName: "Duplicate User", + Password: initialPassword, Status: "active", + }); !isUserIdentityConflict(err) { + t.Fatalf("duplicate user error = %v, want identity conflict", err) } - updatedUser, err := service.UpdateUserStatus(ctx, 7, "user-status-"+suffix, userID, UserStatusInput{Status: "disabled"}) + var storedHash string + if err := db.Raw("SELECT password_hash FROM users WHERE id = ?", createdUser.ID).Scan(&storedHash).Error; err != nil { + t.Fatalf("read created user password hash: %v", err) + } + if !passwordpkg.Verify(storedHash, initialPassword) || strings.Contains(storedHash, initialPassword) { + t.Fatal("created portal user password is not stored in the compatible encoded form") + } + newPassword := "replacement-" + suffix + resetUser, err := service.ResetUserPassword(ctx, 7, "user-password-"+suffix, createdUser.ID, UserPasswordInput{Password: newPassword}) + if err != nil || resetUser.ID != createdUser.ID { + t.Fatalf("reset user=%#v error=%v", resetUser, err) + } + if err := db.Raw("SELECT password_hash FROM users WHERE id = ?", createdUser.ID).Scan(&storedHash).Error; err != nil { + t.Fatalf("read reset user password hash: %v", err) + } + if passwordpkg.Verify(storedHash, initialPassword) || !passwordpkg.Verify(storedHash, newPassword) { + t.Fatal("password reset did not invalidate the old password and activate the new password") + } + var userAudits []string + if err := db.Raw("SELECT CAST(summary AS CHAR) FROM admin_audit_events WHERE target_type = 'user' AND target_id = ?", fmt.Sprint(createdUser.ID)).Scan(&userAudits).Error; err != nil { + t.Fatalf("read user audit events: %v", err) + } + for _, summary := range userAudits { + if strings.Contains(summary, initialPassword) || strings.Contains(summary, newPassword) || strings.Contains(summary, storedHash) { + t.Fatalf("user audit contains protected password data: %s", summary) + } + } + updatedUser, err := service.UpdateUserStatus(ctx, 7, "user-status-"+suffix, createdUser.ID, UserStatusInput{Status: "disabled"}) if err != nil || updatedUser.Status != "disabled" || updatedUser.Username != userUsername { t.Fatalf("updated user=%#v error=%v", updatedUser, err) } @@ -517,11 +552,16 @@ func deleteAdminFixture(t *testing.T, db *gorm.DB, suffix string) { if err := db.Where("id IN ?", providerIDs).Delete(&providerRow{}).Error; err != nil { t.Errorf("delete providers: %v", err) } - if err := db.Where("target_type IN ('provider', 'provider_model', 'route_pool') AND request_id LIKE ?", "%"+suffix+"%").Delete(&auditRow{}).Error; err != nil { + if err := db.Where("target_type IN ('provider', 'provider_model', 'route_pool', 'user') AND request_id LIKE ?", "%"+suffix+"%").Delete(&auditRow{}).Error; err != nil { t.Errorf("delete audit events: %v", err) } } +func isUserIdentityConflict(err error) bool { + var conflict UserIdentityConflictError + return errors.As(err, &conflict) +} + func registerActiveRouteRestore(t *testing.T, db *gorm.DB, capability model.Capability) { t.Helper() var previous activeRouteRow diff --git a/admin/app/chorus/service_test.go b/admin/app/chorus/service_test.go index 3fc7298..4ff3baa 100644 --- a/admin/app/chorus/service_test.go +++ b/admin/app/chorus/service_test.go @@ -2,6 +2,7 @@ package chorus import ( "encoding/json" + "errors" "testing" "git.ilapage.cn/OPC/chorus/internal/core/model" @@ -16,6 +17,32 @@ func TestPortalUserViewUsername(t *testing.T) { } } +func TestValidateUserCreate(t *testing.T) { + valid := UserCreateInput{Username: "portal-user", Email: "portal@example.invalid", DisplayName: "Portal User", Password: "secret6", Status: "active"} + if err := validateUserCreate(valid); err != nil { + t.Fatalf("valid user rejected: %v", err) + } + for _, test := range []struct { + name string + input UserCreateInput + field string + }{ + {name: "uppercase username", input: UserCreateInput{Username: "Portal-user", Email: valid.Email, DisplayName: valid.DisplayName, Password: valid.Password, Status: valid.Status}, field: "username"}, + {name: "email-shaped username", input: UserCreateInput{Username: "a@b", Email: valid.Email, DisplayName: valid.DisplayName, Password: valid.Password, Status: valid.Status}, field: "username"}, + {name: "invalid email", input: UserCreateInput{Username: valid.Username, Email: "invalid", DisplayName: valid.DisplayName, Password: valid.Password, Status: valid.Status}, field: "email"}, + {name: "empty display name", input: UserCreateInput{Username: valid.Username, Email: valid.Email, DisplayName: "", Password: valid.Password, Status: valid.Status}, field: "display_name"}, + {name: "short password", input: UserCreateInput{Username: valid.Username, Email: valid.Email, DisplayName: valid.DisplayName, Password: "12345", Status: valid.Status}, field: "password"}, + {name: "invalid status", input: UserCreateInput{Username: valid.Username, Email: valid.Email, DisplayName: valid.DisplayName, Password: valid.Password, Status: "pending"}, field: "status"}, + } { + t.Run(test.name, func(t *testing.T) { + var field FieldError + if err := validateUserCreate(test.input); !errors.As(err, &field) || field.Field != test.field { + t.Fatalf("error = %#v, want field %s", err, test.field) + } + }) + } +} + func TestGenerationAttemptEnrichmentAndMalformedFallback(t *testing.T) { attempts := parseGenerationAttempts(json.RawMessage(`[{"type":"lease"},{"type":"provider","provider_model_id":4,"provider_ordinal":1}]`)) items := enrichGenerationAttempts(attempts, map[uint64]providerModelRow{4: {ID: 4, ProviderID: 2, Name: "图像模型"}}, map[uint64]providerRow{2: {ID: 2, Name: "图像服务"}}) diff --git a/admin/app/chorus/types.go b/admin/app/chorus/types.go index 5ce57ea..667a2f3 100644 --- a/admin/app/chorus/types.go +++ b/admin/app/chorus/types.go @@ -163,6 +163,18 @@ type UserStatusInput struct { Status string `json:"status"` } +type UserCreateInput struct { + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Password string `json:"password"` + Status string `json:"status"` +} + +type UserPasswordInput struct { + Password string `json:"password"` +} + type UserView struct { ID uint64 `json:"id"` Username string `json:"username"` diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 442eb11..c43cfad 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- -wiki_revision: cc8d6fe5bd31eb22aeca2b7e4b6a13077589a0a7 -synchronized_at: 2026-08-27T01:57:40Z +wiki_revision: 0ae0f617f353daa24a43ea3c48cd188599b6f5b9 +synchronized_at: 2026-08-28T13:26:18Z # 架构与代码地图 @@ -499,3 +499,13 @@ Admin 生成记录列表仍只返回任务摘要。管理员点击“查看” 请求沿用 go-admin JWT 与 Casbin。服务先核对 generation 与 input/output 的数据库关系,再核对存储对象的 owner、generation 和 MIME 元数据;响应不包含 `storage_key`、缩略图 key 或本地路径。Admin 只注入 `storage.Reader` 的 `Open` 能力,不持有 `Put` 或 `Delete`。前端通过带 Authorization header 的 Axios Blob 请求读取图片,并在关闭详情时释放对象 URL,不把 Token 拼入媒体 URL。 + + +## 管理员创建受控终端用户(#73) + +- Portal 继续只注册账号密码登录,不提供公开注册、找回密码或邮件验证入口。 +- Admin 在既有 JWT 与 Casbin 边界内提供 `POST /api/v1/chorus/users` 和 `PUT /api/v1/chorus/users/:id/password`;`sys_user` 与 `users` 仍然分表且不复用凭据。 +- 创建用户统一规范化账号和邮箱,复用 `internal/platform/password` 的 `bcrypt:v1` 编码;密码重置只替换哈希,响应、错误、日志和审计不返回密码或哈希。 +- 终端用户列表和生成记录关联查询显式选择公开字段,不读取 `password_hash`。创建、启停和密码重置写入脱敏的 `admin_audit_events`。 +- `000011_admin_portal_user_management` 只增加两条 Admin API、菜单关联和 `chorus_operator` Casbin 权限,down 不删除或修改任何终端用户数据。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 985a871..8bdd98b 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.- -wiki_revision: 3ea4bac9925a081f33c2745438deac7e218cb309 -synchronized_at: 2026-08-27T01:57:53Z +wiki_revision: 3c04a5144ee4280aa9f521870713361938b35d72 +synchronized_at: 2026-08-28T13:26:29Z # 业务规则与术语 @@ -238,3 +238,13 @@ synchronized_at: 2026-08-27T01:57:53Z - 媒体端点只读,不提供编辑、删除、重新生成、批量下载或公开分享。 - Admin 列表不批量返回输入/输出,只有显式打开详情后才加载。 + + +## 受控终端用户创建与密码重置(#73) + +- Portal 仍不开放自助注册;终端用户只能由获授权管理员创建。 +- 创建字段为账号、昵称、邮箱、初始密码和状态。账号、邮箱规范化后分别唯一;状态只允许 `active` 或 `disabled`。 +- 终端用户密码继续使用 6 至 1024 字符规则和 `bcrypt:v1` 编码。管理员只能设置新密码,不能读取旧密码、当前密码或密码哈希。 +- 停用用户不能登录 Portal;密码重置后旧密码立即失效。 +- 创建、启停和密码重置必须审计,但任何响应、错误、日志、审计摘要或管理列表都不得包含密码或密码哈希。 + diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 5930490..6d94d04 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.- -wiki_revision: 4a087f2f6a5fbdb7a93380ef8df8ea722fed2f71 -synchronized_at: 2026-08-27T01:58:01Z +wiki_revision: 7e5263ea3ca25119464e10b1afcda12e9582229c +synchronized_at: 2026-08-28T13:26:42Z # 本地开发与验证 @@ -620,3 +620,23 @@ pnpm -C admin-ui build:prod migration 10 必须在精确命名的可丢弃 MySQL 8 测试库执行 up/down/up。测试应核对四个 API、菜单关联和 `chorus_operator` Casbin 规则,并确认 down 只移除本迁移登记的权限数据。浏览器验收使用现有管理员会话和已有记录,不调用 Provider;覆盖详情按需打开、多图、文本、空结果、对象缺失、错误状态和窄屏,检查媒体请求使用 Authorization header 且 URL 不包含 Token 或存储键。 + + +## 管理员创建终端用户(#73) + +1. 先把数据库迁移到 version 11;生产仍禁止 AutoMigrate。 +2. 登录 Admin,进入“用户与访问 → 终端用户”,点击“新增终端用户”,填写账号、昵称、邮箱、初始密码和状态。 +3. 创建成功后用户立即出现在列表;active 用户可用账号和初始密码登录 Portal,disabled 用户不能登录。 +4. 行操作“重置密码”只接受并确认新密码;保存后旧密码失效。 + +最小验证命令: + +```powershell +go test ./migrations ./portal/auth ./portal/handler +go -C admin test ./app/chorus +corepack pnpm@9.15.1 --dir admin-ui exec jest tests/unit/chorus/users-account.spec.js --runInBand +corepack pnpm@9.15.1 --dir admin-ui build:prod +``` + +migration 的 up/down/up 必须只在明确指定的可丢弃 `chorus_test` 数据库执行;version 11 的 down 只撤销权限配置,不删除已经创建的用户。 + diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 73dfc43..5397840 100644 --- a/docs/09-product-requirements-overview.md +++ b/docs/09-product-requirements-overview.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements-Overview wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Product-Requirements-Overview.- -wiki_revision: 6c7e3b2aee8c0f675013cf60020243cadb566e77 -synchronized_at: 2026-08-26T12:38:17Z +wiki_revision: 5a9e186e2c9af9b68f3934f2c19889f07326cbfe +synchronized_at: 2026-08-28T13:27:49Z # 产品需求总览 @@ -318,3 +318,9 @@ MVP-2 父工单 #35 仍等待用户单独确认后关闭;本节只记录 #47 - Admin 时间线把 lease、本地限流等标记为系统事件,不占 Provider 尝试序号;Provider 尝试显示持久化序号、Provider/模型名称、单次耗时和状态。 - 旧记录的缺失、畸形或逆序时间采用安全兜底,不显示负耗时,也不导致页面异常。耗时只从既有时间戳和 `latency_ms` 派生,不持久化重复字段。 + + +## 管理员创建受控终端用户(#73) + +为减少日常通过 seed 或数据库建号的操作,管理端增加受控终端用户创建和密码重置能力;Portal 继续不开放注册、找回密码或邮件验证。该能力复用现有终端用户页和对话框规范,保持管理员与终端用户分表,创建与密码重置受 JWT/Casbin 保护并写脱敏审计。工单 [#73](https://git.ilapage.cn/OPC/chorus/issues/73) 当前处于待验收。 + diff --git a/migrations/000011_admin_portal_user_management.down.sql b/migrations/000011_admin_portal_user_management.down.sql new file mode 100644 index 0000000..9003244 --- /dev/null +++ b/migrations/000011_admin_portal_user_management.down.sql @@ -0,0 +1,18 @@ +DELETE casbin +FROM sys_casbin_rule casbin +JOIN sys_api api + ON casbin.ptype = 'p' + AND casbin.v0 = 'chorus_operator' + AND casbin.v1 = api.path + AND casbin.v2 = api.action +WHERE api.handle IN ('chorus.users.create', 'chorus.users.password.reset'); + +DELETE menu_api +FROM sys_menu_api_rule menu_api +JOIN sys_api api ON api.id = menu_api.sys_api_id +JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id +WHERE menu.path = '/chorus/users' + AND api.handle IN ('chorus.users.create', 'chorus.users.password.reset'); + +DELETE FROM sys_api +WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset'); diff --git a/migrations/000011_admin_portal_user_management.up.sql b/migrations/000011_admin_portal_user_management.up.sql new file mode 100644 index 0000000..2855dbf --- /dev/null +++ b/migrations/000011_admin_portal_user_management.up.sql @@ -0,0 +1,17 @@ +INSERT INTO sys_api (handle, title, path, type, action) +VALUES + ('chorus.users.create', 'Create portal user', '/api/v1/chorus/users', 'BUS', 'POST'), + ('chorus.users.password.reset', 'Reset portal user password', '/api/v1/chorus/users/:id/password', 'BUS', 'PUT') +ON DUPLICATE KEY UPDATE + title = VALUES(title), type = VALUES(type), deleted_at = NULL; + +INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id) +SELECT menu.menu_id, api.id +FROM sys_menu menu +JOIN sys_api api ON api.handle IN ('chorus.users.create', 'chorus.users.password.reset') +WHERE menu.path = '/chorus/users'; + +INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', 'chorus_operator', path, action, '', '', '' +FROM sys_api +WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset'); diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index 07e7303..8d72780 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -32,6 +32,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { "000008_portal_username_login.up.sql", "000009_admin_grouped_navigation.up.sql", "000010_admin_generation_media.up.sql", + "000011_admin_portal_user_management.up.sql", } slices.Sort(upFiles) if !slices.Equal(upFiles, wantFiles) { @@ -63,6 +64,34 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { } } +func TestAdminPortalUserManagementMigrationContracts(t *testing.T) { + up, err := os.ReadFile("000011_admin_portal_user_management.up.sql") + if err != nil { + t.Fatal(err) + } + down, err := os.ReadFile("000011_admin_portal_user_management.down.sql") + if err != nil { + t.Fatal(err) + } + for _, required := range []string{ + "chorus.users.create", "chorus.users.password.reset", "sys_menu_api_rule", "sys_casbin_rule", "/chorus/users", + } { + if !strings.Contains(string(up), required) || !strings.Contains(string(down), required) { + t.Errorf("portal user management migration is missing reversible contract %s", required) + } + } + for _, route := range []string{"/api/v1/chorus/users", "/api/v1/chorus/users/:id/password"} { + if !strings.Contains(string(up), route) { + t.Errorf("portal user management migration is missing route %s", route) + } + } + for _, forbidden := range []string{"INSERT INTO users", "DELETE FROM users", "password_hash", "AutoMigrate"} { + if strings.Contains(string(up), forbidden) || strings.Contains(string(down), forbidden) { + t.Errorf("portal user permission migration must not touch %s", forbidden) + } + } +} + func TestAdminGenerationMediaMigrationContracts(t *testing.T) { up, err := os.ReadFile("000010_admin_generation_media.up.sql") if err != nil { diff --git a/migrations/mysql_integration_test.go b/migrations/mysql_integration_test.go index 4c978ea..7649a3d 100644 --- a/migrations/mysql_integration_test.go +++ b/migrations/mysql_integration_test.go @@ -157,7 +157,13 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) { assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.login-logs.%' AND action <> 'GET'`, 0) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 LIKE '/api/v1/chorus/generations/%' AND v2 = 'GET'`, 4) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 IN ('/api/v1/chorus/users', '/api/v1/chorus/users/:id/password') AND v2 IN ('POST', 'PUT')`, 2) + runMigrate("down", "1") + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4) + assertGroupedAdminNavigation(t, ctx, db) runMigrate("down", "1") assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 0) assertGroupedAdminNavigation(t, ctx, db) @@ -205,6 +211,7 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) { assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1) assertGroupedAdminNavigation(t, ctx, db) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2) } func assertGroupedAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) {