feat: return bind credentials and reuse sync-bound agents on register (Z28)

Phone/bind-code/ticket responses include rotated client_secret; SelfRegister reuses active sync_bound hosts instead of spawning pending; archive coop opinion and keep a living summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-06 21:09:56 +08:00
parent d9d126fbbb
commit e75c6732d6
8 changed files with 1138 additions and 994 deletions

View File

@@ -136,22 +136,43 @@ func (s *MemoryStore) Register(_ context.Context, tenantID int64, name, hostKey
hostKey = strings.TrimSpace(hostKey)
if hostKey != "" {
for _, a := range s.byID {
if a.TenantID == tenantID && a.HostKey == hostKey {
if a.Status == StatusPending {
secret := randomHex(24)
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
if err != nil {
return nil, "", false, err
}
a.SecretHash = string(hash)
if n := strings.TrimSpace(name); n != "" {
a.Name = n
}
cp := cloneAcc(&a.Account)
return &cp, secret, true, nil
}
return nil, "", false, fmt.Errorf("host already registered as %s (status=%s)", a.ClientID, a.Status)
if a.HostKey != hostKey {
continue
}
// Z28已有任意租户下 active+sync_bound → 禁止另起 pending
if a.Status == StatusActive && strings.TrimSpace(a.ChannelID) != "" && strings.TrimSpace(a.OnlineDBID) != "" {
if a.TenantID != tenantID {
return nil, "", false, fmt.Errorf("host_key 已同步绑定其它公司client_id=%s请使用同步绑定凭证换票勿再 SelfRegister", a.ClientID)
}
secret := randomHex(24)
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
if err != nil {
return nil, "", false, err
}
a.SecretHash = string(hash)
if n := strings.TrimSpace(name); n != "" {
a.Name = n
}
cp := cloneAcc(&a.Account)
return &cp, secret, true, nil
}
if a.TenantID != tenantID {
continue
}
if a.Status == StatusPending {
secret := randomHex(24)
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
if err != nil {
return nil, "", false, err
}
a.SecretHash = string(hash)
if n := strings.TrimSpace(name); n != "" {
a.Name = n
}
cp := cloneAcc(&a.Account)
return &cp, secret, true, nil
}
return nil, "", false, fmt.Errorf("host already registered as %s (status=%s)", a.ClientID, a.Status)
}
}
acc, secret, err := s.createLocked(tenantID, 0, CreateInput{
@@ -457,32 +478,42 @@ func (s *PostgresStore) Create(ctx context.Context, tenantID, createdBy int64, i
func (s *PostgresStore) Register(ctx context.Context, tenantID int64, name, hostKey string) (*Account, string, bool, error) {
hostKey = strings.TrimSpace(hostKey)
if hostKey != "" {
var a Account
var last sql.NullTime
err := s.DB.QueryRowContext(ctx, `
SELECT agent_id, tenant_id, name, client_id, COALESCE(host_key,''), COALESCE(role_id,0),
COALESCE(channel_id,''), COALESCE(online_db_id,''), COALESCE(database_name,''),
status, created_by, created_at, last_token_at
FROM platform_meta.agent_accounts WHERE tenant_id=$1 AND host_key=$2`, tenantID, hostKey,
).Scan(&a.AgentID, &a.TenantID, &a.Name, &a.ClientID, &a.HostKey, &a.RoleID,
&a.ChannelID, &a.OnlineDBID, &a.DatabaseName,
&a.Status, &a.CreatedBy, &a.CreatedAt, &last)
if err == nil {
if a.Status == StatusPending {
secret, err := s.RotateSecret(ctx, tenantID, a.AgentID)
// Z28先按 host_key 全局查找,避免已 sync_bound 时另起 pending
if existing, err := s.FindByHostKey(ctx, hostKey); err == nil && existing != nil {
syncBound := strings.TrimSpace(existing.ChannelID) != "" && strings.TrimSpace(existing.OnlineDBID) != ""
if existing.Status == StatusActive && syncBound {
if existing.TenantID != tenantID {
return nil, "", false, fmt.Errorf("host_key 已同步绑定其它公司client_id=%s请使用同步绑定凭证换票勿再 SelfRegister", existing.ClientID)
}
secret, err := s.RotateSecret(ctx, tenantID, existing.AgentID)
if err != nil {
return nil, "", false, err
}
if n := strings.TrimSpace(name); n != "" {
_, _ = s.Update(ctx, tenantID, a.AgentID, UpdateInput{Name: &n})
_, _ = s.Update(ctx, tenantID, existing.AgentID, UpdateInput{Name: &n})
}
acc, err := s.Get(ctx, tenantID, a.AgentID)
acc, err := s.Get(ctx, tenantID, existing.AgentID)
return acc, secret, true, err
}
return nil, "", false, fmt.Errorf("host already registered as %s (status=%s)", a.ClientID, a.Status)
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, "", false, err
if existing.TenantID == tenantID && existing.Status == StatusPending {
secret, err := s.RotateSecret(ctx, tenantID, existing.AgentID)
if err != nil {
return nil, "", false, err
}
if n := strings.TrimSpace(name); n != "" {
_, _ = s.Update(ctx, tenantID, existing.AgentID, UpdateInput{Name: &n})
}
acc, err := s.Get(ctx, tenantID, existing.AgentID)
return acc, secret, true, err
}
if existing.TenantID == tenantID {
return nil, "", false, fmt.Errorf("host already registered as %s (status=%s)", existing.ClientID, existing.Status)
}
} else if err != nil {
// FindByHostKey 未找到 → 继续新建 pending
if !strings.Contains(err.Error(), "not found") {
return nil, "", false, err
}
}
}
acc, secret, err := s.insert(ctx, tenantID, 0, CreateInput{

View File

@@ -26,7 +26,7 @@ var Catalog = []Entry{
{Method: "GET", Path: "/api/v1/auth/me", OperationID: "authMe", Summary: "当前登录用户资料", Group: "auth"},
{Method: "PUT", Path: "/api/v1/auth/phone", OperationID: "bindPhone", Summary: "绑定或更换手机号", Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/token", OperationID: "authToken", Summary: "服务/智能体签发 JWT含 client_credentials", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/agent/register", OperationID: "agentSelfRegister", Summary: "宿主首次连接自注册pending", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/agent/register", OperationID: "agentSelfRegister", Summary: "宿主自注册;已 sync_bound 则复用并轮换密钥Z28", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/bind-code/redeem", OperationID: "redeemBindCode", Summary: "绑定码兑换host_key+code", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/bind/phone-lookup", OperationID: "bindPhoneLookup", Summary: "同号探测(不绑定)", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/bind/phone-confirm", OperationID: "bindPhoneConfirm", Summary: "同号确认后绑定", Public: true, Group: "auth"},

View File

@@ -146,7 +146,12 @@ func (l *AuthLogic) SelfRegisterAgent(req *types.AgentSelfRegisterReq) (*types.A
}
msg := "已登记为 pending请管理员在控制台分配角色并启用后再换票"
if reused {
msg = "已存在 pending 登记,已轮换 client_secret仍须管理员分配角色并启用"
syncBound := strings.TrimSpace(acc.ChannelID) != "" && strings.TrimSpace(acc.OnlineDBID) != ""
if acc.Status == agentstore.StatusActive && syncBound {
msg = "已存在同步绑定账号,已轮换 client_secret请直接用返回的 client_id/client_secret 换票,勿再走绑定表单"
} else {
msg = "已存在 pending 登记,已轮换 client_secret仍须管理员分配角色并启用"
}
}
return &types.AgentSelfRegisterResp{
Account: *acc,

View File

@@ -113,6 +113,7 @@ type BindCodeRedeemResp struct {
TenantID int64 `json:"tenant_id"`
AgentID int64 `json:"agent_id"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // Z28绑定成功回传便于直接换票
ChannelID string `json:"channel_id"`
OnlineDBID string `json:"online_db_id"`
DatabaseName string `json:"database_name,omitempty"`
@@ -158,6 +159,11 @@ func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp,
if err != nil {
return nil, err
}
// Z28回传可换票凭证
secret, err := l.svcCtx.Agents.RotateSecret(l.ctx, updated.TenantID, updated.AgentID)
if err != nil {
return nil, fmt.Errorf("rotate client_secret: %w", err)
}
_ = l.writeBindAudit("bind_code_redeem", updated.TenantID, updated.AgentID, map[string]any{
"code": bc.Code, "channel_id": bc.ChannelID, "online_db_id": online,
})
@@ -166,11 +172,12 @@ func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp,
TenantID: updated.TenantID,
AgentID: updated.AgentID,
ClientID: updated.ClientID,
ClientSecret: secret,
ChannelID: updated.ChannelID,
OnlineDBID: updated.OnlineDBID,
DatabaseName: updated.DatabaseName,
SyncBound: true,
Message: "绑定成功",
Message: "绑定成功;请保存 client_id/client_secret 用于换票",
}, nil
}
@@ -300,6 +307,8 @@ type PhoneConfirmResp struct {
OK bool `json:"ok"`
TenantID int64 `json:"tenant_id"`
AgentID int64 `json:"agent_id"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // Z28绑定成功回传
ChannelID string `json:"channel_id"`
OnlineDBID string `json:"online_db_id"`
DatabaseName string `json:"database_name,omitempty"`
@@ -361,7 +370,7 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
if u.TenantID <= 0 {
return nil, fmt.Errorf("该账号尚未加入公司")
}
updated, _, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
updated, secret, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
if err != nil {
return nil, err
}
@@ -373,11 +382,13 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
OK: true,
TenantID: updated.TenantID,
AgentID: updated.AgentID,
ClientID: updated.ClientID,
ClientSecret: secret,
ChannelID: updated.ChannelID,
OnlineDBID: updated.OnlineDBID,
DatabaseName: updated.DatabaseName,
SyncBound: true,
Message: "绑定成功",
Message: "绑定成功;请保存 client_id/client_secret 用于换票",
}, nil
}

View File

@@ -199,6 +199,11 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID,
if err != nil {
return nil, "", err
}
// Z28绑定成功后轮换密钥并回传宇恒可直接换票无需再 SelfRegister pending
secret, err = l.svcCtx.Agents.RotateSecret(l.ctx, updated.TenantID, updated.AgentID)
if err != nil {
return nil, "", fmt.Errorf("rotate client_secret: %w", err)
}
if localID == "" {
localID = "host:" + hostKey
}