Files
ai_site/platform/internal/logic/applogic/bind_flow.go
whm cb76824e94 fix: start system default sync channels by default
Create and restart paths enable IsSystemDefault channels; SyncPage auto-starts after save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:50:27 +08:00

404 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}