package applogic import ( "fmt" "strings" "aijianzhan/platform/internal/agentcap" "aijianzhan/platform/internal/agentstore" "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/dbsync" "aijianzhan/platform/internal/types" "aijianzhan/platform/internal/userstore" "aijianzhan/platform/internal/yuhticket" ) type YuhengTicketExchangeReq struct { Ticket string `json:"ticket"` HostKey string `json:"host_key"` // 须与票内一致 LocalDatabaseID string `json:"local_database_id,omitempty"` Name string `json:"name,omitempty"` } type YuhengTicketExchangeResp struct { *types.TokenResp OK bool `json:"ok"` ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` // 仅新注册/轮换时返回一次 Message string `json:"message,omitempty"` } // ExchangeYuhengTicket 宇恒专属:验签凭票 → 绑定落点 → 签发智能体 JWT(免智建账号密码登录)。 func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTicketExchangeResp, error) { cfg := l.svcCtx.Config.Agent.YuhengTicket if !cfg.Enabled { return nil, fmt.Errorf("宇恒凭票未启用(Agent.YuhengTicket.Enabled)") } if strings.TrimSpace(cfg.Secret) == "" { return nil, fmt.Errorf("宇恒凭票 Secret 未配置") } if l.svcCtx.Users == nil || l.svcCtx.Agents == nil || l.svcCtx.DBSync == nil { return nil, fmt.Errorf("bind service unavailable") } claims, err := yuhticket.Verify(req.Ticket, yuhticket.VerifyOpts{ Secret: cfg.Secret, Issuer: cfg.Issuer, Audience: cfg.Audience, }) if err != nil { return nil, err } hostKey := strings.TrimSpace(req.HostKey) if hostKey == "" { hostKey = strings.TrimSpace(claims.HostKey) } if hostKey == "" || hostKey != strings.TrimSpace(claims.HostKey) { return nil, fmt.Errorf("host_key 与凭票不一致") } if l.svcCtx.YuhengJTI != nil { if err := l.svcCtx.YuhengJTI.Consume(claims.JTI, claims.Exp); err != nil { return nil, err } } phone, err := userstore.NormalizePhone(claims.Phone) if err != nil { return nil, fmt.Errorf("凭票手机号无效") } 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("该账号尚未加入公司") } name := strings.TrimSpace(req.Name) if name == "" { name = strings.TrimSpace(claims.Name) } localID := strings.TrimSpace(req.LocalDatabaseID) if localID == "" { localID = strings.TrimSpace(claims.LocalDatabaseID) } acc, secret, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket") if err != nil { return nil, err } if err := l.ensureAgentSyncPerm(acc); err != nil { return nil, err } // 重新加载 perms acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID) if err != nil { return nil, err } token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms) if err != nil { return nil, err } _ = l.svcCtx.Agents.TouchToken(l.ctx, acc.AgentID) capSecret := l.svcCtx.Config.Agent.CapsuleSecret if capSecret == "" { capSecret = l.svcCtx.JWT.AccessSecret } tr := &types.TokenResp{ AccessToken: token, TokenType: "Bearer", ExpiresAt: exp, TenantID: acc.TenantID, UserID: acc.AgentID, Username: acc.ClientID, DisplayName: acc.Name, Role: authx.RoleAgent, AgentKey: agentcap.PublicAgentKey(capSecret, acc.TenantID, acc.AgentID), AgentID: acc.AgentID, Permissions: append([]string{}, acc.Perms...), AppSlugs: append([]string{}, acc.AppSlugs...), } fillAgentSyncOnToken(tr, acc) _ = l.writeBindAudit("yuheng_ticket_exchange", acc.TenantID, acc.AgentID, map[string]any{ "phone": phone, "jti": claims.JTI, "yuheng_user_id": claims.YuhengUserID, }) return &YuhengTicketExchangeResp{ TokenResp: tr, OK: true, ClientID: acc.ClientID, ClientSecret: secret, Message: "凭票换票成功(仅宇恒)", }, nil } func (l *AuthLogic) ensureAgentSyncPerm(acc *agentstore.Account) error { if acc == nil { return nil } has := false for _, p := range acc.Perms { if p == authx.Perm数据同步 { has = true break } } if has && acc.Status == agentstore.StatusActive { return nil } perms := append([]string{}, acc.Perms...) if !has { perms = append(perms, authx.Perm数据同步) } st := agentstore.StatusActive _, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{ Status: &st, Perms: &perms, }) return err } // bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。 func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, string, error) { 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) } var secret string acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey) if err != nil { if name == "" { name = "离线终端 · " + maskDisplayName(u.DisplayName) } created, sec, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey) if regErr != nil { return nil, "", regErr } acc, secret = created, sec } else if acc.TenantID != u.TenantID && acc.Status == agentstore.StatusPending { // 允许 pending 迁公司;已激活异租户拒绝 } else if acc.TenantID != u.TenantID { return nil, "", fmt.Errorf("host_key 已绑定其它公司") } online := fmt.Sprintf("%s_u%d", dbsync.ResolveOnlineDBID("", ch.ID), u.UserID) dbName := 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) if err != nil { return nil, "", err } 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: note, }) return updated, secret, nil }