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("服务协议内容待补充。"); + }); +});