package applogic import ( "encoding/json" "fmt" "strings" "time" "aijianzhan/platform/internal/agentstore" "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/bindcodestore" "aijianzhan/platform/internal/dbsync" "aijianzhan/platform/internal/smsstore" "aijianzhan/platform/internal/userstore" ) // AgentMe Z12b:智能体自查绑定(无需「管理智能体」)。 func (l *AuthLogic) AgentMe() (*agentstore.Account, error) { if !authx.IsAgent(authx.Role(l.ctx)) { return nil, fmt.Errorf("仅智能体可访问") } if l.svcCtx.Agents == nil { return nil, fmt.Errorf("agent store unavailable") } tid := authx.TenantID(l.ctx) aid := authx.UserID(l.ctx) acc, err := l.svcCtx.Agents.Get(l.ctx, tid, aid) if err != nil { return nil, err } return acc, nil } type BindCodeCreateReq struct { ChannelID string `json:"channel_id"` OnlineDBID string `json:"online_db_id"` DatabaseName string `json:"database_name"` MaxUses int `json:"max_uses"` ExpiresHours int `json:"expires_hours"` // 0=168h Note string `json:"note"` } func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCode, error) { if l.svcCtx.BindCodes == nil { return nil, fmt.Errorf("bind code store unavailable") } tid := authx.TenantID(l.ctx) if tid <= 0 { return nil, fmt.Errorf("未加入公司") } channelID := strings.TrimSpace(req.ChannelID) online := strings.TrimSpace(req.OnlineDBID) dbName := strings.TrimSpace(req.DatabaseName) if channelID == "" && l.svcCtx.DBSync != nil { if ch, err := l.svcCtx.DBSync.Store().FindSystemDefaultChannel(tid); err == nil && ch != nil { channelID = ch.ID if online == "" { online = dbsync.ResolveOnlineDBID("", ch.ID) } _ = l.svcCtx.DBSync.StartChannel(ch.ID) // 已有默认同步通道也保持运行中 } else { // 尝试创建默认同步通道 cfg := l.svcCtx.Config.DBSync driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) if driver == "" { driver = dbsync.DriverPostgres } saved, err := l.svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(dbsync.DefaultChannelOpts{ TenantID: tid, RemoteDriver: driver, RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), }) if err != nil { return nil, fmt.Errorf("无默认同步通道:%w", err) } channelID = saved.ID if online == "" { online = dbsync.ResolveOnlineDBID("", saved.ID) } } } if channelID == "" { return nil, fmt.Errorf("请先启用智能体以生成默认同步通道,或指定 channel_id") } if online == "" { online = dbsync.ResolveOnlineDBID("", channelID) } exp := time.Duration(req.ExpiresHours) * time.Hour return l.svcCtx.BindCodes.Create(l.ctx, tid, authx.UserID(l.ctx), bindcodestore.CreateInput{ ChannelID: channelID, OnlineDBID: online, DatabaseName: dbName, MaxUses: req.MaxUses, ExpiresIn: exp, Note: req.Note, }) } func (l *AuthLogic) ListBindCodes() ([]bindcodestore.BindCode, error) { if l.svcCtx.BindCodes == nil { return nil, fmt.Errorf("bind code store unavailable") } tid := authx.TenantID(l.ctx) return l.svcCtx.BindCodes.List(l.ctx, tid) } func (l *AuthLogic) RevokeBindCode(code string) error { if l.svcCtx.BindCodes == nil { return fmt.Errorf("bind code store unavailable") } return l.svcCtx.BindCodes.Revoke(l.ctx, authx.TenantID(l.ctx), code) } type BindCodeRedeemReq struct { Code string `json:"code"` HostKey string `json:"host_key"` Name string `json:"name"` // 可选:无 agent 时注册用 } type BindCodeRedeemResp struct { OK bool `json:"ok"` TenantID int64 `json:"tenant_id"` AgentID int64 `json:"agent_id"` ClientID string `json:"client_id,omitempty"` ChannelID string `json:"channel_id"` OnlineDBID string `json:"online_db_id"` DatabaseName string `json:"database_name,omitempty"` SyncBound bool `json:"sync_bound"` Message string `json:"message,omitempty"` } func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp, error) { if l.svcCtx.BindCodes == nil || l.svcCtx.Agents == nil { return nil, fmt.Errorf("bind service unavailable") } code := strings.TrimSpace(req.Code) hostKey := strings.TrimSpace(req.HostKey) if code == "" || hostKey == "" { return nil, fmt.Errorf("code and host_key required") } bc, err := l.svcCtx.BindCodes.Redeem(l.ctx, code) if err != nil { return nil, err } acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey) if err != nil { name := strings.TrimSpace(req.Name) if name == "" { name = "离线终端" } created, secret, _, regErr := l.svcCtx.Agents.Register(l.ctx, bc.TenantID, name, hostKey) if regErr != nil { return nil, fmt.Errorf("register agent: %w", regErr) } _ = secret acc = created } online := strings.TrimSpace(bc.OnlineDBID) if online == "" { online = dbsync.ResolveOnlineDBID("", bc.ChannelID) } dbName := strings.TrimSpace(bc.DatabaseName) if dbName == "" { dbName = fmt.Sprintf("agent_%d", acc.AgentID) } updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, bc.TenantID, bc.ChannelID, online, dbName, true) if err != nil { return nil, err } _ = l.writeBindAudit("bind_code_redeem", updated.TenantID, updated.AgentID, map[string]any{ "code": bc.Code, "channel_id": bc.ChannelID, "online_db_id": online, }) return &BindCodeRedeemResp{ OK: true, TenantID: updated.TenantID, AgentID: updated.AgentID, ClientID: updated.ClientID, ChannelID: updated.ChannelID, OnlineDBID: updated.OnlineDBID, DatabaseName: updated.DatabaseName, SyncBound: true, Message: "绑定成功", }, nil } type PhoneLookupReq struct { Phone string `json:"phone"` } type PhoneLookupResp struct { Exists bool `json:"exists"` TenantID int64 `json:"tenant_id,omitempty"` TenantName string `json:"tenant_name,omitempty"` 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 { name = strings.TrimSpace(name) if name == "" { return "***" } r := []rune(name) if len(r) == 1 { return string(r[0]) + "*" } if len(r) == 2 { return string(r[0]) + "*" } return string(r[0]) + strings.Repeat("*", len(r)-2) + string(r[len(r)-1]) } 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: "手机号格式不正确", SMSRequiredUnlessAttested: smsReq}, nil } u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone) if err != nil || u == nil { return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员", SMSRequiredUnlessAttested: smsReq}, nil } if u.TenantID <= 0 { 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, SMSRequiredUnlessAttested: smsReq, Message: msg, }, nil } type PhoneConfirmReq struct { Phone string `json:"phone"` HostKey string `json:"host_key"` 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 { OK bool `json:"ok"` TenantID int64 `json:"tenant_id"` AgentID int64 `json:"agent_id"` ChannelID string `json:"channel_id"` OnlineDBID string `json:"online_db_id"` DatabaseName string `json:"database_name,omitempty"` SyncBound bool `json:"sync_bound"` 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)") } if l.svcCtx.Users == nil || l.svcCtx.Agents == nil || l.svcCtx.DBSync == nil { return nil, fmt.Errorf("bind service unavailable") } phone, err := userstore.NormalizePhone(req.Phone) 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") } u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone) if err != nil || u == nil { return nil, fmt.Errorf("无此成员;请使用绑定码") } if u.TenantID <= 0 { return nil, fmt.Errorf("该账号尚未加入公司") } updated, _, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm") if err != nil { return nil, err } _ = l.ensureAgentSyncPerm(updated) _ = l.writeBindAudit("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{ "phone": phone, "user_id": u.UserID, "channel_id": updated.ChannelID, "online_db_id": updated.OnlineDBID, }) return &PhoneConfirmResp{ OK: true, TenantID: updated.TenantID, AgentID: updated.AgentID, ChannelID: updated.ChannelID, OnlineDBID: updated.OnlineDBID, DatabaseName: updated.DatabaseName, SyncBound: true, Message: "绑定成功", }, nil } func (l *AuthLogic) writeBindAudit(action string, tenantID, agentID int64, detail map[string]any) error { if l.svcCtx.Audit == nil { return nil } if detail == nil { detail = map[string]any{} } detail["agent_id"] = agentID b, _ := json.Marshal(detail) return l.svcCtx.Audit.Log(l.ctx, tenantID, authx.UserID(l.ctx), action, string(b)) }