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:
whm
2026-08-05 15:09:49 +08:00
parent f90245db9c
commit d195aa4804
19 changed files with 957 additions and 96 deletions

View File

@@ -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,