From 316386a2bda7fdd1b1069bd123719b82a9d7508b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B3=BD?= <749959948@qq.com> Date: Sun, 20 Sep 2026 22:57:31 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat(auth):=20=E6=B3=A8=E5=86=8C=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=20-=20=E5=A2=9E=E5=8A=A0=E5=90=8C=E6=84=8F=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=B9=B6=E9=A2=84=E7=95=99=E5=BD=B1=E7=AD=96=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/auth_bridge.go | 4 +- backend/internal/auth/auth.go | 14 +- .../internal/auth/auth_registration_test.go | 186 ++++++++++++++++++ backend/internal/auth/email_domain_test.go | 2 +- backend/internal/auth/linuxdo.go | 7 +- backend/internal/database/migrations.go | 10 +- backend/internal/database/migrations_test.go | 4 +- .../oauth_state_accepted_terms_test.go | 50 +++++ backend/internal/handler/auth.go | 3 +- backend/internal/model/models_identity.go | 17 +- .../content/docs/backend/backend-database.mdx | 5 +- docs/content/docs/backend/http-api.mdx | 8 + docs/content/docs/progress/pending-test.mdx | 7 + web/src/pages/auth/register.tsx | 37 +++- web/src/services/api/auth.ts | 7 +- web/test/auth-registration-agreement.test.ts | 41 ++++ 16 files changed, 372 insertions(+), 30 deletions(-) create mode 100644 backend/internal/auth/auth_registration_test.go create mode 100644 backend/internal/database/oauth_state_accepted_terms_test.go create mode 100644 web/test/auth-registration-agreement.test.ts diff --git a/backend/internal/app/auth_bridge.go b/backend/internal/app/auth_bridge.go index 2d26cf494..7617150d1 100644 --- a/backend/internal/app/auth_bridge.go +++ b/backend/internal/app/auth_bridge.go @@ -196,8 +196,8 @@ func (s *Service) LinuxDOEnabled() bool { return s.authDomain().LinuxDOEnabled() } -func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) { - return s.authDomain().BeginLinuxDOLogin(nextPath) +func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string, error) { + return s.authDomain().BeginLinuxDOLogin(nextPath, acceptedTerms) } func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) { diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 1d864baa3..451cae060 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -28,11 +28,12 @@ var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`) type AuthError = kernel.AppError type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - EmailCode string `json:"emailCode"` - DisplayName string `json:"displayName"` - Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + EmailCode string `json:"emailCode"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + AcceptedTerms bool `json:"acceptedTerms"` } type LoginRequest struct { @@ -82,6 +83,9 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) { } func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) { + if !req.AcceptedTerms { + return nil, kernel.BadAuthRequest("请先同意影策服务协议") + } username := NormalizeUsername(req.Username) email := NormalizeEmail(req.Email) displayName := NormalizeDisplayName(req.DisplayName, username) diff --git a/backend/internal/auth/auth_registration_test.go b/backend/internal/auth/auth_registration_test.go new file mode 100644 index 000000000..3aa0e57b8 --- /dev/null +++ b/backend/internal/auth/auth_registration_test.go @@ -0,0 +1,186 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func newRegistrationTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + svc, db := newPasswordResetTestService(t) + if err := db.AutoMigrate(&model.UserIdentity{}, &model.OAuthState{}, &model.CreditAccount{}); err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + return svc, db +} + +func TestRegisterRequiresAcceptedTermsBeforeWriting(t *testing.T) { + for _, payload := range []string{ + `{"username":"new-user","password":"password"}`, + `{"username":"new-user","password":"password","acceptedTerms":false}`, + } { + svc, db := newRegistrationTestService(t) + var req RegisterRequest + if err := json.Unmarshal([]byte(payload), &req); err != nil { + t.Fatal(err) + } + _, err := svc.Register(req) + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Status != 400 || authErr.Message != "请先同意影策服务协议" { + t.Fatalf("Register() error = %v", err) + } + for _, entity := range []any{&model.User{}, &model.AuthSession{}} { + var count int64 + if err := db.Model(entity).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("unaccepted registration wrote %T: count=%d err=%v", entity, count, err) + } + } + } +} + +func TestRegisterAcceptedTermsCreatesFirstAdmin(t *testing.T) { + svc, _ := newRegistrationTestService(t) + result, err := svc.Register(RegisterRequest{Username: "new-user", Password: "password", AcceptedTerms: true}) + if err != nil { + t.Fatal(err) + } + if result.User.Username != "new-user" || result.User.Role != model.UserRoleAdmin || result.Session == "" { + t.Fatalf("Register() result = %#v", result) + } +} + +func TestRegisterAcceptedTermsCreatesEmailUser(t *testing.T) { + svc, db := newRegistrationTestService(t) + if err := db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil { + t.Fatal(err) + } + var code string + svc.SetMailSender(func(_ EmailSettingValue, _, _ string, body string) error { + code = codeFromEmailBody(body) + return nil + }) + if err := svc.SendRegistrationEmailCode("member@example.com"); err != nil { + t.Fatal(err) + } + result, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", EmailCode: code, Password: "password", AcceptedTerms: true}) + if err != nil { + t.Fatal(err) + } + if result.User.Role != model.UserRoleUser || result.Session == "" { + t.Fatalf("Register() result = %#v", result) + } +} + +func TestLinuxDORegistrationAgreement(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var payload any + switch r.URL.Path { + case "/token": + payload = map[string]string{"access_token": "test-access-token"} + case "/profile": + payload = map[string]string{"id": "42", "username": "linux-user", "name": "Linux User"} + default: + http.NotFound(w, r) + return + } + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Error(err) + } + })) + defer server.Close() + for _, tc := range []struct { + name string + accepted bool + existing bool + registration bool + wantError string + }{ + {name: "new user without agreement", registration: true, wantError: "请先同意影策服务协议"}, + {name: "new user with agreement", accepted: true, registration: true}, + {name: "existing user without agreement", existing: true, registration: true}, + {name: "existing user with registration closed", existing: true}, + {name: "agreement does not bypass registration closure", accepted: true, wantError: "管理员未开放新用户注册"}, + } { + t.Run(tc.name, func(t *testing.T) { + svc, db := newRegistrationTestService(t) + if err := db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}).Error; err != nil { + t.Fatal(err) + } + setting, err := json.Marshal(linuxDOSettingValue{Enabled: true, ClientID: "client", AuthorizationURL: server.URL + "/authorize", TokenURL: server.URL + "/token", UserInfoURL: server.URL + "/profile", RedirectURL: "https://example.com/callback"}) + if err != nil { + t.Fatal(err) + } + for _, value := range []model.SystemSetting{ + {Key: linuxDOSettingKey, ValueJSON: string(setting)}, + {Key: registrationSettingKey, ValueJSON: `{"enabled":` + strconv.FormatBool(tc.registration) + `}`}, + } { + if err := db.Create(&value).Error; err != nil { + t.Fatal(err) + } + } + if tc.existing { + if err := svc.repo.CreateOAuthUser( + &model.User{ID: "existing-user", Username: "linux-user", Role: model.UserRoleUser, Status: model.UserStatusActive}, + &model.UserIdentity{ID: "identity", UserID: "existing-user", Provider: "linuxdo", Subject: "42"}, + ); err != nil { + t.Fatal(err) + } + } + target, err := svc.BeginLinuxDOLogin("/create", tc.accepted) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(target) + if err != nil { + t.Fatal(err) + } + stateValue := parsed.Query().Get("state") + var saved model.OAuthState + if err := db.Where("state_hash = ?", HashToken(stateValue)).First(&saved).Error; err != nil || saved.AcceptedTerms != tc.accepted { + t.Fatalf("saved consent = %v, err=%v", saved.AcceptedTerms, err) + } + result, err := svc.CompleteLinuxDOLogin(stateValue, "test-code") + wantUsers, wantRecords := int64(2), int64(1) + if tc.wantError != "" { + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Message != tc.wantError { + t.Fatalf("CompleteLinuxDOLogin() error = %v", err) + } + wantUsers, wantRecords = 1, 0 + } else if err != nil || result == nil || result.Session.Session == "" || result.Next != "/create" { + t.Fatalf("CompleteLinuxDOLogin() result=%#v err=%v", result, err) + } + if count, err := svc.repo.UserCount(); err != nil || count != wantUsers { + t.Fatalf("user count=%d want=%d err=%v", count, wantUsers, err) + } + for _, entity := range []any{&model.UserIdentity{}, &model.CreditAccount{}, &model.AuthSession{}} { + var count int64 + if err := db.Model(entity).Count(&count).Error; err != nil || count != wantRecords { + t.Fatalf("%T count=%d want=%d err=%v", entity, count, wantRecords, err) + } + } + if _, err := svc.CompleteLinuxDOLogin(stateValue, "test-code"); err == nil { + t.Fatal("consumed OAuth state was accepted twice") + } + }) + } +} diff --git a/backend/internal/auth/email_domain_test.go b/backend/internal/auth/email_domain_test.go index 096f389f8..dec99e270 100644 --- a/backend/internal/auth/email_domain_test.go +++ b/backend/internal/auth/email_domain_test.go @@ -43,7 +43,7 @@ func TestRegistrationDomainPolicyChecksSendingAndRegistration(t *testing.T) { if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"gmail.com"}}); err != nil { t.Fatal(err) } - if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode}); err == nil || !strings.Contains(err.Error(), "白名单") { + if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode, AcceptedTerms: true}); err == nil || !strings.Contains(err.Error(), "白名单") { t.Fatalf("registration must recheck current domain policy: %v", err) } count, err := svc.repo.UserCount() diff --git a/backend/internal/auth/linuxdo.go b/backend/internal/auth/linuxdo.go index df1e7646b..498805469 100644 --- a/backend/internal/auth/linuxdo.go +++ b/backend/internal/auth/linuxdo.go @@ -140,7 +140,7 @@ func (s *Service) LinuxDOEnabled() bool { return err == nil && setting.Enabled } -func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) { +func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string, error) { count, err := s.repo.UserCount() if err != nil { return "", err @@ -161,7 +161,7 @@ func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) { challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:]) if err := s.repo.CreateOAuthState(&model.OAuthState{ ID: kernel.NewID(), Provider: "linuxdo", StateHash: HashToken(state), CodeVerifier: verifier, - NextPath: safeOAuthNext(nextPath), ExpiresAt: time.Now().Add(10 * time.Minute), + NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms, ExpiresAt: time.Now().Add(10 * time.Minute), }); err != nil { return "", err } @@ -234,6 +234,9 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO if !registrationEnabled { return nil, kernel.Forbidden("管理员未开放新用户注册") } + if !state.AcceptedTerms { + return nil, kernel.BadAuthRequest("请先同意影策服务协议") + } user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL) if err != nil { return nil, err diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 122eb1e3c..b96e59403 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 31 +const CurrentSchemaVersion int64 = 32 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -105,6 +105,14 @@ var schemaMigrations = []migration{ {version: 31, name: "tool_favorites", checksum: "sha256:tool-favorites-v31", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.ToolFavorite{}) }}, + {version: 32, name: "oauth_state_accepted_terms", checksum: "sha256:oauth-state-accepted-terms-v32", apply: migrateOAuthStateAcceptedTerms}, +} + +func migrateOAuthStateAcceptedTerms(tx *gorm.DB) error { + if tx.Migrator().HasColumn(&model.OAuthState{}, "AcceptedTerms") { + return nil + } + return tx.Migrator().AddColumn(&model.OAuthState{}, "AcceptedTerms") } func migrateChannelCreditCost(tx *gorm.DB) error { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 26f68d097..8d46852aa 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -446,7 +446,7 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil { + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}, &model.OAuthState{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { @@ -490,7 +490,7 @@ func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil { + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}, &model.OAuthState{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { diff --git a/backend/internal/database/oauth_state_accepted_terms_test.go b/backend/internal/database/oauth_state_accepted_terms_test.go new file mode 100644 index 000000000..f9d3ae879 --- /dev/null +++ b/backend/internal/database/oauth_state_accepted_terms_test.go @@ -0,0 +1,50 @@ +package database + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestOAuthStateAcceptedTermsMigration(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE o_auth_states (id TEXT PRIMARY KEY, provider TEXT, state_hash TEXT, code_verifier TEXT, next_path TEXT)`, + `INSERT INTO o_auth_states VALUES ('legacy', 'linuxdo', 'old-hash', 'old-verifier', '/create')`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateOAuthStateAcceptedTerms(db); err != nil { + t.Fatal(err) + } + } + var state model.OAuthState + if err := db.First(&state, "id = ?", "legacy").Error; err != nil { + t.Fatal(err) + } + if state.AcceptedTerms || state.Provider != "linuxdo" || state.StateHash != "old-hash" || state.CodeVerifier != "old-verifier" || state.NextPath != "/create" { + t.Fatalf("migration changed existing state: %#v", state) + } + if err := db.Exec(`INSERT INTO o_auth_states (id) VALUES ('new-state')`).Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&model.OAuthState{}).Where("accepted_terms = ?", false).Count(&count).Error; err != nil || count != 2 { + t.Fatalf("default consent count=%d err=%v", count, err) + } + if err := db.Model(&state).Update("accepted_terms", true).Error; err != nil { + t.Fatal(err) + } + if err := migrateOAuthStateAcceptedTerms(db); err != nil { + t.Fatal(err) + } + if err := db.First(&state, "id = ?", "legacy").Error; err != nil || !state.AcceptedTerms { + t.Fatalf("repeat migration lost saved consent: %#v err=%v", state, err) + } +} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 190c41c95..6ed4a6972 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -139,7 +139,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { if !enforceRateLimit(c, "linuxdo-start:"+c.ClientIP(), 20, 10*time.Minute) { return } - target, err := svc.BeginLinuxDOLogin(c.Query("next")) + acceptedTerms := c.Query("acceptedTerms") == "true" + target, err := svc.BeginLinuxDOLogin(c.Query("next"), acceptedTerms) if err != nil { failService(c, err) return diff --git a/backend/internal/model/models_identity.go b/backend/internal/model/models_identity.go index 249f80695..487cbf112 100644 --- a/backend/internal/model/models_identity.go +++ b/backend/internal/model/models_identity.go @@ -36,14 +36,15 @@ type UserIdentity struct { } type OAuthState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Provider string `json:"provider" gorm:"index;size:32"` - StateHash string `json:"-" gorm:"uniqueIndex;size:64"` - CodeVerifier string `json:"-" gorm:"size:160"` - NextPath string `json:"nextPath"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - UsedAt *time.Time `json:"usedAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id" gorm:"primaryKey;size:36"` + Provider string `json:"provider" gorm:"index;size:32"` + StateHash string `json:"-" gorm:"uniqueIndex;size:64"` + CodeVerifier string `json:"-" gorm:"size:160"` + NextPath string `json:"nextPath"` + AcceptedTerms bool `json:"acceptedTerms" gorm:"not null;default:false"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + UsedAt *time.Time `json:"usedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` } type EmailVerificationCode struct { diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index bdc50d6ae..6f739d525 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -9,6 +9,8 @@ description: 影策后端核心持久化表与状态边界 ## 结构迁移与版本 +当前结构版本为 32。版本 32 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`;保留旧 state 的身份、PKCE、跳转目标及有效期,不替历史流程补造同意记录。升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 32,回退需恢复匹配的数据库备份。 + ### 云端 Agent 运行时 每轮 Agent 存为 `tasks` 中 `operation = cloud_agent` 的 `canvas_text` 任务,任务 `input_json.cloudAgent` 保存版本 1 的已校验请求、父轮 ID、请求指纹和技能快照;历史从服务端父轮结果构造。任务 ID 由用户与幂等键确定,利用现有主键、任务 admission 和积分预留事务防止重复提交重复扣费。Agent 的模型循环、工具调用、审批、子任务和事件写入 `cloud_agent_executions.state_json`,任务 Worker 只负责推进状态,不依赖浏览器连接。公共任务创建与重试接口禁止绕过 Agent admission;删除任务会影响该轮继续对话与回放。 @@ -23,7 +25,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 27。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。版本 24 `channel_model_label` 为渠道模型增加 `channel_label`(最长 80 字、非空、默认空字符串),不重写模型标识、上游 ID、价格或能力;版本 25 `video_token_formula_snapshot` 为账单增加视频公式用量及用量来源快照。版本 26 `channel_model_description` 为渠道模型增加 `description`(最长 500 字、非空、默认空字符串),供二级选项展示使用说明,不重写身份或价格。版本 27 `channel_credit_cost` 为价格档添加管理员积分成本,并在账单保存执行线路的成本快照;成本不进入普通用户 API。已发布版本的实现和校验和不得修改。 +历史迁移说明:版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。版本 24 `channel_model_label` 为渠道模型增加 `channel_label`(最长 80 字、非空、默认空字符串),不重写模型标识、上游 ID、价格或能力;版本 25 `video_token_formula_snapshot` 为账单增加视频公式用量及用量来源快照。版本 26 `channel_model_description` 为渠道模型增加 `description`(最长 500 字、非空、默认空字符串),供二级选项展示使用说明,不重写身份或价格。版本 27 `channel_credit_cost` 为价格档添加管理员积分成本,并在账单保存执行线路的成本快照;成本不进入普通用户 API。已发布版本的实现和校验和不得修改。 渠道模型的文本能力 JSON 还可声明 `contextWindowTokens` 与 `maxOutputTokens`。这两个字段是模型能力合同,不是数据库或 Agent checkpoint 的容量配置;旧记录读取时补 128K / 16K 默认值。运行状态、journal 和 transcript 的持久化边界仍由既有 runtime 表及其 512KB checkpoint 校验负责,本阶段没有通过扩大 checkpoint 绕过模型上下文限制。 @@ -100,6 +102,7 @@ docker compose -f docker-compose.deploy.yml up -d backend web | --- | --- | --- | | `users` | 保存用户名、唯一非空邮箱、本地密码哈希、角色和状态 | 密码只保存 bcrypt 哈希;密码找回只面向状态正常且已有本地密码的账号 | | `auth_sessions` | 保存登录 Cookie 对应的服务端会话和令牌哈希 | 密码重置成功时删除该用户全部会话,旧 Cookie 立即失效 | +| `o_auth_states` | 保存一次性 OAuth state、PKCE verifier、跳转路径和本次 `accepted_terms` 标志 | 有效期 10 分钟且只能消费一次;仅新建第三方账号时要求同意,已有账号登录不受该标志影响;不是长期协议签署记录 | | `email_verification_codes` | 保存注册和密码重置的一次性邮箱验证码 | 只保存带用途隔离的 HMAC 哈希;验证码 10 分钟过期、只能消费一次,明文不进入数据库 | 密码重置在单个数据库事务中条件消费 `password_reset` 验证码、更新 `users.password_hash` 并删除 `auth_sessions`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 9b77e9013..8544102c6 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -11,6 +11,14 @@ description: 信封、错误码与 OpenAPI 出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。 +## 注册服务协议 + +`POST /auth/register` 的 JSON 请求必须携带布尔值 `acceptedTerms: true`,首次创建管理员也适用。缺省、`false` 或 `null` 均按未同意处理,在创建用户、消费验证码和创建会话前返回 HTTP 400,提示“请先同意影策服务协议”。原有注册开放、邮箱白名单、验证码与限流校验继续生效。 + +`GET /auth/linuxdo/start` 可携带 `acceptedTerms=true`,只有该精确值表示同意。服务端将标志写入一次性的 OAuth state;回调仅在新建 Linux.do 用户时要求该 state 已同意,不从回调参数重新取值。已有 Linux.do 用户可从登录页继续登录,无需传此参数;同意协议不绕过注册关闭策略。 + +注册页的协议名称为《影策服务协议》。正文暂留空,由维护者在 `web/src/pages/auth/register.tsx` 的 `SERVICE_AGREEMENT` 中手动填写正式条款并审核后再发布;占位提示不是正式协议。当前只校验本次注册的同意标志,不保存长期协议版本或签署凭证。前后端需一起更新,旧客户端缺少同意标志时无法注册。 + ## 站点皮肤主按钮 `GET /public/appearance` 返回当前 `activeSkin`;管理员通过 `GET/PUT /admin/settings/appearance` 读取、保存完整主题库。外观合同版本 8 新增 `tokens.buttons.light/dark`,字段为 `mode`(`solid|gradient`)、整数 `angle`(0–360)、HEX 色值 `start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd`、`foreground`。纯色模式使用既有主操作颜色,渐变仅用于主按钮。非法模式、角度、色值或修改锁定经典主题均拒绝保存;整块缺失的旧配置按主题身份补齐。此变更不增加数据表迁移。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 607194f42..d4244f0eb 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 注册服务协议 + +- [ ] 维护者手动填写 `web/src/pages/auth/register.tsx` 中的 `SERVICE_AGREEMENT` 正文并审核后再发布;当前仅显示“服务协议内容待补充。”,不包含正式条款。 +- [ ] 真实浏览器确认《影策服务协议》弹窗、默认未勾选、取消勾选、普通注册限流和窄屏显示。 +- [ ] 在真实 Linux.do 环境验证新用户未同意被拒绝、同意后注册,以及注册关闭时已有用户仍可登录。 +- [ ] 升级前备份,在 PostgreSQL 上验证版本 31→32;前后端一起发布。当前同意标志不提供长期版本化签署留痕。 + ## 多模态列表与分镜表合并回归 - [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。 diff --git a/web/src/pages/auth/register.tsx b/web/src/pages/auth/register.tsx index d10097f82..375ea05ce 100644 --- a/web/src/pages/auth/register.tsx +++ b/web/src/pages/auth/register.tsx @@ -1,5 +1,5 @@ import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react"; -import { App, Button, Divider, Input } from "antd"; +import { App, Button, Checkbox, Divider, Input, Modal } from "antd"; import { ArrowRight, Info, LockKeyhole, Mail, ShieldCheck, TriangleAlert, UserRound } from "lucide-react"; import { useNavigate, useSearchParams } from "react-router"; @@ -9,6 +9,9 @@ import { ApiError } from "@/services/api/request"; type AuthSettings = Awaited>; +// TODO: 由维护者填写正式的影策服务协议条款后再发布。 +const SERVICE_AGREEMENT: { title: string; content: string }[] = []; + export default function RegisterPage() { const navigate = useNavigate(); const [params] = useSearchParams(); @@ -20,6 +23,8 @@ export default function RegisterPage() { const [displayName, setDisplayName] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [agreementAccepted, setAgreementAccepted] = useState(false); + const [agreementOpen, setAgreementOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [sendingCode, setSendingCode] = useState(false); const [countdown, setCountdown] = useState(0); @@ -68,6 +73,10 @@ export default function RegisterPage() { const submit = async (event: FormEvent) => { event.preventDefault(); if (registering.current || registerCountdown > 0) return; + if (!agreementAccepted) { + message.warning("请先同意影策服务协议"); + return; + } if (password !== confirmPassword) { message.error("两次输入的密码不一致"); return; @@ -75,7 +84,7 @@ export default function RegisterPage() { registering.current = true; setSubmitting(true); try { - await register({ username, email, emailCode, displayName, password }); + await register({ username, email, emailCode, displayName, password, acceptedTerms: agreementAccepted }); const { applyUserSession } = await import("@/lib/user-session"); await applyUserSession(await getAuthSession()); if (!settings?.firstUser) window.sessionStorage.setItem("infinite-canvas:model-setup-guide", "1"); @@ -189,7 +198,16 @@ export default function RegisterPage() { - + + + {settings?.linuxdoEnabled ? ( @@ -197,11 +215,22 @@ export default function RegisterPage() { 或 - ) : null} + setAgreementOpen(false)} footer={null} destroyOnHidden> +
+ {SERVICE_AGREEMENT.length === 0 ?

服务协议内容待补充。

: null} + {SERVICE_AGREEMENT.map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
); } diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index b381e9139..a07e065c3 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -371,9 +371,10 @@ export function getAuthSettings() { return http.get<{ firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean }>("/auth/settings"); } -export function linuxDOLoginURL(next: string) { +export function linuxDOLoginURL(next: string, acceptedTerms?: boolean) { const base = String(apiBaseURL).replace(/\/$/, ""); - return `${base}/auth/linuxdo/start?next=${encodeURIComponent(next)}`; + const termsQuery = acceptedTerms === undefined ? "" : `&acceptedTerms=${acceptedTerms ? "true" : "false"}`; + return `${base}/auth/linuxdo/start?next=${encodeURIComponent(next)}${termsQuery}`; } export function getAuthSession() { @@ -426,7 +427,7 @@ export function resetPassword(input: { email: string; emailCode: string; passwor return http.post<{ reset: boolean }>("/auth/password-reset", input); } -export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string }) { +export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string; acceptedTerms: boolean }) { return http.post<{ user: LocalUser }>("/auth/register", input); } diff --git a/web/test/auth-registration-agreement.test.ts b/web/test/auth-registration-agreement.test.ts new file mode 100644 index 000000000..0f1802608 --- /dev/null +++ b/web/test/auth-registration-agreement.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("注册服务协议", () => { + test("确认密码后展示协议入口,默认未同意且保留注册限流", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/auth/register.tsx"), "utf8"); + const passwordIndex = source.indexOf('label="确认密码"'); + const agreementIndex = source.indexOf("Checkbox checked={agreementAccepted}"); + const submitIndex = source.indexOf('htmlType="submit"'); + + expect(passwordIndex).toBeGreaterThanOrEqual(0); + expect(agreementIndex).toBeGreaterThan(passwordIndex); + expect(submitIndex).toBeGreaterThan(agreementIndex); + expect(source).toContain("[agreementAccepted, setAgreementAccepted] = useState(false)"); + expect(source).toContain("disabled={disabled || registerCountdown > 0 || !agreementAccepted}"); + expect(source).toContain("if (registering.current || registerCountdown > 0) return;"); + expect(source).toContain("disabled={!agreementAccepted}"); + }); + + test("普通注册与 Linux.do 注册传递同意状态,登录入口保留原合同", () => { + const page = readFileSync(resolve(import.meta.dir, "../src/pages/auth/register.tsx"), "utf8"); + const login = readFileSync(resolve(import.meta.dir, "../src/pages/auth/login.tsx"), "utf8"); + const api = readFileSync(resolve(import.meta.dir, "../src/services/api/auth.ts"), "utf8"); + + expect(page).toContain("acceptedTerms: agreementAccepted"); + expect(page).toContain("agreementAccepted ? linuxDOLoginURL(next, true) : undefined"); + expect(page).toContain("请先同意影策服务协议"); + expect(login).toContain("linuxDOLoginURL(next)"); + expect(api).toContain("acceptedTerms: boolean"); + expect(api).toContain("acceptedTerms?: boolean"); + }); + + test("协议使用影策标题并提供正文承载区域", () => { + const page = readFileSync(resolve(import.meta.dir, "../src/pages/auth/register.tsx"), "utf8"); + + expect(page).toContain('title="影策服务协议"'); + expect(page).toContain("SERVICE_AGREEMENT.map"); + expect(page).toContain("服务协议内容待补充。"); + }); +}); From 691073e173dd4387e13ea0221c34a46ea585836c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 21 Sep 2026 14:20:17 +0800 Subject: [PATCH 02/31] =?UTF-8?q?fix(create):=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=20-=20=E6=81=A2=E5=A4=8D=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=B7=BB=E5=8A=A0=E4=B8=8E=E6=8C=89=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/progress/pending-test.mdx | 3 ++ web/src/pages/create/creation-references.ts | 25 ++++++++++ web/src/pages/create/creation-workspace.tsx | 2 +- web/src/pages/create/index.tsx | 55 ++++++++++++++++----- web/test/create-library-button.test.ts | 10 ++++ web/test/creation-references.test.ts | 31 +++++++++++- 6 files changed, 111 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f4c5c98a6..fb942a470 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -1734,7 +1734,10 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束 ## 创作输入区参考内容管理 +- [x] 视频创作按同名模型组汇总图片、视频和音频参考能力;文生视频路由本身不接收参考素材时,仍可先添加参考内容,再由现有兼容路由选择图生视频或全模态参考模型。素材库和提交前按媒体类型分别执行上限校验。 +- [x] 创作参考、素材库入口和模型路由 47 项专项测试、前端 TypeScript 检查与生产构建通过。前端已部署为 `1.5.7-creation-video-references`,容器健康,HTTPS 创作页与 API 健康入口返回 200,线上创作页脚本与本次构建哈希一致;未调用真实视频上游。 - [x] 自动化覆盖首个、中间和末尾参考内容按稳定 ID 独立删除,且删除后其余顺序保持不变。 +- [ ] 在真实创作台分别选择图片、视频和音频参考,确认素材库只允许当前模型组支持的类型,达到各类型上限时提示明确,并检查任务详情中的 `referenceImages`、`referenceVideos`、`referenceAudios` 与所选素材一致。 - [x] 折叠态提供明确的“查看全部”入口,展开态提供图片、视频、音频、文件筛选、清空全部和收起入口。 - [x] 在桌面浏览器验证上方“参考内容”打开素材选择、折叠堆叠态中间卡片删除、展开态任意卡片删除、筛选、拖动排序和收起。 - [ ] 在真实登录数据下验证“清空全部素材”同步移除全部附件引用,不影响技能引用和素材库原始文件。 diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index 675976fa8..636ff8e97 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -7,6 +7,12 @@ export type CreationReference = CanvasResourceReference & { attachmentId?: string; }; +export type CreationReferenceLimits = { + maxImages: number; + maxVideos: number; + maxAudios: number; +}; + export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) { const counts = { image: 0, video: 0, audio: 0, file: 0 }; const attachmentReferences = attachments.map((attachment) => { @@ -34,6 +40,25 @@ export function reconcileCreationAttachmentLimit(attachments: CreationAttachment return { attachments: nextAttachments, removedReferences }; } +export function reconcileCreationAttachmentLimits(attachments: CreationAttachment[], references: CreationReference[], limits: CreationReferenceLimits) { + const remaining = { + image: Math.max(0, Math.floor(limits.maxImages)), + video: Math.max(0, Math.floor(limits.maxVideos)), + audio: Math.max(0, Math.floor(limits.maxAudios)), + }; + const nextAttachments = attachments.filter((attachment) => { + const kind = creationAttachmentKind(attachment); + if (kind === "file" || remaining[kind] <= 0) return false; + remaining[kind] -= 1; + return true; + }); + if (nextAttachments.length === attachments.length) return { attachments, removedReferences: [] as CreationReference[] }; + + const retainedIds = new Set(nextAttachments.map((attachment) => attachment.id)); + const removedReferences = references.filter((reference) => reference.attachmentId && !retainedIds.has(reference.attachmentId)); + return { attachments: nextAttachments, removedReferences }; +} + export function removeCreationReferenceTokens(value: string, references: CreationReference[]) { return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join("").split(`@${reference.label}`).join(""), value); } diff --git a/web/src/pages/create/creation-workspace.tsx b/web/src/pages/create/creation-workspace.tsx index 41dc93480..243329153 100644 --- a/web/src/pages/create/creation-workspace.tsx +++ b/web/src/pages/create/creation-workspace.tsx @@ -415,7 +415,7 @@ export function CreationComposer(props: ComposerProps) { : "描述镜头内容、运动、光线与节奏"; const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; - const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video"); + const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.maxReferences > 0; const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`; const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => { diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index c588b028d..7d6308232 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -12,7 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope"; import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions } from "@/lib/model-capabilities"; -import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; +import { inferVideoOperation, modelGroupReferenceLimits, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import type { BackendGenerationResult } from "@/services/api/generation-task"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; @@ -27,7 +27,7 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; -import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references"; +import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference, type CreationReferenceLimits } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; import { defaultCreationMode, modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types"; import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; @@ -42,6 +42,18 @@ type CreationRuntime = Awaited>; const TEXT_STREAMING_PREF_KEY = "creation.composer.text-streaming"; const TEXT_THINKING_PREF_KEY = "creation.composer.text-thinking"; + +function creationLibraryDisabledReason(mode: CreationMode, kind: string | undefined, videoLimits?: CreationReferenceLimits) { + if (!kind) return undefined; + if (mode === "image") return kind === "image" ? undefined : "图片创作仅支持参考图"; + if (mode !== "video") return undefined; + + const maximum = kind === "image" ? videoLimits?.maxImages : kind === "video" ? videoLimits?.maxVideos : kind === "audio" ? videoLimits?.maxAudios : 0; + if ((maximum || 0) > 0) return undefined; + const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "image" ? "图片" : "此类素材"; + return `当前视频模型不支持参考${label}`; +} + function readComposerPref(key: string, fallback: boolean): boolean { try { const stored = window.localStorage.getItem(key); @@ -152,7 +164,14 @@ export default function CreatePage() { : config, [config, mode, ratio, seconds, selectedModel, videoQuality]); const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); - const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; + // 同名逻辑模型可能把文生视频、图生视频和全模态参考拆到不同路由。 + // 入口需要展示整个模型组的引用能力,添加素材后再由兼容路由选择具体模型。 + const videoReferenceLimits = useMemo(() => mode === "video" + ? modelGroupReferenceLimits(config, preferredModel || selectedModel, "video") || { maxImages: 0, maxVideos: 0, maxAudios: 0 } + : undefined, [config, mode, preferredModel, selectedModel]); + const maxReferences = mode === "video" + ? (videoReferenceLimits?.maxImages || 0) + (videoReferenceLimits?.maxVideos || 0) + (videoReferenceLimits?.maxAudios || 0) + : mode === "image" ? imageProfile.references.maxImages : 6; const referenceImageSize = useMemo(() => { const imageAttachments = attachments.filter(isImageAttachment); if (imageAttachments.length !== 1) return undefined; @@ -225,16 +244,16 @@ export default function CreatePage() { setSeconds(normalized.seconds); setRatio(normalized.ratio); setVideoQuality(normalized.resolution.replace(/p$/i, "")); - const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0; - if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences)); }, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, videoProfile]); useEffect(() => { - const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); + const reconciled = mode === "video" && videoReferenceLimits + ? reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits) + : reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); if (reconciled.attachments === attachments) return; setAttachments(reconciled.attachments); if (reconciled.removedReferences.length) setPrompt((current) => removeCreationReferenceTokens(current, reconciled.removedReferences)); - }, [attachments, maxReferences, mentionReferences]); + }, [attachments, maxReferences, mentionReferences, mode, videoReferenceLimits]); useEffect(() => { let cancelled = false; @@ -387,9 +406,9 @@ export default function CreatePage() { const externalLibraryItems = useMemo( () => externalAssetSources.items.map((item) => ({ ...item, - disabledReason: mode === "image" && item.external?.item.kind !== "image" ? "图片创作仅支持参考图" : undefined, + disabledReason: creationLibraryDisabledReason(mode, item.external?.item.kind, videoReferenceLimits), })), - [externalAssetSources.items, mode], + [externalAssetSources.items, mode, videoReferenceLimits], ); const libraryItems = useMemo(() => [ ...assets @@ -401,10 +420,10 @@ export default function CreatePage() { kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片", asset, searchText: (asset.tags || []).join(" "), - disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined, + disabledReason: creationLibraryDisabledReason(mode, asset.kind, videoReferenceLimits), })), ...externalLibraryItems, - ], [assets, externalLibraryItems, mode]); + ], [assets, externalLibraryItems, mode, videoReferenceLimits]); const uploadCreationAsset = async (file: File) => { const { uploadImage, uploadMediaFile } = await loadCreationRuntime(); if (file.type.startsWith("video/")) { @@ -455,7 +474,14 @@ export default function CreatePage() { return external ? [creationAttachmentFromExternalAsset(external)] : []; }); if (!next.length) return; - setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences)); + setAttachments((current) => { + const candidates = [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next]; + const reconciled = mode === "video" && videoReferenceLimits + ? reconcileCreationAttachmentLimits(candidates, [], videoReferenceLimits) + : reconcileCreationAttachmentLimit(candidates, [], maxReferences); + if (reconciled.attachments.length < candidates.length) toast.warning("部分素材超出当前模型的参考内容上限,未添加到创作中"); + return reconciled.attachments; + }); setLibraryOpen(false); }; @@ -556,7 +582,10 @@ export default function CreatePage() { releaseSubmitGate(); return; } - if (attachments.length > maxReferences) { + const reconciledAttachments = mode === "video" && videoReferenceLimits + ? reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits) + : reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); + if (reconciledAttachments.attachments !== attachments) { toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); releaseRetryLock(); releaseSubmitGate(); diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 46a847e18..1f761af2a 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -53,6 +53,16 @@ describe("creation library button", () => { expect(source).toContain("个素材已上传到素材库并自动选中"); }); + test("视频创作使用同名模型组的全部参考能力开放素材入口", () => { + const source = readCreateSource(); + const workspace = readCreateWorkspaceSource(); + + expect(source).toContain('modelGroupReferenceLimits(config, preferredModel || selectedModel, "video")'); + expect(source).toContain("reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits)"); + expect(workspace).toContain('props.mode !== "video" || props.maxReferences > 0'); + expect(workspace).not.toContain('props.videoProfile.operations.includes("image_to_video")'); + }); + test("previews prompt reference images without removing them", () => { const createSource = readCreateWorkspaceSource(); const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8"); diff --git a/web/test/creation-references.test.ts b/web/test/creation-references.test.ts index a5ad7d25d..3885b2cb7 100644 --- a/web/test/creation-references.test.ts +++ b/web/test/creation-references.test.ts @@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"; import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references"; import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets"; -import { buildCreationMentionReferences, displayCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences } from "../src/pages/create/creation-references"; +import { + buildCreationMentionReferences, + displayCreationPrompt, + reconcileCreationAttachmentLimit, + reconcileCreationAttachmentLimits, + removeCreationReferenceTokens, + replaceCreationAttachmentReference, + selectedCreationReferences, +} from "../src/pages/create/creation-references"; function imageAttachment(id: string): CreationAttachment { return { @@ -14,6 +22,10 @@ function imageAttachment(id: string): CreationAttachment { }; } +function mediaAttachment(id: string, type: string): CreationAttachment { + return { id, name: id, type, url: `blob:${id}`, storageKey: `${id}:key`, previewUrl: "" }; +} + describe("creation references", () => { test("removes attachments and prompt tokens beyond the current model limit", () => { const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")]; @@ -37,6 +49,23 @@ describe("creation references", () => { expect(result.removedReferences).toEqual([]); }); + test("视频参考内容分别遵守图片、视频和音频上限", () => { + const attachments = [imageAttachment("image-1"), mediaAttachment("video-1", "video/mp4"), imageAttachment("image-2"), mediaAttachment("audio-1", "audio/mpeg"), mediaAttachment("video-2", "video/mp4")]; + const references = buildCreationMentionReferences([], attachments); + const result = reconcileCreationAttachmentLimits(attachments, references, { maxImages: 1, maxVideos: 2, maxAudios: 0 }); + + expect(result.attachments.map((attachment) => attachment.id)).toEqual(["image-1", "video-1", "video-2"]); + expect(result.removedReferences.map((reference) => reference.attachmentId)).toEqual(["image-2", "audio-1"]); + }); + + test("视频参考内容在各类型均未超限时保留原数组", () => { + const attachments = [imageAttachment("image"), mediaAttachment("video", "video/mp4"), mediaAttachment("audio", "audio/mpeg")]; + const result = reconcileCreationAttachmentLimits(attachments, buildCreationMentionReferences([], attachments), { maxImages: 1, maxVideos: 1, maxAudios: 1 }); + + expect(result.attachments).toBe(attachments); + expect(result.removedReferences).toEqual([]); + }); + test("文本创作允许媒体和常用文档,图片创作仍只接受图片", () => { expect(creationFileAccepted("text", { name: "story.pdf", type: "application/pdf" })).toBe(true); expect(creationFileAccepted("text", { name: "clip.mp4", type: "video/mp4" })).toBe(true); From d716e3900d13e8c064a557414e21c6ae548b9704 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 21 Sep 2026 15:38:21 +0800 Subject: [PATCH 03/31] =?UTF-8?q?feat(admin):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=20-=20=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=B9=B6=E7=A7=BB=E5=8A=A8=E5=88=B0=E6=8C=87=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/overview/features.mdx | 2 +- docs/content/docs/progress/pending-test.mdx | 6 +++ .../components/channel-model-manager.tsx | 2 +- .../admin/components/channel-order-dialog.tsx | 45 ++++++++++++++++--- web/test/admin-channel-workspace.test.tsx | 1 + web/test/channel-presentation.test.tsx | 3 ++ 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index fdc993f06..d101136d6 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -138,7 +138,7 @@ description: 当前项目已实现的核心工作台能力与使用入口 系统渠道模型列表按规格显示成本价 / 销售价及利润率;文本输入、输出、缓存 Token 分开呈现。勾选多个模型可统一调价:弹窗一行一个模型,展示模型名、渠道展示名(上游 ID)、规格、成本与可编辑售价,多规格在模型行内纵向对齐。输入目标利润率并选择保留 0–6 位小数(默认 2 位),按 `成本 / (1 − 利润率)` 四舍五入实时计算;也可点击「重新计算」覆盖手动值,再逐项修改。保存最终输入售价,服务端校验价格与版本并原子保存,不改变成本或启停状态。 -后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 +后台系统渠道通过“设置排序”、模型管理通过“自定义排序”打开完整列表。支持搜索选择条目后输入目标位置,也可拖动、上移和下移;列表显示从 1 开始的位置,点击“保存排序”统一生效,取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f4c5c98a6..b55eed6c1 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 系统模型自定义排序 + +- 模型管理入口明确为“自定义排序”,支持搜索选择模型并输入目标位置,保留拖动和上下移动;位置从 1 开始,保存整份顺序,取消放弃草稿。复用已有管理员排序接口、事务与并发冲突检查,前台系统模型选择器沿用保存后的顺序。 +- [x] 前端 8 项排序 / 管理页面专项、类型检查、生产构建与 UI lint 通过;后端现有排序事务、权限、过期快照和跨渠道检查专项通过。前端已部署,入口与排序脚本同构建一致,API 就绪;构建仍提示部分 chunk 大于 500 kB。静态资源核验不替代浏览器交互验收。 +- [ ] 真实浏览器验证跨分页移动、停用模型排序、取消不保存、刷新恢复、前台模型顺序,以及其他管理员同时修改时的冲突提示。 + ## 画布 Agent 配置与 Live2D - [ ] 使用约 50 MiB 的真实模型 ZIP 验收上传和预览;ZIP 上限 128 MiB,解压累计 384 MiB、单文件 128 MiB,超限须拒绝。 diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index dde51bde8..9e5d16ef1 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -267,7 +267,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan

模型管理 {items.length}

-

规格展示成本价 / 销售价及利润率,勾选模型可统一调价

+

点击“自定义排序”调整模型展示顺序;规格展示成本价 / 销售价及利润率,勾选模型可统一调价

(items: T[], id: string, target: number): T[] { const from = items.findIndex((item) => item.id === id); - if (from < 0 || target < 0 || target >= items.length || from === target) return items; + if (from < 0 || !Number.isInteger(target) || target < 0 || target >= items.length || from === target) return items; const next = [...items]; const [item] = next.splice(from, 1); next.splice(target, 0, item!); @@ -20,6 +20,8 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; [saving, setSaving] = useState(false); const [items, setItems] = useState([]), [original, setOriginal] = useState([]); + const [targetId, setTargetId] = useState(); + const [targetPosition, setTargetPosition] = useState(null); const dragged = useRef(null), pending = useRef(false); const changed = items.some((item, index) => item.id !== original[index]); @@ -28,6 +30,8 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; setLoading(true); setItems([]); setOriginal([]); + setTargetId(undefined); + setTargetPosition(null); try { const result = await getChannelOrder(channelId); setItems(result.items); @@ -50,8 +54,12 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; original, ); setOpen(false); - await onSaved(); message.success("排序已保存,用户端按此顺序展示"); + try { + await onSaved(); + } catch (error) { + message.warning(error instanceof Error ? `排序已保存,但列表刷新失败:${error.message}` : "排序已保存,请刷新页面查看"); + } } catch (error) { message.error(error instanceof Error ? error.message : "保存排序失败"); } finally { @@ -62,10 +70,10 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; return ( <> setOpen(false)} closable={!saving && !loading} @@ -83,7 +91,31 @@ export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; } > -

拖动条目或点击上下箭头调整顺序,保存后用户端按此顺序展示。这里包含全部条目,不受列表筛选和分页影响。

+

选择条目并输入目标位置,或拖动、点击上下箭头调整顺序。点击“保存排序”后生效,取消会放弃本次调整。这里包含全部条目,不受列表筛选和分页影响。

+
+