Files
ai_site/platform/internal/logic/applogic/yuheng_ticket.go
whm 43dc708f1e feat: Z38 grant Yuheng agents ready-to-use access
Complete permissions during every bind and token path, expose effective access in the console, and keep the platform return path globally available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:11:13 +08:00

245 lines
8.0 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 (
"fmt"
"strings"
"aijianzhan/platform/internal/agentcap"
"aijianzhan/platform/internal/agentstore"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/schema"
"aijianzhan/platform/internal/tenantperm"
"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, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket")
if err != nil {
return nil, err
}
// Z12h换票时顺带自愈本租户挂死通道的 Binding/Agent
if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, u.TenantID); hErr == nil {
if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID); gErr == nil && refreshed != nil {
acc = refreshed
}
}
acc, err = l.ensureYuhengAgentPerms(acc)
if err != nil {
return nil, err
}
// 补权成功后才轮换密钥,禁止“绑定成功但业务接口 403”。
secret, err := l.svcCtx.Agents.RotateSecret(l.ctx, acc.TenantID, acc.AgentID)
if err != nil {
return nil, fmt.Errorf("rotate client_secret: %w", 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
}
var yuhengAgentPerms = []string{
authx.Perm读取模块,
authx.Perm写入模块,
authx.Perm发布模块,
authx.Perm查询数据,
authx.Perm新增数据,
authx.Perm导入数据,
authx.Perm上传文件,
authx.Perm下载文件,
authx.Perm数据同步,
}
// ensureYuhengAgentPerms 以并集方式补齐建站权限包,保留账号已有权限。
func (l *AuthLogic) ensureYuhengAgentPerms(acc *agentstore.Account) (*agentstore.Account, error) {
if acc == nil {
return nil, fmt.Errorf("agent account unavailable")
}
perms := append([]string{}, acc.Perms...)
seen := make(map[string]struct{}, len(perms)+len(yuhengAgentPerms))
for _, p := range acc.Perms {
seen[authx.NormalizePerm(p)] = struct{}{}
}
changed := acc.Status != agentstore.StatusActive
for _, p := range yuhengAgentPerms {
if _, ok := seen[p]; ok {
continue
}
perms = append(perms, p)
seen[p] = struct{}{}
changed = true
}
if !changed {
return acc, nil
}
perms = authx.NormalizePerms(perms)
if err := tenantperm.MustAllow(l.ctx, l.svcCtx.TenantPerm, acc.TenantID, perms); err != nil {
return nil, fmt.Errorf("绑定授权超出公司权限额度: %w", err)
}
st := agentstore.StatusActive
updated, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{
Status: &st,
Perms: &perms,
})
if err != nil {
return nil, fmt.Errorf("绑定授权失败: %w", err)
}
return updated, nil
}
// bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。
func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, error) {
cfg := l.svcCtx.Config.DBSync
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
if driver == "" {
driver = dbsync.DriverPostgres
}
ch, err := l.svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(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 {
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 = 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)
// 中文昵称可接受:写入 Binding 展示字段;物理库名映射为 user_{id} / 哈希Z35
display := strings.TrimSpace(u.DisplayName)
if display == "" {
display = fmt.Sprintf("user_%d", u.UserID)
}
dbName := schema.SafeSyncDatabaseName(display, acc.AgentID, 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: display, // 可读名(可中文)
DisplayName: display,
Note: note,
})
return updated, nil
}