feat(auth): 注册服务协议 - 名称与条款改为后台可配置并同步注册页展示
后端: - RegistrationSettingRequest 协议字段改为可选指针,注册开关单独保存不再覆盖协议正文 - 新增 RegistrationAgreement / AgreementTitleForMessage:标题未配置时跟随品牌名生成,读取失败返回空标题而非猜一个不存在的协议名 - PublicAuthSettings 下发 agreementTitle / agreementContent,首用户分支同样携带 - 新增 registration_agreement_test.go 覆盖品牌名兜底、存储合并与失败降级 前端: - 注册页协议标题与条款改为消费后台配置;读取失败时隐藏协议入口并支持重新读取 - 验证码获取按钮内联到输入框右侧,联系方式为空即禁用;sms_email 双通道合并为单输入框 - 协议弹窗重排:条目标题独立成行加重、空态虚线提示、底部补充同意说明;深浅主题适配 - 协议勾选行改为状态化区域,复选框适配 AntD 6 结构(方框在 .ant-checkbox 本体,修正深色主题白底白勾) - 后台「服务协议名称与条款」区块补齐表单内边距并对齐区块头/页脚,协议名称限宽 文档:http-api.mdx 更新协议字段语义与失败降级说明;pending-test.mdx 增加手工验证清单
This commit is contained in:
1 parent
abf593c6c9
commit
672bb0be73
16 files changed
+977
-74
No files matched your search
@@ -47,13 +47,15 @@ type LoginRequest struct {
|
||||
|
||||
type PublicAuthSettings struct {
|
||||
VerificationPolicy
|
||||
SMSBindingAvailable bool `json:"smsBindingAvailable"`
|
||||
EmailBindingAvailable bool `json:"emailBindingAvailable"`
|
||||
FirstUser bool `json:"firstUser"`
|
||||
RegistrationEnabled bool `json:"registrationEnabled"`
|
||||
LinuxDOEnabled bool `json:"linuxdoEnabled"`
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
EmailCodeRequired bool `json:"emailCodeRequired"`
|
||||
SMSBindingAvailable bool `json:"smsBindingAvailable"`
|
||||
EmailBindingAvailable bool `json:"emailBindingAvailable"`
|
||||
FirstUser bool `json:"firstUser"`
|
||||
RegistrationEnabled bool `json:"registrationEnabled"`
|
||||
LinuxDOEnabled bool `json:"linuxdoEnabled"`
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
EmailCodeRequired bool `json:"emailCodeRequired"`
|
||||
AgreementTitle string `json:"agreementTitle,omitempty"`
|
||||
AgreementContent string `json:"agreementContent,omitempty"`
|
||||
}
|
||||
|
||||
type AuthSessionResult struct {
|
||||
@@ -76,7 +78,9 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return &PublicAuthSettings{FirstUser: true, RegistrationEnabled: true, LinuxDOEnabled: false}, nil
|
||||
// 首个管理员同样要看到服务协议,不能因为跳过注册校验就丢失协议字段。
|
||||
agreementTitle, agreementContent := s.RegistrationAgreement()
|
||||
return &PublicAuthSettings{FirstUser: true, RegistrationEnabled: true, LinuxDOEnabled: false, AgreementTitle: agreementTitle, AgreementContent: agreementContent}, nil
|
||||
}
|
||||
registrationEnabled, err := s.RegistrationEnabled()
|
||||
if err != nil {
|
||||
@@ -107,12 +111,24 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
|
||||
p.SMSRegistration = p.SMSRegistration && smsRegister
|
||||
p.EmailRegistration = p.EmailRegistration && emailEnabled
|
||||
p.SMSAndEmailRegistration = p.SMSAndEmailRegistration && smsRegister && emailEnabled
|
||||
return &PublicAuthSettings{VerificationPolicy: p, SMSBindingAvailable: smsBind, EmailBindingAvailable: emailEnabled, FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: p.EmailRegistration || p.SMSAndEmailRegistration}, nil
|
||||
agreementTitle, agreementContent := s.RegistrationAgreement()
|
||||
return &PublicAuthSettings{
|
||||
VerificationPolicy: p,
|
||||
SMSBindingAvailable: smsBind,
|
||||
EmailBindingAvailable: emailEnabled,
|
||||
FirstUser: false,
|
||||
RegistrationEnabled: registrationEnabled,
|
||||
LinuxDOEnabled: s.LinuxDOEnabled(),
|
||||
EmailEnabled: emailEnabled,
|
||||
EmailCodeRequired: p.EmailRegistration || p.SMSAndEmailRegistration,
|
||||
AgreementTitle: agreementTitle,
|
||||
AgreementContent: agreementContent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
|
||||
if !req.AcceptedTerms {
|
||||
return nil, kernel.BadAuthRequest("请先同意影策服务协议")
|
||||
return nil, kernel.BadAuthRequest("请先同意" + s.AgreementTitleForMessage())
|
||||
}
|
||||
username := NormalizeUsername(req.Username)
|
||||
email := NormalizeEmail(req.Email)
|
||||
|
||||
@@ -242,7 +242,7 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO
|
||||
return nil, kernel.Forbidden("管理员未开放新用户注册")
|
||||
}
|
||||
if !state.AcceptedTerms {
|
||||
return nil, kernel.BadAuthRequest("请先同意影策服务协议")
|
||||
return nil, kernel.BadAuthRequest("请先同意" + s.AgreementTitleForMessage())
|
||||
}
|
||||
user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,19 +14,27 @@ import (
|
||||
|
||||
const registrationSettingKey = "registration"
|
||||
|
||||
// RegistrationSettingRequest 的协议字段用指针区分「未提交」和「明确清空」:
|
||||
// 注册开关单独切换时不带协议字段,必须保留已保存的协议正文。
|
||||
type RegistrationSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AgreementTitle *string `json:"agreementTitle"`
|
||||
AgreementContent *string `json:"agreementContent"`
|
||||
}
|
||||
|
||||
type PublicRegistrationSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AgreementTitle string `json:"agreementTitle"`
|
||||
AgreementContent string `json:"agreementContent"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type registrationSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AgreementTitle string `json:"agreementTitle"`
|
||||
AgreementContent string `json:"agreementContent"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminRegistrationSetting(actor *model.User) (*PublicRegistrationSetting, error) {
|
||||
@@ -44,11 +52,22 @@ func (s *Service) UpdateRegistrationSetting(actor *model.User, req RegistrationS
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(registrationSettingValue{Enabled: req.Enabled})
|
||||
current, currentValue, err := s.readRegistrationSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, _, err := s.readRegistrationSetting()
|
||||
nextValue := registrationSettingValue{
|
||||
Enabled: req.Enabled,
|
||||
AgreementTitle: currentValue.AgreementTitle,
|
||||
AgreementContent: currentValue.AgreementContent,
|
||||
}
|
||||
if req.AgreementTitle != nil {
|
||||
nextValue.AgreementTitle = strings.TrimSpace(*req.AgreementTitle)
|
||||
}
|
||||
if req.AgreementContent != nil {
|
||||
nextValue.AgreementContent = strings.TrimSpace(*req.AgreementContent)
|
||||
}
|
||||
encoded, err := json.Marshal(nextValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,7 +78,7 @@ func (s *Service) UpdateRegistrationSetting(actor *model.User, req RegistrationS
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRegistrationSetting(&setting, registrationSettingValue{Enabled: req.Enabled}), nil
|
||||
return publicRegistrationSetting(&setting, nextValue), nil
|
||||
}
|
||||
|
||||
func (s *Service) RegistrationEnabled() (bool, error) {
|
||||
@@ -67,6 +86,39 @@ func (s *Service) RegistrationEnabled() (bool, error) {
|
||||
return value.Enabled, err
|
||||
}
|
||||
|
||||
// AgreementTitleForMessage 只用于错误提示。协议读取失败时不再返回品牌名兜底标题,
|
||||
// 否则用户会被要求同意一个后台并不存在的协议名称。
|
||||
func (s *Service) AgreementTitleForMessage() string {
|
||||
title, _ := s.RegistrationAgreement()
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return "服务协议"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// RegistrationAgreement 返回展示用的协议标题(已去掉书名号)和条款正文。
|
||||
// 标题未配置时跟随当前品牌名,保证品牌改名后注册页文案同步。
|
||||
// 读取失败时返回空标题,由调用方决定是降级展示还是给出失败提示。
|
||||
func (s *Service) RegistrationAgreement() (string, string) {
|
||||
_, value, err := s.readRegistrationSetting()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
title := strings.Trim(strings.TrimSpace(value.AgreementTitle), "《》")
|
||||
if title == "" {
|
||||
title = s.defaultAgreementTitle()
|
||||
}
|
||||
return title, value.AgreementContent
|
||||
}
|
||||
|
||||
func (s *Service) defaultAgreementTitle() string {
|
||||
brand := strings.TrimSpace(s.host.BrandName())
|
||||
if brand == "" {
|
||||
brand = DefaultBrandName
|
||||
}
|
||||
return brand + "服务协议"
|
||||
}
|
||||
|
||||
func (s *Service) readRegistrationSetting() (*model.SystemSetting, registrationSettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(registrationSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -83,7 +135,11 @@ func (s *Service) readRegistrationSetting() (*model.SystemSetting, registrationS
|
||||
}
|
||||
|
||||
func publicRegistrationSetting(setting *model.SystemSetting, value registrationSettingValue) *PublicRegistrationSetting {
|
||||
result := &PublicRegistrationSetting{Enabled: value.Enabled}
|
||||
result := &PublicRegistrationSetting{
|
||||
Enabled: value.Enabled,
|
||||
AgreementTitle: value.AgreementTitle,
|
||||
AgreementContent: value.AgreementContent,
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRegistrationAgreementFollowsBrandNameWhenUnset(t *testing.T) {
|
||||
svc := New(repository.New(newAgreementTestDB(t)), brandHost{name: "星野"}, nil)
|
||||
|
||||
title, content := svc.RegistrationAgreement()
|
||||
if title != "星野服务协议" {
|
||||
t.Fatalf("default agreement title = %q", title)
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("default agreement content = %q", content)
|
||||
}
|
||||
|
||||
settings, err := svc.PublicAuthSettings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.AgreementTitle != "星野服务协议" || settings.AgreementContent != "" {
|
||||
t.Fatalf("public settings agreement = %q / %q", settings.AgreementTitle, settings.AgreementContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRegistrationSettingStoresAgreement(t *testing.T) {
|
||||
db := newAgreementTestDB(t)
|
||||
svc := New(repository.New(db), brandHost{name: "星野"}, nil)
|
||||
if err := db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
|
||||
saved, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{
|
||||
Enabled: true,
|
||||
AgreementTitle: strPtr("《星野平台服务条款》"),
|
||||
AgreementContent: strPtr("第一条 服务说明\n\n第二条 内容合规"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.Enabled || saved.AgreementTitle != "《星野平台服务条款》" {
|
||||
t.Fatalf("saved setting = %#v", saved)
|
||||
}
|
||||
|
||||
reloaded, err := svc.AdminRegistrationSetting(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.AgreementContent != "第一条 服务说明\n\n第二条 内容合规" {
|
||||
t.Fatalf("reloaded content = %q", reloaded.AgreementContent)
|
||||
}
|
||||
|
||||
// 展示用标题去掉书名号,避免注册页出现双书名号。
|
||||
title, content := svc.RegistrationAgreement()
|
||||
if title != "星野平台服务条款" || content != "第一条 服务说明\n\n第二条 内容合规" {
|
||||
t.Fatalf("agreement = %q / %q", title, content)
|
||||
}
|
||||
|
||||
settings, err := svc.PublicAuthSettings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.AgreementTitle != "星野平台服务条款" || settings.AgreementContent != "第一条 服务说明\n\n第二条 内容合规" {
|
||||
t.Fatalf("public settings agreement = %q / %q", settings.AgreementTitle, settings.AgreementContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationToggleKeepsSavedAgreement(t *testing.T) {
|
||||
db := newAgreementTestDB(t)
|
||||
svc := New(repository.New(db), brandHost{name: "星野"}, nil)
|
||||
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
|
||||
if _, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{Enabled: true, AgreementContent: strPtr("条款正文")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := svc.RegistrationEnabled()
|
||||
if err != nil || !enabled {
|
||||
t.Fatalf("RegistrationEnabled() = %v, %v", enabled, err)
|
||||
}
|
||||
|
||||
// 管理面板切换注册开关时只提交 enabled,已保存的协议正文不能被清空。
|
||||
switched, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if switched.Enabled || switched.AgreementContent != "条款正文" {
|
||||
t.Fatalf("toggle wiped agreement: %#v", switched)
|
||||
}
|
||||
|
||||
cleared, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{Enabled: true, AgreementContent: strPtr("")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleared.AgreementContent != "" {
|
||||
t.Fatalf("explicit clear ignored: %#v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(value string) *string { return &value }
|
||||
|
||||
// 配置损坏时不能再返回品牌名兜底标题:注册页会据此展示,与后台真实配置不符。
|
||||
func TestRegistrationAgreementDegradesWhenSettingUnreadable(t *testing.T) {
|
||||
db := newAgreementTestDB(t)
|
||||
svc := New(repository.New(db), brandHost{name: "星野"}, nil)
|
||||
|
||||
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: "{not-json"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
title, content := svc.RegistrationAgreement()
|
||||
if title != "" || content != "" {
|
||||
t.Fatalf("broken setting should degrade to empty, got %q / %q", title, content)
|
||||
}
|
||||
|
||||
// 错误提示仍需给出可读文案,但不能是凭空拼出来的协议名。
|
||||
if message := svc.AgreementTitleForMessage(); message != "服务协议" {
|
||||
t.Fatalf("fallback message title = %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func newAgreementTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
Reference in new issue
Block a user