Files
ai_site/platform/internal/logic/applogic/yuheng_restore.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

246 lines
7.5 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 (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"aijianzhan/platform/internal/agentcap"
"aijianzhan/platform/internal/agentstore"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/types"
"aijianzhan/platform/internal/yuhticket"
)
// YuhengRestoreByHostReq Z34b仅凭宇恒账号 host_key 恢复已有同步绑定。
type YuhengRestoreByHostReq struct {
Ticket string `json:"ticket"`
HostKey string `json:"host_key"` // 须与票内一致(= 宇恒用户 _id
}
// YuhengRestoreByHostResp 与绑定成功对齐,便于宇恒 apply_bind_success。
type YuhengRestoreByHostResp struct {
*types.TokenResp
OK bool `json:"ok"`
SyncBound bool `json:"sync_bound"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
OnlineDBID string `json:"online_db_id,omitempty"`
TenantID int64 `json:"tenant_id,omitempty"`
AgentID int64 `json:"agent_id,omitempty"`
HostKey string `json:"host_key,omitempty"`
PhoneMasked string `json:"phone_masked,omitempty"`
Message string `json:"message,omitempty"`
}
// RestoreBindError 映射 HTTP 状态404 need_bind / 401 / 403
type RestoreBindError struct {
HTTPStatus int
Message string
NeedBind bool
}
func (e *RestoreBindError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func restoreNeedBind(msg string) error {
return &RestoreBindError{HTTPStatus: http.StatusNotFound, Message: msg, NeedBind: true}
}
func restoreUnauthorized(msg string) error {
return &RestoreBindError{HTTPStatus: http.StatusUnauthorized, Message: msg}
}
func restoreForbidden(msg string) error {
return &RestoreBindError{HTTPStatus: http.StatusForbidden, Message: msg}
}
var onlineUserSuffix = regexp.MustCompile(`_u(\d+)$`)
// RestoreByYuhengHost Z34b验签凭票 → FindByHostKey → 已 sync_bound 则轮换密钥并回传落点。
func (l *AuthLogic) RestoreByYuhengHost(req YuhengRestoreByHostReq) (*YuhengRestoreByHostResp, error) {
cfg := l.svcCtx.Config.Agent.YuhengTicket
if !cfg.Enabled {
return nil, restoreUnauthorized("宇恒凭票未启用Agent.YuhengTicket.Enabled")
}
if strings.TrimSpace(cfg.Secret) == "" {
return nil, restoreUnauthorized("宇恒凭票 Secret 未配置")
}
if l.svcCtx.Agents == 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,
AllowEmptyPhone: true,
AllowedScopes: []string{"", "sync_bind", "sync_restore"},
})
if err != nil {
msg := err.Error()
if strings.Contains(msg, "expired") || strings.Contains(msg, "signature") ||
strings.Contains(msg, "already used") || strings.Contains(msg, "invalid") ||
strings.Contains(msg, "not allowed") || strings.Contains(msg, "mismatch") ||
strings.Contains(msg, "未配置") {
return nil, restoreUnauthorized(msg)
}
return nil, restoreUnauthorized(msg)
}
hostKey := strings.TrimSpace(req.HostKey)
if hostKey == "" {
hostKey = strings.TrimSpace(claims.HostKey)
}
if hostKey == "" || hostKey != strings.TrimSpace(claims.HostKey) {
return nil, restoreForbidden("host_key 与凭票不一致")
}
if yid := strings.TrimSpace(claims.YuhengUserID); yid != "" && yid != hostKey {
return nil, restoreForbidden("yuheng_user_id 与 host_key 不一致")
}
if l.svcCtx.YuhengJTI != nil {
if err := l.svcCtx.YuhengJTI.Consume(claims.JTI, claims.Exp); err != nil {
return nil, restoreUnauthorized(err.Error())
}
}
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
if err != nil || acc == nil {
return nil, restoreNeedBind("无此 host_key 同步绑定;请先绑定码/手机号绑定")
}
// 通道误删时先自愈再判断 sync_bound
if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, acc.TenantID); hErr == nil {
if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID); gErr == nil && refreshed != nil {
acc = refreshed
}
}
syncBound := strings.TrimSpace(acc.ChannelID) != "" && strings.TrimSpace(acc.OnlineDBID) != ""
if acc.Status != agentstore.StatusActive || !syncBound {
return nil, restoreNeedBind("host_key 尚未完成同步绑定;请先绑定码/手机号绑定")
}
acc, err = l.ensureYuhengAgentPerms(acc)
if err != nil {
return nil, err
}
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)
phoneMasked := maskPhoneDigits(strings.TrimSpace(claims.Phone))
if phoneMasked == "" {
phoneMasked = l.phoneMaskedForAgent(acc)
}
_ = l.writeBindAudit("yuheng_restore_by_host", acc.TenantID, acc.AgentID, map[string]any{
"host_key": hostKey, "jti": claims.JTI, "yuheng_user_id": claims.YuhengUserID,
})
return &YuhengRestoreByHostResp{
TokenResp: tr,
OK: true,
SyncBound: true,
ClientID: acc.ClientID,
ClientSecret: secret,
ChannelID: acc.ChannelID,
OnlineDBID: acc.OnlineDBID,
TenantID: acc.TenantID,
AgentID: acc.AgentID,
HostKey: hostKey,
PhoneMasked: phoneMasked,
Message: "已按宇恒账号恢复绑定并授权完成",
}, nil
}
func maskPhoneDigits(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return ""
}
// 11 位国内号135****1944
if len(phone) == 11 {
return phone[:3] + "****" + phone[7:]
}
keep := 3
if len(phone) < keep*2 {
return phone[:1] + "****"
}
return phone[:keep] + "****" + phone[len(phone)-keep:]
}
func (l *AuthLogic) phoneMaskedForAgent(acc *agentstore.Account) string {
if acc == nil || l.svcCtx.Users == nil {
return ""
}
// 优先 online_db_id 后缀 _u{userID}
if m := onlineUserSuffix.FindStringSubmatch(strings.TrimSpace(acc.OnlineDBID)); len(m) == 2 {
var uid int64
_, _ = fmt.Sscanf(m[1], "%d", &uid)
if uid > 0 {
if u, err := l.svcCtx.Users.GetByID(l.ctx, uid); err == nil && u != nil {
return maskPhoneDigits(u.Phone)
}
}
}
// 回落:同租户 Binding 上挂该 online_db_id 的用户
if l.svcCtx.DBSync == nil {
return ""
}
list, err := l.svcCtx.DBSync.Store().ListBindingsFiltered(acc.TenantID, 0, "")
if err != nil {
return ""
}
online := strings.TrimSpace(acc.OnlineDBID)
for _, b := range list {
if strings.TrimSpace(b.OnlineDBID) != online || b.UserID <= 0 {
continue
}
if u, err := l.svcCtx.Users.GetByID(l.ctx, b.UserID); err == nil && u != nil {
if m := maskPhoneDigits(u.Phone); m != "" {
return m
}
}
}
return ""
}
// AsRestoreBindError 供 handler 取 HTTP 状态。
func AsRestoreBindError(err error) *RestoreBindError {
var e *RestoreBindError
if errors.As(err, &e) {
return e
}
return nil
}