feat: add Yuheng ticket bind, trial SMS off, shared bindings
Ship ticket-exchange and bind/policy for Z13, keep trial binds SMS-free, allow shared company bindings, and align SyncPage plus sync docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/bindcodestore"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
"aijianzhan/platform/internal/smsstore"
|
||||
"aijianzhan/platform/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -192,6 +193,50 @@ type PhoneLookupResp struct {
|
||||
MaskedName string `json:"masked_name,omitempty"`
|
||||
NeedConfirm bool `json:"need_confirm"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Z13c-2:试运行 RequireForBind=false 时为 false;正式开启后为 true(宇恒同号可 attested 免验)
|
||||
SMSRequiredUnlessAttested bool `json:"sms_required_unless_attested"`
|
||||
}
|
||||
|
||||
func (l *AuthLogic) bindSMSRequired() bool {
|
||||
if l == nil || l.svcCtx == nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(l.svcCtx.Config.SMS.Provider), "off") {
|
||||
return false
|
||||
}
|
||||
return l.svcCtx.Config.SMS.RequireForBind
|
||||
}
|
||||
|
||||
// BindPolicy 公开策略,供宇恒决定是否弹短信 / 走凭票。
|
||||
type BindPolicyResp struct {
|
||||
RequireForBind bool `json:"require_for_bind"`
|
||||
SMSProvider string `json:"sms_provider"`
|
||||
YuhengTicketEnabled bool `json:"yuheng_ticket_enabled"`
|
||||
TrialMode bool `json:"trial_mode"` // !require_for_bind
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (l *AuthLogic) BindPolicy() BindPolicyResp {
|
||||
req := l.bindSMSRequired()
|
||||
ticketOn := l.svcCtx != nil && l.svcCtx.Config.Agent.YuhengTicket.Enabled
|
||||
prov := ""
|
||||
if l.svcCtx != nil {
|
||||
prov = strings.TrimSpace(l.svcCtx.Config.SMS.Provider)
|
||||
}
|
||||
msg := "试运行:绑定可不校验短信,仍须用户确认"
|
||||
if req {
|
||||
msg = "正式:异号须 sms_code;同号请用宇恒凭票 ticket-exchange(或 attested,若未启凭票)"
|
||||
if ticketOn {
|
||||
msg = "正式:异号须 sms_code;同号请用 POST /api/v1/auth/yuheng/ticket-exchange(已禁 attested_same_phone)"
|
||||
}
|
||||
}
|
||||
return BindPolicyResp{
|
||||
RequireForBind: req,
|
||||
SMSProvider: prov,
|
||||
YuhengTicketEnabled: ticketOn,
|
||||
TrialMode: !req,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func maskDisplayName(name string) string {
|
||||
@@ -210,31 +255,41 @@ func maskDisplayName(name string) string {
|
||||
}
|
||||
|
||||
func (l *AuthLogic) PhoneLookup(req PhoneLookupReq) (*PhoneLookupResp, error) {
|
||||
smsReq := l.bindSMSRequired()
|
||||
if l.svcCtx.Users == nil {
|
||||
return nil, fmt.Errorf("user store unavailable")
|
||||
}
|
||||
phone, err := userstore.NormalizePhone(req.Phone)
|
||||
if err != nil {
|
||||
return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确"}, nil
|
||||
return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确", SMSRequiredUnlessAttested: smsReq}, nil
|
||||
}
|
||||
u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone)
|
||||
if err != nil || u == nil {
|
||||
return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员"}, nil
|
||||
return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员", SMSRequiredUnlessAttested: smsReq}, nil
|
||||
}
|
||||
if u.TenantID <= 0 {
|
||||
return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司"}, nil
|
||||
return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司", SMSRequiredUnlessAttested: smsReq}, nil
|
||||
}
|
||||
tenantName := ""
|
||||
if t, err := l.svcCtx.Users.GetTenant(l.ctx, u.TenantID); err == nil && t != nil {
|
||||
tenantName = t.Name
|
||||
}
|
||||
msg := fmt.Sprintf("已找到账号「%s」所属「%s」,是否绑定到本机?", maskDisplayName(u.DisplayName), tenantName)
|
||||
if !smsReq {
|
||||
msg += "(试运行:短信验证已关闭,确认即可)"
|
||||
} else if l.svcCtx.Config.Agent.YuhengTicket.Enabled {
|
||||
msg += "(正式:同号请用宇恒凭票 ticket-exchange;异号须 sms_code)"
|
||||
} else {
|
||||
msg += "(正式:宇恒同号可 attested_same_phone=true;异号须 sms_code)"
|
||||
}
|
||||
return &PhoneLookupResp{
|
||||
Exists: true,
|
||||
TenantID: u.TenantID,
|
||||
TenantName: tenantName,
|
||||
MaskedName: maskDisplayName(u.DisplayName),
|
||||
NeedConfirm: true,
|
||||
Message: fmt.Sprintf("已找到账号「%s」所属「%s」,是否绑定到本机?", maskDisplayName(u.DisplayName), tenantName),
|
||||
Exists: true,
|
||||
TenantID: u.TenantID,
|
||||
TenantName: tenantName,
|
||||
MaskedName: maskDisplayName(u.DisplayName),
|
||||
NeedConfirm: true,
|
||||
SMSRequiredUnlessAttested: smsReq,
|
||||
Message: msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -244,6 +299,9 @@ type PhoneConfirmReq struct {
|
||||
Name string `json:"name"`
|
||||
Confirm bool `json:"confirm"` // 必须 true
|
||||
LocalDBID string `json:"local_database_id"`
|
||||
// Z13c-2:输入号=宇恒已绑手机时,宇恒置 true 可免短信;异号必须带 sms_code
|
||||
AttestedSamePhone bool `json:"attested_same_phone"`
|
||||
SMSCode string `json:"sms_code"`
|
||||
}
|
||||
|
||||
type PhoneConfirmResp struct {
|
||||
@@ -257,6 +315,35 @@ type PhoneConfirmResp struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (l *AuthLogic) requirePhoneBindProof(phone string, attested bool, smsCode string) error {
|
||||
// 正式模式且启用宇恒凭票:禁止明文 attested(须走 ticket-exchange)
|
||||
formal := l.bindSMSRequired()
|
||||
if attested && formal && l.svcCtx.Config.Agent.YuhengTicket.Enabled {
|
||||
return fmt.Errorf("已启用宇恒凭票:请使用 POST /api/v1/auth/yuheng/ticket-exchange,勿再传 attested_same_phone")
|
||||
}
|
||||
if attested {
|
||||
return nil
|
||||
}
|
||||
// 试运行:RequireForBind=false 或 Provider=off,跳过短信
|
||||
if !formal {
|
||||
return nil
|
||||
}
|
||||
code := strings.TrimSpace(smsCode)
|
||||
if code == "" {
|
||||
return fmt.Errorf("须提供 sms_code,或使用宇恒凭票 ticket-exchange(同号)")
|
||||
}
|
||||
if l.svcCtx.SMS == nil {
|
||||
return fmt.Errorf("短信服务未启用,无法校验验证码")
|
||||
}
|
||||
if err := l.svcCtx.SMS.Consume(smsstore.PurposeBind, phone, code); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := l.svcCtx.SMS.Consume(smsstore.PurposeLogin, phone, code); err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("短信验证码无效或已过期")
|
||||
}
|
||||
|
||||
func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) {
|
||||
if !req.Confirm {
|
||||
return nil, fmt.Errorf("须明确确认绑定(confirm=true)")
|
||||
@@ -268,6 +355,9 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("手机号格式不正确")
|
||||
}
|
||||
if err := l.requirePhoneBindProof(phone, req.AttestedSamePhone, req.SMSCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hostKey := strings.TrimSpace(req.HostKey)
|
||||
if hostKey == "" {
|
||||
return nil, fmt.Errorf("host_key required")
|
||||
@@ -279,58 +369,13 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
|
||||
if u.TenantID <= 0 {
|
||||
return nil, fmt.Errorf("该账号尚未加入公司")
|
||||
}
|
||||
cfg := l.svcCtx.Config.DBSync
|
||||
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
|
||||
if driver == "" {
|
||||
driver = dbsync.DriverPostgres
|
||||
}
|
||||
ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(dbsync.DefaultChannelOpts{
|
||||
TenantID: u.TenantID,
|
||||
RemoteDriver: driver,
|
||||
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure channel: %w", err)
|
||||
}
|
||||
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
|
||||
if err != nil {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "离线终端 · " + maskDisplayName(u.DisplayName)
|
||||
}
|
||||
created, _, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey)
|
||||
if regErr != nil {
|
||||
return nil, regErr
|
||||
}
|
||||
acc = created
|
||||
}
|
||||
online := dbsync.ResolveOnlineDBID("", ch.ID)
|
||||
// 个人落点:按用户隔离 online_db_id
|
||||
online = fmt.Sprintf("%s_u%d", online, u.UserID)
|
||||
dbName := fmt.Sprintf("%s", strings.TrimSpace(u.DisplayName))
|
||||
if dbName == "" {
|
||||
dbName = fmt.Sprintf("user_%d", u.UserID)
|
||||
}
|
||||
updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true)
|
||||
updated, _, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localID := strings.TrimSpace(req.LocalDBID)
|
||||
if localID == "" {
|
||||
localID = "host:" + hostKey
|
||||
}
|
||||
_, _ = l.svcCtx.DBSync.Store().EnsureBinding(dbsync.Binding{
|
||||
TenantID: u.TenantID,
|
||||
UserID: u.UserID,
|
||||
LocalDatabaseID: localID,
|
||||
OnlineDBID: online,
|
||||
ChannelID: ch.ID,
|
||||
DatabaseName: dbName,
|
||||
DisplayName: dbName,
|
||||
Note: "phone-confirm",
|
||||
})
|
||||
_ = l.ensureAgentSyncPerm(updated)
|
||||
_ = l.writeBindAudit("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{
|
||||
"phone": phone, "user_id": u.UserID, "channel_id": ch.ID, "online_db_id": online,
|
||||
"phone": phone, "user_id": u.UserID, "channel_id": updated.ChannelID, "online_db_id": updated.OnlineDBID,
|
||||
})
|
||||
return &PhoneConfirmResp{
|
||||
OK: true,
|
||||
|
||||
Reference in New Issue
Block a user