feat(auth): 注册协议 - 合并同意校验并顺延迁移版本
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
186
backend/internal/auth/auth_registration_test.go
Normal file
186
backend/internal/auth/auth_registration_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 32
|
||||
const CurrentSchemaVersion int64 = 33
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -106,6 +106,7 @@ var schemaMigrations = []migration{
|
||||
return tx.AutoMigrate(&model.ToolFavorite{})
|
||||
}},
|
||||
{version: 32, name: "channel_model_tags", checksum: "sha256:channel-model-tags-v32", apply: migrateChannelModelTags},
|
||||
{version: 33, name: "oauth_state_accepted_terms", checksum: "sha256:oauth-state-accepted-terms-v33", apply: migrateOAuthStateAcceptedTerms},
|
||||
}
|
||||
|
||||
func migrateChannelModelTags(tx *gorm.DB) error {
|
||||
@@ -115,6 +116,13 @@ func migrateChannelModelTags(tx *gorm.DB) error {
|
||||
return tx.Migrator().AddColumn(&model.ChannelModel{}, "Tags")
|
||||
}
|
||||
|
||||
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 {
|
||||
for _, entity := range []any{&model.ChannelModelPriceTier{}, &model.BillingOrder{}} {
|
||||
for _, column := range []string{"cost_configured", "cost_unit_price_microcredits", "cost_input_token_price_microcredits", "cost_output_token_price_microcredits", "cost_cached_token_price_microcredits"} {
|
||||
|
||||
@@ -456,7 +456,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 {
|
||||
@@ -500,7 +500,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 {
|
||||
|
||||
50
backend/internal/database/oauth_state_accepted_terms_test.go
Normal file
50
backend/internal/database/oauth_state_accepted_terms_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,8 @@ description: 影策后端核心持久化表与状态边界
|
||||
|
||||
## 结构迁移与版本
|
||||
|
||||
当前结构版本为 33。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`;保留旧 state 的身份、PKCE、跳转目标及有效期,不替历史流程补造同意记录。升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 33,回退需恢复匹配的数据库备份。
|
||||
|
||||
### 云端 Agent 运行时
|
||||
|
||||
每轮 Agent 存为 `tasks` 中 `operation = cloud_agent` 的 `canvas_text` 任务,任务 `input_json.cloudAgent` 保存版本 1 的已校验请求、父轮 ID、请求指纹和技能快照;历史从服务端父轮结果构造。任务 ID 由用户与幂等键确定,利用现有主键、任务 admission 和积分预留事务防止重复提交重复扣费。Agent 的模型循环、工具调用、审批、子任务和事件写入 `cloud_agent_executions.state_json`,任务 Worker 只负责推进状态,不依赖浏览器连接。公共任务创建与重试接口禁止绕过 Agent admission;删除任务会影响该轮继续对话与回放。
|
||||
@@ -29,7 +31,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 绕过模型上下文限制。
|
||||
|
||||
@@ -106,6 +108,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`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。
|
||||
|
||||
@@ -29,6 +29,13 @@ description: 信封、错误码与 OpenAPI
|
||||
- 物理空间由后台删除队列异步释放,失败保留任务重试;接口成功不代表对象存储已经清理完成。未选中素材使用的资源,以及其他 Resource 行(包括其他用户)复用的物理对象继续保留。
|
||||
- 旧画布 / 任务 / 历史快照记录本身保留,但被删媒体链接将失效。此操作不可恢复;没有数据库结构迁移。
|
||||
|
||||
## 注册服务协议
|
||||
|
||||
`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` 中手动填写正式条款并审核后再发布;占位提示不是正式协议。当前只校验本次注册的同意标志,不保存长期协议版本或签署凭证。前后端需一起更新,旧客户端缺少同意标志时无法注册。
|
||||
## 站点皮肤主按钮
|
||||
|
||||
### 画布配置与 Live2D(外观合同版本 9)
|
||||
|
||||
@@ -129,6 +129,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- [x] 三种协议的 15 组场景通过,覆盖响应结束前发布增量、普通 JSON 回退、关闭流式、524 单次调用和取消;声明式执行、Agent 流解析及计费核对相关回归通过。正式后端编译、部署后版本与 API 就绪检查通过。
|
||||
- [ ] 真实渠道核对首个文本增量、长回复、取消与最终用量;历史 524 任务没有上游任务 ID,仍需供应商核对执行和费用,不自动重新生成或改写账务。
|
||||
|
||||
## 注册服务协议
|
||||
|
||||
- [ ] 维护者手动填写 `web/src/pages/auth/register.tsx` 中的 `SERVICE_AGREEMENT` 正文并审核后再发布;当前仅显示“服务协议内容待补充。”,不包含正式条款。
|
||||
- [ ] 真实浏览器确认《影策服务协议》弹窗、默认未勾选、取消勾选、普通注册限流和窄屏显示。
|
||||
- [ ] 在真实 Linux.do 环境验证新用户未同意被拒绝、同意后注册,以及注册关闭时已有用户仍可登录。
|
||||
- [ ] 升级前备份,在 PostgreSQL 上验证版本 32→33;前后端一起发布。当前同意标志不提供长期版本化签署留痕。
|
||||
## 多模态列表与分镜表合并回归
|
||||
|
||||
- [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。
|
||||
|
||||
@@ -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<ReturnType<typeof getAuthSettings>>;
|
||||
|
||||
// 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<HTMLFormElement>) => {
|
||||
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() {
|
||||
</AuthField>
|
||||
</div>
|
||||
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
<div className="flex items-start gap-2 text-xs leading-5 text-white/55">
|
||||
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
|
||||
<span className="text-xs leading-5 text-white/55">我已阅读并同意</span>
|
||||
</Checkbox>
|
||||
<button type="button" className="-ml-1 text-xs leading-5 text-blue-300/85 transition hover:text-blue-200" onClick={() => setAgreementOpen(true)}>
|
||||
《影策服务协议》
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0 || !agreementAccepted} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
|
||||
</Button>
|
||||
{settings?.linuxdoEnabled ? (
|
||||
@@ -197,11 +215,22 @@ export default function RegisterPage() {
|
||||
<Divider plain className="!border-white/10 !text-white/30">
|
||||
或
|
||||
</Divider>
|
||||
<Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>
|
||||
<Button size="large" block disabled={!agreementAccepted} icon={<LinuxDOIcon />} href={agreementAccepted ? linuxDOLoginURL(next, true) : undefined}>
|
||||
使用 Linux.do 注册 / 登录
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Modal className="workspace-modal workspace-modal-compact" title="影策服务协议" open={agreementOpen} onCancel={() => setAgreementOpen(false)} footer={null} destroyOnHidden>
|
||||
<div className="max-h-96 space-y-4 overflow-y-auto pr-1 text-sm leading-6 text-foreground/68">
|
||||
{SERVICE_AGREEMENT.length === 0 ? <p className="m-0">服务协议内容待补充。</p> : null}
|
||||
{SERVICE_AGREEMENT.map((item) => (
|
||||
<section key={item.title}>
|
||||
<h3 className="m-0 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
<p className="mt-1.5 mb-0">{item.content}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -375,9 +375,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() {
|
||||
@@ -430,7 +431,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);
|
||||
}
|
||||
|
||||
|
||||
41
web/test/auth-registration-agreement.test.ts
Normal file
41
web/test/auth-registration-agreement.test.ts
Normal file
@@ -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("服务协议内容待补充。");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user