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>
This commit is contained in:
@@ -59,7 +59,7 @@ postgres://user:pass@127.0.0.1:5432/app_online?sslmode=disable
|
|||||||
- `POST /api/v1/auth/token` → `channel_id` / `online_db_id` / `database_name` / `sync_bound`
|
- `POST /api/v1/auth/token` → `channel_id` / `online_db_id` / `database_name` / `sync_bound`
|
||||||
- `GET /api/v1/agents/me`(智能体 Bearer)
|
- `GET /api/v1/agents/me`(智能体 Bearer)
|
||||||
- `POST /api/v1/auth/yuheng/ticket-exchange`(仅宇恒 HMAC 凭票)
|
- `POST /api/v1/auth/yuheng/ticket-exchange`(仅宇恒 HMAC 凭票)
|
||||||
- `POST /api/v1/auth/yuheng/restore-by-host`(Z34b:按宇恒 `host_key` 恢复已有同步绑定,票内 phone 可空)
|
- `POST /api/v1/auth/yuheng/restore-by-host`(按宇恒 `host_key` 恢复已有绑定并自动补齐建站权限;票内 phone 可空)
|
||||||
|
|
||||||
## 运维高级:手建通道(过渡 / 多机)
|
## 运维高级:手建通道(过渡 / 多机)
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,15 @@ func (l *AuthLogic) IssueClientCredentials(clientID, clientSecret string) (*type
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Z38:宇恒已绑定账号用 client_credentials 换票时自动修复存量缺权。
|
||||||
|
if strings.TrimSpace(acc.HostKey) != "" &&
|
||||||
|
strings.TrimSpace(acc.ChannelID) != "" &&
|
||||||
|
strings.TrimSpace(acc.OnlineDBID) != "" {
|
||||||
|
acc, err = l.ensureYuhengAgentPerms(acc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms)
|
token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ type BindCodeRedeemResp struct {
|
|||||||
OnlineDBID string `json:"online_db_id"`
|
OnlineDBID string `json:"online_db_id"`
|
||||||
DatabaseName string `json:"database_name,omitempty"`
|
DatabaseName string `json:"database_name,omitempty"`
|
||||||
SyncBound bool `json:"sync_bound"`
|
SyncBound bool `json:"sync_bound"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +176,10 @@ func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
updated, err = l.ensureYuhengAgentPerms(updated)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// Z28:回传可换票凭证
|
// Z28:回传可换票凭证
|
||||||
secret, err := l.svcCtx.Agents.RotateSecret(l.ctx, updated.TenantID, updated.AgentID)
|
secret, err := l.svcCtx.Agents.RotateSecret(l.ctx, updated.TenantID, updated.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,7 +198,8 @@ func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp,
|
|||||||
OnlineDBID: updated.OnlineDBID,
|
OnlineDBID: updated.OnlineDBID,
|
||||||
DatabaseName: updated.DatabaseName,
|
DatabaseName: updated.DatabaseName,
|
||||||
SyncBound: true,
|
SyncBound: true,
|
||||||
Message: "绑定成功;请保存 client_id/client_secret 用于换票",
|
Permissions: append([]string{}, updated.Perms...),
|
||||||
|
Message: "绑定并授权完成;请保存 client_id/client_secret 用于换票",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +335,7 @@ type PhoneConfirmResp struct {
|
|||||||
OnlineDBID string `json:"online_db_id"`
|
OnlineDBID string `json:"online_db_id"`
|
||||||
DatabaseName string `json:"database_name,omitempty"`
|
DatabaseName string `json:"database_name,omitempty"`
|
||||||
SyncBound bool `json:"sync_bound"`
|
SyncBound bool `json:"sync_bound"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +393,18 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
|
|||||||
if u.TenantID <= 0 {
|
if u.TenantID <= 0 {
|
||||||
return nil, fmt.Errorf("该账号尚未加入公司")
|
return nil, fmt.Errorf("该账号尚未加入公司")
|
||||||
}
|
}
|
||||||
updated, secret, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
|
updated, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = l.ensureAgentSyncPerm(updated)
|
updated, err = l.ensureYuhengAgentPerms(updated)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{
|
_ = 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,
|
"phone": phone, "user_id": u.UserID, "channel_id": updated.ChannelID, "online_db_id": updated.OnlineDBID,
|
||||||
})
|
})
|
||||||
@@ -404,7 +418,8 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
|
|||||||
OnlineDBID: updated.OnlineDBID,
|
OnlineDBID: updated.OnlineDBID,
|
||||||
DatabaseName: updated.DatabaseName,
|
DatabaseName: updated.DatabaseName,
|
||||||
SyncBound: true,
|
SyncBound: true,
|
||||||
Message: "绑定成功;请保存 client_id/client_secret 用于换票",
|
Permissions: append([]string{}, updated.Perms...),
|
||||||
|
Message: "绑定并授权完成;请保存 client_id/client_secret 用于换票",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,10 +124,7 @@ func (l *AuthLogic) RestoreByYuhengHost(req YuhengRestoreByHostReq) (*YuhengRest
|
|||||||
return nil, restoreNeedBind("host_key 尚未完成同步绑定;请先绑定码/手机号绑定")
|
return nil, restoreNeedBind("host_key 尚未完成同步绑定;请先绑定码/手机号绑定")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := l.ensureAgentSyncPerm(acc); err != nil {
|
acc, err = l.ensureYuhengAgentPerms(acc)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,7 @@ func (l *AuthLogic) RestoreByYuhengHost(req YuhengRestoreByHostReq) (*YuhengRest
|
|||||||
AgentID: acc.AgentID,
|
AgentID: acc.AgentID,
|
||||||
HostKey: hostKey,
|
HostKey: hostKey,
|
||||||
PhoneMasked: phoneMasked,
|
PhoneMasked: phoneMasked,
|
||||||
Message: "已按宇恒账号恢复同步绑定",
|
Message: "已按宇恒账号恢复绑定并授权完成",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"aijianzhan/platform/internal/authx"
|
"aijianzhan/platform/internal/authx"
|
||||||
"aijianzhan/platform/internal/dbsync"
|
"aijianzhan/platform/internal/dbsync"
|
||||||
"aijianzhan/platform/internal/schema"
|
"aijianzhan/platform/internal/schema"
|
||||||
|
"aijianzhan/platform/internal/tenantperm"
|
||||||
"aijianzhan/platform/internal/types"
|
"aijianzhan/platform/internal/types"
|
||||||
"aijianzhan/platform/internal/userstore"
|
"aijianzhan/platform/internal/userstore"
|
||||||
"aijianzhan/platform/internal/yuhticket"
|
"aijianzhan/platform/internal/yuhticket"
|
||||||
@@ -80,7 +81,7 @@ func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTi
|
|||||||
if localID == "" {
|
if localID == "" {
|
||||||
localID = strings.TrimSpace(claims.LocalDatabaseID)
|
localID = strings.TrimSpace(claims.LocalDatabaseID)
|
||||||
}
|
}
|
||||||
acc, secret, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket")
|
acc, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,14 +91,15 @@ func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTi
|
|||||||
acc = refreshed
|
acc = refreshed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := l.ensureAgentSyncPerm(acc); err != nil {
|
acc, err = l.ensureYuhengAgentPerms(acc)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 重新加载 perms
|
|
||||||
acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -130,38 +132,61 @@ func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTi
|
|||||||
OK: true,
|
OK: true,
|
||||||
ClientID: acc.ClientID,
|
ClientID: acc.ClientID,
|
||||||
ClientSecret: secret,
|
ClientSecret: secret,
|
||||||
Message: "凭票换票成功(仅宇恒)",
|
Message: "绑定并授权完成(仅宇恒)",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *AuthLogic) ensureAgentSyncPerm(acc *agentstore.Account) error {
|
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 {
|
if acc == nil {
|
||||||
return nil
|
return nil, fmt.Errorf("agent account unavailable")
|
||||||
}
|
|
||||||
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...)
|
perms := append([]string{}, acc.Perms...)
|
||||||
if !has {
|
seen := make(map[string]struct{}, len(perms)+len(yuhengAgentPerms))
|
||||||
perms = append(perms, authx.Perm数据同步)
|
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
|
st := agentstore.StatusActive
|
||||||
_, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{
|
updated, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{
|
||||||
Status: &st,
|
Status: &st,
|
||||||
Perms: &perms,
|
Perms: &perms,
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("绑定授权失败: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。
|
// bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。
|
||||||
func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, string, error) {
|
func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, error) {
|
||||||
cfg := l.svcCtx.Config.DBSync
|
cfg := l.svcCtx.Config.DBSync
|
||||||
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
|
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
|
||||||
if driver == "" {
|
if driver == "" {
|
||||||
@@ -173,9 +198,8 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID,
|
|||||||
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
|
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("ensure channel: %w", err)
|
return nil, fmt.Errorf("ensure channel: %w", err)
|
||||||
}
|
}
|
||||||
var secret string
|
|
||||||
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
|
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -183,13 +207,14 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID,
|
|||||||
}
|
}
|
||||||
created, sec, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey)
|
created, sec, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey)
|
||||||
if regErr != nil {
|
if regErr != nil {
|
||||||
return nil, "", regErr
|
return nil, regErr
|
||||||
}
|
}
|
||||||
acc, secret = created, sec
|
acc = created
|
||||||
|
_ = sec
|
||||||
} else if acc.TenantID != u.TenantID && acc.Status == agentstore.StatusPending {
|
} else if acc.TenantID != u.TenantID && acc.Status == agentstore.StatusPending {
|
||||||
// 允许 pending 迁公司;已激活异租户拒绝
|
// 允许 pending 迁公司;已激活异租户拒绝
|
||||||
} else if acc.TenantID != u.TenantID {
|
} else if acc.TenantID != u.TenantID {
|
||||||
return nil, "", fmt.Errorf("host_key 已绑定其它公司")
|
return nil, fmt.Errorf("host_key 已绑定其它公司")
|
||||||
}
|
}
|
||||||
online := fmt.Sprintf("%s_u%d", dbsync.ResolveOnlineDBID("", ch.ID), u.UserID)
|
online := fmt.Sprintf("%s_u%d", dbsync.ResolveOnlineDBID("", ch.ID), u.UserID)
|
||||||
// 中文昵称可接受:写入 Binding 展示字段;物理库名映射为 user_{id} / 哈希(Z35)
|
// 中文昵称可接受:写入 Binding 展示字段;物理库名映射为 user_{id} / 哈希(Z35)
|
||||||
@@ -200,12 +225,7 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID,
|
|||||||
dbName := schema.SafeSyncDatabaseName(display, acc.AgentID, 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)
|
updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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 == "" {
|
if localID == "" {
|
||||||
localID = "host:" + hostKey
|
localID = "host:" + hostKey
|
||||||
@@ -220,5 +240,5 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID,
|
|||||||
DisplayName: display,
|
DisplayName: display,
|
||||||
Note: note,
|
Note: note,
|
||||||
})
|
})
|
||||||
return updated, secret, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<script type="module" crossorigin src="/assets/index-C41vYD6l.js"></script>
|
<script type="module" crossorigin src="/assets/index-aOZIXFeC.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CtqUfnO-.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CtqUfnO-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -245,6 +245,23 @@ export function AgentUsersPage(props: {
|
|||||||
title: "角色",
|
title: "角色",
|
||||||
render: (_, r) => r.role_name || r.role_code || "—",
|
render: (_, r) => r.role_name || r.role_code || "—",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "权限",
|
||||||
|
dataIndex: "permissions",
|
||||||
|
width: 260,
|
||||||
|
render: (permissions: string[]) =>
|
||||||
|
(permissions || []).length ? (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{(permissions || []).map((p) => (
|
||||||
|
<Tag key={p} color={["读取模块", "发布模块", "数据同步"].includes(permLabel(p)) ? "blue" : undefined}>
|
||||||
|
{permLabel(p)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">未授权</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "可访问模块",
|
title: "可访问模块",
|
||||||
dataIndex: "app_slugs",
|
dataIndex: "app_slugs",
|
||||||
|
|||||||
@@ -676,7 +676,7 @@ export default function App() {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
onTabChange={onConsoleTabChange}
|
onTabChange={onConsoleTabChange}
|
||||||
onOpenApp={onOpenApp}
|
onOpenApp={onOpenApp}
|
||||||
onSession={(s) => setSession(s)}
|
onSession={persist}
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
clearSession();
|
clearSession();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
BankOutlined,
|
BankOutlined,
|
||||||
CloudSyncOutlined,
|
CloudSyncOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { bindPhone, changePassword, saveSession, type Session } from "./api";
|
import { bindPhone, changePassword, exitPlatformTenant, saveSession, type Session } from "./api";
|
||||||
import { hasEntitlement, isCompanyAdmin, isPlatformAdmin, roleLabel } from "./agentPerms";
|
import { hasEntitlement, isCompanyAdmin, isPlatformAdmin, roleLabel } from "./agentPerms";
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -93,6 +94,7 @@ export function ConsoleLayout({
|
|||||||
const [pwdBusy, setPwdBusy] = useState(false);
|
const [pwdBusy, setPwdBusy] = useState(false);
|
||||||
const [phoneOpen, setPhoneOpen] = useState(false);
|
const [phoneOpen, setPhoneOpen] = useState(false);
|
||||||
const [phoneBusy, setPhoneBusy] = useState(false);
|
const [phoneBusy, setPhoneBusy] = useState(false);
|
||||||
|
const [exitTenantBusy, setExitTenantBusy] = useState(false);
|
||||||
const [pwdForm] = Form.useForm<{ old_password: string; new_password: string; confirm: string }>();
|
const [pwdForm] = Form.useForm<{ old_password: string; new_password: string; confirm: string }>();
|
||||||
const [phoneForm] = Form.useForm<{ phone: string }>();
|
const [phoneForm] = Form.useForm<{ phone: string }>();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@@ -201,7 +203,30 @@ export function ConsoleLayout({
|
|||||||
)}
|
)}
|
||||||
<Tag color="green">{roleLabel(session.role || "管理员")}</Tag>
|
<Tag color="green">{roleLabel(session.role || "管理员")}</Tag>
|
||||||
{isPlatformAdmin(session.role) && session.tenantId > 0 ? (
|
{isPlatformAdmin(session.role) && session.tenantId > 0 ? (
|
||||||
|
<>
|
||||||
<Tag color="orange">管理:{session.tenantName || `公司 #${session.tenantId}`}</Tag>
|
<Tag color="orange">管理:{session.tenantName || `公司 #${session.tenantId}`}</Tag>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
loading={exitTenantBusy}
|
||||||
|
onClick={async () => {
|
||||||
|
setExitTenantBusy(true);
|
||||||
|
try {
|
||||||
|
const next = await exitPlatformTenant(session);
|
||||||
|
saveSession(next);
|
||||||
|
onSession?.(next);
|
||||||
|
onTabChange("platform");
|
||||||
|
message.success("已回到平台工作台");
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
setExitTenantBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回平台工作台
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : session.tenantId > 0 ? (
|
) : session.tenantId > 0 ? (
|
||||||
<Tag>公司 #{session.tenantId}</Tag>
|
<Tag>公司 #{session.tenantId}</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 联调后修改意见 · 宇恒松离线(形态 B)
|
# 联调后修改意见 · 宇恒松离线(形态 B)
|
||||||
|
|
||||||
> 初稿:2026-08-01 · 修订至 **2026-08-07**(+Z37 SyncPage LWW 403 体验)
|
> 初稿:2026-08-01 · 修订至 **2026-08-07**(+Z38 返回上一级/智能体权限)
|
||||||
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0)
|
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0)
|
||||||
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`)
|
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`)
|
||||||
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
|
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
| **Z35 库名非法中文** | **智建已落实(须生产 pull)** | 绑定/AttachSyncBind/heal 纠正;publish 拒非法名;§5.33;**宇恒勿改** |
|
| **Z35 库名非法中文** | **智建已落实(须生产 pull)** | 绑定/AttachSyncBind/heal 纠正;publish 拒非法名;§5.33;**宇恒勿改** |
|
||||||
| **Z36 编辑发布无增量** | **智建已落实(须生产 pull)** | 无增量 + `host_meta.module_name` → `host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`(§5.34) |
|
| **Z36 编辑发布无增量** | **智建已落实(须生产 pull)** | 无增量 + `host_meta.module_name` → `host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`(§5.34) |
|
||||||
| **Z37 SyncPage LWW 403 吓人** | **智建已落实(须生产 pull)** | SyncPage 无冲突队列;中性提示 + 空通道说明;conflicts 403 静默(§5.35) |
|
| **Z37 SyncPage LWW 403 吓人** | **智建已落实(须生产 pull)** | SyncPage 无冲突队列;中性提示 + 空通道说明;conflicts 403 静默(§5.35) |
|
||||||
|
| **Z38 绑定即用 + 返回上一级** | **智建已落实(须生产 pull)** | 绑定自动并集补齐建站权限并回传;超管公司视图全局可返回平台(§5.36) |
|
||||||
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
|
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
|
||||||
|
|
||||||
### 0.0 修改流程(冻结)
|
### 0.0 修改流程(冻结)
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
| **Z35** | 绑定 `database_name=user_{id}`;publish 拒绝中文库名并纠正脏数据;见 §5.33 |
|
| **Z35** | 绑定 `database_name=user_{id}`;publish 拒绝中文库名并纠正脏数据;见 §5.33 |
|
||||||
| **Z36** | merge 无增量:有 `host_meta.module_name` → `publish_mode=host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`;见 §5.34 |
|
| **Z36** | merge 无增量:有 `host_meta.module_name` → `publish_mode=host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`;见 §5.34 |
|
||||||
| **Z37** | SyncPage 无冲突队列;中性 LWW 说明 +「该公司暂无通道」空态;`listSyncConflicts` 403 静默;见 §5.35 |
|
| **Z37** | SyncPage 无冲突队列;中性 LWW 说明 +「该公司暂无通道」空态;`listSyncConflicts` 403 静默;见 §5.35 |
|
||||||
|
| **Z38** | 宇恒各绑定/恢复路径并集补齐 9 项建站权限;回执/Token 可验;全局返回平台;智能体列表展示权限;见 §5.36 |
|
||||||
|
|
||||||
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
|
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@
|
|||||||
|
|
||||||
| 优先级 | 编号 | 项 | 说明 |
|
| 优先级 | 编号 | 项 | 说明 |
|
||||||
|--------|------|----|------|
|
|--------|------|----|------|
|
||||||
| — | — | (Z36/Z37 已合入见 B) | 当前无待开发项;运维见 B′ |
|
| — | — | (Z38 已合入见 B) | 当前无待开发项;运维见 B′ |
|
||||||
|
|
||||||
#### B‴. 智建 · 本次明确不改(Z15)
|
#### B‴. 智建 · 本次明确不改(Z15)
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@
|
|||||||
|
|
||||||
| 优先级 | 项 | 说明 |
|
| 优先级 | 项 | 说明 |
|
||||||
|--------|----|------|
|
|--------|----|------|
|
||||||
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b** + **Z35** + **Z36** + **Z37**;`bash ./restart.sh --pull` |
|
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b** + **Z35** + **Z36** + **Z37** + **Z38**;`bash ./restart.sh --pull` |
|
||||||
| **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 |
|
| **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 |
|
||||||
| **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 |
|
| **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 |
|
||||||
| **P1** | 凭票 Secret | `YuhengTicket.Secret` ≡ `YXD_YUHENG_TICKET_SECRET` |
|
| **P1** | 凭票 Secret | `YuhengTicket.Secret` ≡ `YXD_YUHENG_TICKET_SECRET` |
|
||||||
@@ -1198,9 +1200,64 @@ POST /api/v1/agent/sync/channels/{id}/pull
|
|||||||
2. 该公司确有通道时列表可见;无通道时空态说明清楚,不报权限错误。
|
2. 该公司确有通道时列表可见;无通道时空态说明清楚,不报权限错误。
|
||||||
3. 平台租户页「LWW 覆盖日志」仍可查审计;公司侧 conflicts 接口保持 403(契约不变)。
|
3. 平台租户页「LWW 覆盖日志」仍可查审计;公司侧 conflicts 接口保持 403(契约不变)。
|
||||||
|
|
||||||
|
### 5.36 【Z38 · 2026-08-07】宇恒绑定后须直接可用;超管进公司须能返回上一级
|
||||||
|
|
||||||
|
**状态**:**智建已落实(须生产 pull);宇恒已改提示文案**。
|
||||||
|
|
||||||
|
#### 现象
|
||||||
|
|
||||||
|
1. 宇恒绑定成功后仍提示智能体未开「读取模块」(app.read),不能列模块;用户还要进智建后台编辑权限。该流程不符合产品约定:**绑定成功即完成开通,后续不得依赖管理后台人工补权限**。
|
||||||
|
2. 平台超管点「管理该公司」后进入侧栏「智能体账号 / 数据同步」等页时,**顶栏只有橙色「管理:xxx」Tag**,没有「返回上一级 / 返回平台工作台」;该按钮目前只在「平台工作台 · 公司总览」页内、且仅当仍停在该页时可见。
|
||||||
|
|
||||||
|
#### 根因
|
||||||
|
|
||||||
|
| 点 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| **绑定权限不完整** | 绑定/凭票路径 `ensureAgentSyncPerm` 目前**只保证「数据同步」**;绑定码 `RedeemBindCode` 甚至未调用该补权函数。宇恒建站技能还需模块、数据和文件权限,因此绑定虽然成功,`GET /api/v1/apps` 仍会因缺 `读取模块` 而 403。 |
|
||||||
|
| **返回按钮位置** | `PlatformTenantsPage` 有「返回平台工作台」(`exitPlatformTenant`);`ConsoleLayout` 顶栏在进公司后未放同款按钮,离开公司总览页后用户找不到返回。 |
|
||||||
|
|
||||||
|
#### 智建改(已落实)
|
||||||
|
|
||||||
|
1. **绑定即用(P0 · 必须)**:把 `ensureAgentSyncPerm` 改为语义明确的 `ensureYuhengAgentPerms`(名字可自定),以**并集追加、不得覆盖/删除已有权限**的方式保证宇恒建站权限包:
|
||||||
|
`读取模块`、`写入模块`、`发布模块`、`查询数据`、`新增数据`、`导入数据`、`上传文件`、`下载文件`、`数据同步`。
|
||||||
|
若技能后续支持修改/删除行,再按功能追加 `更新数据` / `删除数据`,不要预先给无关管理权限。
|
||||||
|
2. **覆盖全部绑定/恢复入口(P0)**:以下路径在返回成功、签发/轮换 token 或 secret **之前**均须调用同一补权函数,失败则整体返回失败,不得 `_ =` 静默忽略:
|
||||||
|
- 宇恒凭票换票 `YuhengTicketExchange`;
|
||||||
|
- 手机确认绑定 `ConfirmPhoneBind`;
|
||||||
|
- 绑定码兑换 `RedeemBindCode`;
|
||||||
|
- 换机恢复 `RestoreByYuhengHost`;
|
||||||
|
- 对已有 `sync_bound` 智能体换票/恢复时也须补齐,以自动修复存量缺权账号。
|
||||||
|
3. **回执可验证(P0)**:绑定/换票/恢复响应增加 `permissions`(或 `perms`)数组,返回最终有效权限;token claims 必须包含同一权限集合。成功 message 使用「绑定并授权完成」,不能只写「同步已绑」。
|
||||||
|
4. **存量自愈(P0)**:生产 pull 后,存量宇恒智能体无需逐条后台编辑;其下一次凭票换票、手机号确认或 restore-by-host 自动补权。可另提供一次性迁移/管理命令,但不能把人工后台操作作为正常流程。
|
||||||
|
5. **全局返回(P0)**:`ConsoleLayout`(或所有公司侧页顶栏)在「超管 + 已 enter 某公司」时固定展示按钮 **「返回平台工作台」/「返回上一级」**,调用已有 `exitPlatformTenant`,回到平台公司总览。
|
||||||
|
6. **权限可见性(P1)**:`AgentUsersPage` 增加「权限」列/Tag,展示至少「读取模块、发布模块、数据同步」;这是诊断能力,**不能替代绑定自动授权**。
|
||||||
|
|
||||||
|
#### 落实记录(2026-08-07)
|
||||||
|
|
||||||
|
- `ensureYuhengAgentPerms` 对现有权限做并集追加,保证 9 项宇恒建站权限并激活账号,不删除已有权限;补权写库失败时绑定/换票直接失败。
|
||||||
|
- `ticket-exchange`、手机号确认、绑定码兑换、`restore-by-host` 及已绑定账号的 `client_credentials` 换票均在轮换密钥/签发 Token 前补权;恢复路由已接回,回执返回最终 `permissions`,成功文案统一为「绑定并授权完成」。
|
||||||
|
- `ConsoleLayout` 在平台超管进入公司上下文后固定显示「返回平台工作台」,任意公司侧页面均可退出管理视图。
|
||||||
|
- `AgentUsersPage` 增加权限 Tag 列,便于核对「读取模块、发布模块、数据同步」等有效权限。
|
||||||
|
|
||||||
|
#### 宇恒
|
||||||
|
|
||||||
|
已改缺权提示,明确平台授权未完成;宇恒不再把「后台人工勾选」定义为正常开通步骤。接口成功后直接继续模块操作。
|
||||||
|
|
||||||
|
#### 临时联调绕过(仅 Z38 上线前)
|
||||||
|
|
||||||
|
智建后台人工勾权限仅供当前联调临时解阻,**不得写入正式用户操作流程**。正式流程必须是宇恒绑定成功后直接列模块、生成、发布、导入和查询。
|
||||||
|
|
||||||
|
#### 验收
|
||||||
|
|
||||||
|
1. 新账号从宇恒完成任一绑定方式后,不登录智建后台,token 已含上述权限包;可直接 `GET /apps`、生成/发布、导入、查询和上传/下载。
|
||||||
|
2. 历史仅有「数据同步」的账号,下一次换票/恢复自动补齐权限,无需后台编辑。
|
||||||
|
3. 任一补权写库失败时绑定/换票明确失败,不得返回“绑定成功”后再由业务接口 403。
|
||||||
|
4. 超管「管理该公司」后,无论停在智能体/同步/模块哪一页,顶栏均可一键回平台工作台。
|
||||||
|
5. 智能体列表可见权限,但用户正常使用不依赖该页面。
|
||||||
|
|
||||||
## 6. 联系与附件
|
## 6. 联系与附件
|
||||||
|
|
||||||
- **待改清单(优先看)**:§0.2(**A′ 宇恒接入 restore-by-host · B′ 运维 pull**);**改代码前先写本意见**(§0.0);配合见 **§0.3**
|
- **待改清单(优先看)**:§0.2(当前开发项已清;**B′ 运维 pull**);配合见 **§0.3**
|
||||||
- 方案:`松离线-dbsync方案-最终版.md`(含 2026-08-01 联调建议落地记录)
|
- 方案:`松离线-dbsync方案-最终版.md`(含 2026-08-01 联调建议落地记录)
|
||||||
- 宇恒使用说明:`宇恒-松离线数据同步使用文档.md`(含 Z10 schema/ensure、Z13 绑定)
|
- 宇恒使用说明:`宇恒-松离线数据同步使用文档.md`(含 Z10 schema/ensure、Z13 绑定)
|
||||||
- 开通说明:`docs/数据同步-开通说明.md`(含 **数据恢复**)
|
- 开通说明:`docs/数据同步-开通说明.md`(含 **数据恢复**)
|
||||||
|
|||||||
Reference in New Issue
Block a user