feat: Z34b restore-by-host + harden web nginx DNS race
Add HMAC restore-by-host for phone-less rebind; resolve gateway at request time and recreate web after stack up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -113,6 +113,7 @@ services:
|
|||||||
web:
|
web:
|
||||||
image: ${DOCKER_BASE_REGISTRY:-docker.m.daocloud.io/library}/nginx:1.27-alpine
|
image: ${DOCKER_BASE_REGISTRY:-docker.m.daocloud.io/library}/nginx:1.27-alpine
|
||||||
hostname: web
|
hostname: web
|
||||||
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- gateway
|
- gateway
|
||||||
ports:
|
ports:
|
||||||
@@ -123,3 +124,7 @@ services:
|
|||||||
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
|
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases:
|
||||||
|
- web
|
||||||
|
|||||||
@@ -59,6 +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 可空)
|
||||||
|
|
||||||
## 运维高级:手建通道(过渡 / 多机)
|
## 运维高级:手建通道(过渡 / 多机)
|
||||||
|
|
||||||
@@ -99,19 +100,6 @@ Binding:`POST /api/v1/admin/sync/bindings` 登记 `local_database_id → onlin
|
|||||||
**空表也要两侧建齐**:本机空表 → `POST .../schema/ensure`;线上空表 → `POST .../schema`(或 pull 的 `columns`)在本机 `CREATE IF NOT EXISTS`。仅靠 outbox 行 push **不会**带上空表。
|
**空表也要两侧建齐**:本机空表 → `POST .../schema/ensure`;线上空表 → `POST .../schema`(或 pull 的 `columns`)在本机 `CREATE IF NOT EXISTS`。仅靠 outbox 行 push **不会**带上空表。
|
||||||
控制台「查看线上表」可 **删表**(`POST .../admin/sync/channels/{id}/drop-table`):只删当前查看侧,**不同步** `DROP` 到另一侧;本机仍有同名表时下次 push/ensure 可能再建回来。
|
控制台「查看线上表」可 **删表**(`POST .../admin/sync/channels/{id}/drop-table`):只删当前查看侧,**不同步** `DROP` 到另一侧;本机仍有同名表时下次 push/ensure 可能再建回来。
|
||||||
|
|
||||||
## 数据恢复(恢复上次同步 · 已落实)
|
|
||||||
|
|
||||||
成功同步后(agent **自动/手动 push**、通道 drain、控制台「同步修复」),平台对**线上库**打滚动快照(`latest` + `previous`,约 30 秒防抖)。
|
|
||||||
|
|
||||||
| 操作 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| SyncPage「数据恢复」 | 将线上库恢复为所选快照(写入快照行,并删除快照中不存在的行) |
|
|
||||||
| 本机 | 平台不直连宇恒 SQLite;恢复线上后,终端下次同步/指纹 pull 可从线上补回 |
|
|
||||||
| 无快照 | 部署后须先完成至少一次成功同步;按钮会提示「尚无成功同步快照」 |
|
|
||||||
| 误删后又自动同步成功 | 最新快照可能已含删除后状态 → 确认框选「上一代」 |
|
|
||||||
|
|
||||||
API:`GET .../admin/sync/channels/{id}/checkpoint`、`POST .../admin/sync/channels/{id}/restore`(`confirm=true`,`which=latest|previous`)。详见联调意见 §5.13。
|
|
||||||
|
|
||||||
## 谁能看什么
|
## 谁能看什么
|
||||||
|
|
||||||
| 角色 | 可见 |
|
| 角色 | 可见 |
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func main() {
|
|||||||
"/api/v1/auth/bind/phone-confirm",
|
"/api/v1/auth/bind/phone-confirm",
|
||||||
"/api/v1/auth/bind/policy",
|
"/api/v1/auth/bind/policy",
|
||||||
"/api/v1/auth/yuheng/ticket-exchange",
|
"/api/v1/auth/yuheng/ticket-exchange",
|
||||||
|
"/api/v1/auth/yuheng/restore-by-host",
|
||||||
"/api/v1/auth/sms/send",
|
"/api/v1/auth/sms/send",
|
||||||
"/api/v1/meta/",
|
"/api/v1/meta/",
|
||||||
"/api/v1/public/",
|
"/api/v1/public/",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var Catalog = []Entry{
|
|||||||
{Method: "POST", Path: "/api/v1/auth/bind/phone-confirm", OperationID: "bindPhoneConfirm", Summary: "同号确认后绑定", Public: true, Group: "auth"},
|
{Method: "POST", Path: "/api/v1/auth/bind/phone-confirm", OperationID: "bindPhoneConfirm", Summary: "同号确认后绑定", Public: true, Group: "auth"},
|
||||||
{Method: "GET", Path: "/api/v1/auth/bind/policy", OperationID: "bindPolicy", Summary: "绑定策略(试运行/短信/凭票开关)", Public: true, Group: "auth"},
|
{Method: "GET", Path: "/api/v1/auth/bind/policy", OperationID: "bindPolicy", Summary: "绑定策略(试运行/短信/凭票开关)", Public: true, Group: "auth"},
|
||||||
{Method: "POST", Path: "/api/v1/auth/yuheng/ticket-exchange", OperationID: "yuhengTicketExchange", Summary: "宇恒凭票免登录换票(仅宇恒)", Public: true, Group: "auth"},
|
{Method: "POST", Path: "/api/v1/auth/yuheng/ticket-exchange", OperationID: "yuhengTicketExchange", Summary: "宇恒凭票免登录换票(仅宇恒)", Public: true, Group: "auth"},
|
||||||
|
{Method: "POST", Path: "/api/v1/auth/yuheng/restore-by-host", OperationID: "yuhengRestoreByHost", Summary: "按宇恒 host_key 恢复同步绑定(Z34b,phone 可空)", Public: true, Group: "auth"},
|
||||||
{Method: "POST", Path: "/api/v1/auth/invites/accept", OperationID: "acceptInvite", Summary: "接受邀请加入租户", Group: "auth"},
|
{Method: "POST", Path: "/api/v1/auth/invites/accept", OperationID: "acceptInvite", Summary: "接受邀请加入租户", Group: "auth"},
|
||||||
{Method: "POST", Path: "/api/v1/tenants", OperationID: "createTenant", Summary: "pending 用户创建自己的公司", Group: "auth"},
|
{Method: "POST", Path: "/api/v1/tenants", OperationID: "createTenant", Summary: "pending 用户创建自己的公司", Group: "auth"},
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ type AgentConf struct {
|
|||||||
YuhengTicket YuhengTicketConf `json:",optional"`
|
YuhengTicket YuhengTicketConf `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// YuhengTicketConf 宇恒 → 智建 短时凭票(scope=sync_bind)。
|
// YuhengTicketConf 宇恒 → 智建 短时凭票(scope=sync_bind | sync_restore)。
|
||||||
type YuhengTicketConf struct {
|
type YuhengTicketConf struct {
|
||||||
Enabled bool `json:",optional"`
|
Enabled bool `json:",optional"`
|
||||||
Secret string `json:",optional"` // 与宇恒共享;生产必换
|
Secret string `json:",optional"` // 与宇恒共享;生产必换
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
|
|||||||
{Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-confirm", Handler: rl(bindPhoneConfirmHandler(svcCtx))},
|
{Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-confirm", Handler: rl(bindPhoneConfirmHandler(svcCtx))},
|
||||||
{Method: http.MethodGet, Path: "/api/v1/auth/bind/policy", Handler: rl(bindPolicyHandler(svcCtx))},
|
{Method: http.MethodGet, Path: "/api/v1/auth/bind/policy", Handler: rl(bindPolicyHandler(svcCtx))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/auth/yuheng/ticket-exchange", Handler: rl(yuhengTicketExchangeHandler(svcCtx))},
|
{Method: http.MethodPost, Path: "/api/v1/auth/yuheng/ticket-exchange", Handler: rl(yuhengTicketExchangeHandler(svcCtx))},
|
||||||
|
{Method: http.MethodPost, Path: "/api/v1/auth/yuheng/restore-by-host", Handler: rl(yuhengRestoreByHostHandler(svcCtx))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: rl(registerHandler(svcCtx))},
|
{Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: rl(registerHandler(svcCtx))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/auth/login", Handler: rl(loginHandler(svcCtx))},
|
{Method: http.MethodPost, Path: "/api/v1/auth/login", Handler: rl(loginHandler(svcCtx))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: rl(sendLoginSMSHandler(svcCtx))},
|
{Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: rl(sendLoginSMSHandler(svcCtx))},
|
||||||
@@ -146,8 +147,6 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
|
|||||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/conflicts", Handler: chain(syncConflictsHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
{Method: http.MethodGet, Path: "/api/v1/admin/sync/conflicts", Handler: chain(syncConflictsHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/conflicts/:id/resolve", Handler: chain(syncResolveConflictHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
{Method: http.MethodPost, Path: "/api/v1/admin/sync/conflicts/:id/resolve", Handler: chain(syncResolveConflictHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/reconcile", Handler: chain(syncReconcileHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/reconcile", Handler: chain(syncReconcileHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/checkpoint", Handler: chain(syncCheckpointMetaHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
|
||||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/restore", Handler: chain(syncRestoreHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
|
||||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/ingest", Handler: chain(syncIngestHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/ingest", Handler: chain(syncIngestHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/inspect", Handler: chain(syncInspectHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/inspect", Handler: chain(syncInspectHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/preview", Handler: chain(syncPreviewHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/preview", Handler: chain(syncPreviewHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||||
@@ -431,6 +430,34 @@ func yuhengTicketExchangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func yuhengRestoreByHostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req applogic.YuhengRestoreByHostReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := applogic.NewAuthLogic(r.Context(), svcCtx).RestoreByYuhengHost(req)
|
||||||
|
if err != nil {
|
||||||
|
if e := applogic.AsRestoreBindError(err); e != nil {
|
||||||
|
body := map[string]any{
|
||||||
|
"code": e.HTTPStatus,
|
||||||
|
"message": e.Message,
|
||||||
|
"ok": false,
|
||||||
|
}
|
||||||
|
if e.NeedBind {
|
||||||
|
body["need_bind"] = true
|
||||||
|
}
|
||||||
|
httpx.WriteJson(w, e.HTTPStatus, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OkJson(w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func roleListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func roleListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := applogic.NewRoleAdminLogic(r.Context(), svcCtx).List()
|
items, err := applogic.NewRoleAdminLogic(r.Context(), svcCtx).List()
|
||||||
|
|||||||
248
platform/internal/logic/applogic/yuheng_restore.go
Normal file
248
platform/internal/logic/applogic/yuheng_restore.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
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 尚未完成同步绑定;请先绑定码/手机号绑定")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := l.ensureAgentSyncPerm(acc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ type Claims struct {
|
|||||||
LocalDatabaseID string `json:"local_database_id,omitempty"`
|
LocalDatabaseID string `json:"local_database_id,omitempty"`
|
||||||
Exp int64 `json:"exp"`
|
Exp int64 `json:"exp"`
|
||||||
JTI string `json:"jti"`
|
JTI string `json:"jti"`
|
||||||
Scope string `json:"scope,omitempty"` // sync_bind
|
Scope string `json:"scope,omitempty"` // sync_bind | sync_restore
|
||||||
YuhengUserID string `json:"yuheng_user_id,omitempty"`
|
YuhengUserID string `json:"yuheng_user_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +35,10 @@ type VerifyOpts struct {
|
|||||||
Audience string // default aijianzhan
|
Audience string // default aijianzhan
|
||||||
Now time.Time
|
Now time.Time
|
||||||
MaxSkew time.Duration // clock skew; default 30s
|
MaxSkew time.Duration // clock skew; default 30s
|
||||||
|
// AllowEmptyPhone:Z34b restore-by-host;phone 可空(仍须 host_key+jti)。
|
||||||
|
AllowEmptyPhone bool
|
||||||
|
// AllowedScopes:空则仅允许 sync_bind(含空 scope);restore 可传 sync_bind+sync_restore。
|
||||||
|
AllowedScopes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
|
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
|
||||||
@@ -43,9 +47,6 @@ func Sign(secret string, c Claims) (string, error) {
|
|||||||
if secret == "" {
|
if secret == "" {
|
||||||
return "", fmt.Errorf("ticket secret empty")
|
return "", fmt.Errorf("ticket secret empty")
|
||||||
}
|
}
|
||||||
if c.JTI == "" || c.Phone == "" || c.HostKey == "" || c.Exp == 0 {
|
|
||||||
return "", fmt.Errorf("jti/phone/host_key/exp required")
|
|
||||||
}
|
|
||||||
if c.Iss == "" {
|
if c.Iss == "" {
|
||||||
c.Iss = "yuheng"
|
c.Iss = "yuheng"
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,11 @@ func Sign(secret string, c Claims) (string, error) {
|
|||||||
if c.Scope == "" {
|
if c.Scope == "" {
|
||||||
c.Scope = "sync_bind"
|
c.Scope = "sync_bind"
|
||||||
}
|
}
|
||||||
|
// sync_restore:允许无 phone(换机仅凭宇恒账号 ID);其余 scope 仍须 phone。
|
||||||
|
needPhone := c.Scope != "sync_restore"
|
||||||
|
if c.JTI == "" || c.HostKey == "" || c.Exp == 0 || (needPhone && c.Phone == "") {
|
||||||
|
return "", fmt.Errorf("jti/host_key/exp required; phone required unless scope=sync_restore")
|
||||||
|
}
|
||||||
raw, err := json.Marshal(c)
|
raw, err := json.Marshal(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -105,7 +111,19 @@ func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
|
|||||||
if c.Aud != aud {
|
if c.Aud != aud {
|
||||||
return nil, fmt.Errorf("ticket audience mismatch")
|
return nil, fmt.Errorf("ticket audience mismatch")
|
||||||
}
|
}
|
||||||
if c.Scope != "" && c.Scope != "sync_bind" {
|
scope := strings.TrimSpace(c.Scope)
|
||||||
|
allowed := opts.AllowedScopes
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
allowed = []string{"", "sync_bind"}
|
||||||
|
}
|
||||||
|
scopeOK := false
|
||||||
|
for _, a := range allowed {
|
||||||
|
if scope == strings.TrimSpace(a) {
|
||||||
|
scopeOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !scopeOK {
|
||||||
return nil, fmt.Errorf("ticket scope not allowed")
|
return nil, fmt.Errorf("ticket scope not allowed")
|
||||||
}
|
}
|
||||||
now := opts.Now
|
now := opts.Now
|
||||||
@@ -124,7 +142,10 @@ func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
|
|||||||
if exp.After(now.Add(10 * time.Minute)) {
|
if exp.After(now.Add(10 * time.Minute)) {
|
||||||
return nil, fmt.Errorf("ticket exp too far")
|
return nil, fmt.Errorf("ticket exp too far")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(c.Phone) == "" || strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
|
if strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
|
||||||
|
return nil, fmt.Errorf("ticket missing host_key/jti")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Phone) == "" && !opts.AllowEmptyPhone && scope != "sync_restore" {
|
||||||
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
|
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
|
||||||
}
|
}
|
||||||
return &c, nil
|
return &c, nil
|
||||||
|
|||||||
@@ -35,6 +35,43 @@ func TestVerifyBadSig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSignVerifyRestoreNoPhone(t *testing.T) {
|
||||||
|
secret := "test-secret"
|
||||||
|
now := time.Unix(1_700_000_000, 0).UTC()
|
||||||
|
c := Claims{
|
||||||
|
Iss: "yuheng", Aud: "aijianzhan", HostKey: "6655aabbccddeeff00112233",
|
||||||
|
Exp: now.Add(90 * time.Second).Unix(), JTI: "jti-restore", Scope: "sync_restore",
|
||||||
|
YuhengUserID: "6655aabbccddeeff00112233",
|
||||||
|
}
|
||||||
|
tok, err := Sign(secret, c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Verify(tok, VerifyOpts{
|
||||||
|
Secret: secret, Now: now, AllowEmptyPhone: true,
|
||||||
|
AllowedScopes: []string{"sync_bind", "sync_restore"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.HostKey != c.HostKey || got.Phone != "" {
|
||||||
|
t.Fatalf("claims mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
// 默认 Verify(须 phone)应拒绝无 phone 的 sync_bind;sync_restore 在默认 scopes 外也应拒绝
|
||||||
|
if _, err := Verify(tok, VerifyOpts{Secret: secret, Now: now}); err == nil {
|
||||||
|
t.Fatal("expected scope reject without AllowedScopes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignRequiresPhoneForSyncBind(t *testing.T) {
|
||||||
|
_, err := Sign("s", Claims{
|
||||||
|
HostKey: "h", Exp: time.Now().Add(time.Minute).Unix(), JTI: "j", Scope: "sync_bind",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected phone required for sync_bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJTIConsume(t *testing.T) {
|
func TestJTIConsume(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
st, err := NewJTIStore(dir)
|
st, err := NewJTIStore(dir)
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ stack_up() {
|
|||||||
compose_cmd up -d --build
|
compose_cmd up -d --build
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
# Podman DNS 竞态:web 可能因启动时解析不到 gateway 而退出,gateway 就绪后再拉起一次
|
||||||
|
sleep 2
|
||||||
|
compose_cmd up -d --force-recreate web >/dev/null 2>&1 || compose_cmd up -d web || true
|
||||||
install_host_nginx_site_conf || true
|
install_host_nginx_site_conf || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +369,10 @@ healthcheck() {
|
|||||||
ok=1
|
ok=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
# 中途补拉 web(常见:nginx 启动时 DNS 未就绪已退出)
|
||||||
|
if [ $((i % 10)) -eq 0 ]; then
|
||||||
|
compose_cmd up -d web >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
if [ "$ok" -eq 1 ]; then
|
if [ "$ok" -eq 1 ]; then
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# Podman/Docker:静态 proxy_pass 会在启动时解析 upstream;gateway 尚未进 DNS 时会 emerg 退出。
|
||||||
|
# 变量 + resolver 改为请求时解析;127.0.0.11=Docker 内置 DNS,10.89.0.1=常见 Podman aardvark。
|
||||||
|
resolver 127.0.0.11 10.89.0.1 valid=10s ipv6=off;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -5,7 +9,8 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://gateway:8180;
|
set $gw_upstream gateway:8180;
|
||||||
|
proxy_pass http://$gw_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -15,7 +20,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /ai/ {
|
location /ai/ {
|
||||||
proxy_pass http://gateway:8180;
|
set $gw_upstream gateway:8180;
|
||||||
|
proxy_pass http://$gw_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Authorization $http_authorization;
|
proxy_set_header Authorization $http_authorization;
|
||||||
@@ -23,7 +29,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /gateway/ {
|
location /gateway/ {
|
||||||
proxy_pass http://gateway:8180;
|
set $gw_upstream gateway:8180;
|
||||||
|
proxy_pass http://$gw_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
@@ -31,4 +38,4 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
联调后修改意见-宇恒松离线.md
128
联调后修改意见-宇恒松离线.md
@@ -1,6 +1,6 @@
|
|||||||
# 联调后修改意见 · 宇恒松离线(形态 B)
|
# 联调后修改意见 · 宇恒松离线(形态 B)
|
||||||
|
|
||||||
> 初稿:2026-08-01 · 修订至 **2026-08-06 22:30**(Z31 同步 host_key=宇恒账号 ID)(Z24 已绑定勿 force 清落点)
|
> 初稿:2026-08-01 · 修订至 **2026-08-07**(Z34b 智建已落实:restore-by-host)
|
||||||
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0)
|
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0)
|
||||||
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`)
|
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`)
|
||||||
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
|
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
| **Z22 绑定表单失败可重试** | **宇恒已改完;智建无需** | prepare 只做非交互检测;`ask_fields`/`Expecting value` 不冒泡成「绑定引导失败」;必弹绑定表单(§5.20) |
|
| **Z22 绑定表单失败可重试** | **宇恒已改完;智建无需** | prepare 只做非交互检测;`ask_fields`/`Expecting value` 不冒泡成「绑定引导失败」;必弹绑定表单(§5.20) |
|
||||||
| **Z23 表单推送失败禁止空等** | **宇恒已改完;智建无需** | `ask_fields_via_chat` 推送失败立即抛错;进行中再选中会话给出提示;对齐 ty_host≥1.2.35(§5.21) |
|
| **Z23 表单推送失败禁止空等** | **宇恒已改完;智建无需** | `ask_fields_via_chat` 推送失败立即抛错;进行中再选中会话给出提示;对齐 ty_host≥1.2.35(§5.21) |
|
||||||
| **Z24 已绑定勿 force 清落点** | **宇恒已改完;智建无需** | 启动自动引导绑定时禁止 `force_rebind` 清 CHANNEL;仅用户点「换绑」才清(§5.22) |
|
| **Z24 已绑定勿 force 清落点** | **宇恒已改完;智建无需** | 启动自动引导绑定时禁止 `force_rebind` 清 CHANNEL;仅用户点「换绑」才清(§5.22) |
|
||||||
|
| **Z34 / Z34b 换机恢复** | **宇恒半程 + 智建已落实** | 有手机静默恢复(Z34);无手机 `POST …/restore-by-host`(Z34b · §5.32) |
|
||||||
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
|
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
|
||||||
|
|
||||||
### 0.0 修改流程(冻结)
|
### 0.0 修改流程(冻结)
|
||||||
@@ -70,6 +71,9 @@
|
|||||||
| **Z22** | **绑定表单失败可重试(2026-08-06 · 宇恒已改完)**:启动检测改为非交互;表单推送/JSON 解析失败不向用户暴露 `Expecting value`,捕获后重试弹表。**智建无需改** |
|
| **Z22** | **绑定表单失败可重试(2026-08-06 · 宇恒已改完)**:启动检测改为非交互;表单推送/JSON 解析失败不向用户暴露 `Expecting value`,捕获后重试弹表。**智建无需改** |
|
||||||
| **Z23** | **表单推送失败禁止空等(2026-08-06 · 宇恒已改完)**:推送失败立即抛错触发重试;禁止「只发文字、表单卡死等待」。**智建无需改** |
|
| **Z23** | **表单推送失败禁止空等(2026-08-06 · 宇恒已改完)**:推送失败立即抛错触发重试;禁止「只发文字、表单卡死等待」。**智建无需改** |
|
||||||
| **Z24** | **已绑定勿 force 清落点(2026-08-06 · 宇恒已改完)**:自动绑定时 `force_rebind=False`;换绑按钮才清。**智建无需改** |
|
| **Z24** | **已绑定勿 force 清落点(2026-08-06 · 宇恒已改完)**:自动绑定时 `force_rebind=False`;换绑按钮才清。**智建无需改** |
|
||||||
|
| **Z32** | **绑定表单按方式显隐字段(2026-08-06 · 宇恒已改完)**:`visible_when` + sendFrom 过滤;绑码/手机号/稍后切换下方表单项。**智建无需改** |
|
||||||
|
| **Z33** | **仅数据同步时勿因缺 app.read 进换绑(2026-08-06 · 宇恒已改完)**:GET /apps 403 且已 sync_bound → apps=[] 继续模块菜单并提示开通读取模块。**智建无需改**(控制台给智能体开「读取模块」即可列模块) |
|
||||||
|
| **Z34** | **换机按账号恢复(2026-08-06 · 宇恒半程已改完)**:有手机号时可静默 ticket/confirm。**完整「仅宇恒 ID」见智建 Z34b(已落实)** |
|
||||||
|
|
||||||
#### A′. 宇恒 · 配合注意(非阻塞新开发)
|
#### A′. 宇恒 · 配合注意(非阻塞新开发)
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@
|
|||||||
| Podman DNS / 脚本可执行位 | compose aliases;`restart.sh` 100755 |
|
| Podman DNS / 脚本可执行位 | compose aliases;`restart.sh` 100755 |
|
||||||
| **Z12h / Z12h-1 / Z12h-2** | `HealTenantSyncBind`:Ensure 默认通道 + 重挂 Agent/Binding;默认同步**可删**,删后立即 Ensure+heal;列表/换票/`agents/me`/ensure Binding 触发;见 §5.9 |
|
| **Z12h / Z12h-1 / Z12h-2** | `HealTenantSyncBind`:Ensure 默认通道 + 重挂 Agent/Binding;默认同步**可删**,删后立即 Ensure+heal;列表/换票/`agents/me`/ensure Binding 触发;见 §5.9 |
|
||||||
| **数据恢复(§5.13)** | 成功同步后线上库快照(latest+previous);SyncPage「数据恢复」;`GET …/checkpoint` + `POST …/restore` |
|
| **数据恢复(§5.13)** | 成功同步后线上库快照(latest+previous);SyncPage「数据恢复」;`GET …/checkpoint` + `POST …/restore` |
|
||||||
|
| **Z34b** | `POST /api/v1/auth/yuheng/restore-by-host`:HMAC 凭票(phone 可空)按 `host_key` 恢复已 sync_bound 落点并轮换密钥;见 §5.32 |
|
||||||
|
|
||||||
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
|
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
|
||||||
|
|
||||||
@@ -98,7 +103,7 @@
|
|||||||
|
|
||||||
| 优先级 | 编号 | 项 | 说明 |
|
| 优先级 | 编号 | 项 | 说明 |
|
||||||
|--------|------|----|------|
|
|--------|------|----|------|
|
||||||
| — | — | **无阻塞开发项** | Z12h、数据恢复已合入;后续仅生产 pull 与运维配号 |
|
| — | — | **无阻塞开发项** | Z34b 已合入;宇恒接入 restore-by-host 后联调;生产 pull 见 B′ |
|
||||||
|
|
||||||
#### B‴. 智建 · 本次明确不改(Z15)
|
#### B‴. 智建 · 本次明确不改(Z15)
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
|
|
||||||
| 优先级 | 项 | 说明 |
|
| 优先级 | 项 | 说明 |
|
||||||
|--------|----|------|
|
|--------|----|------|
|
||||||
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复**;`bash ./restart.sh --pull` |
|
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b**;`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` |
|
||||||
@@ -962,9 +967,124 @@ POST /api/v1/agent/sync/channels/{id}/pull
|
|||||||
|
|
||||||
「智能体 host」在智建侧=绑定请求里的 `host_key` 字段。账号隔离后应等于宇恒用户 `_id`;电脑级 `YXD_HOST_KEY` 不再用于跨公司换绑。
|
「智能体 host」在智建侧=绑定请求里的 `host_key` 字段。账号隔离后应等于宇恒用户 `_id`;电脑级 `YXD_HOST_KEY` 不再用于跨公司换绑。
|
||||||
|
|
||||||
|
### 5.30 【Z32 · 2026-08-06】绑定表单按方式显隐字段
|
||||||
|
|
||||||
|
**状态**:**宇恒已改完;智建无需**。依赖 `yxd_skill_ty_host≥1.2.36`(`visible_when`)+ 前端 `sendFrom.vue`。
|
||||||
|
|
||||||
|
#### 现象
|
||||||
|
|
||||||
|
选「绑定码 / 手机号 / 稍后绑定」后,下方绑定码、手机号、短信三项始终全显,体验像「又要选又不变」。
|
||||||
|
|
||||||
|
#### 改动
|
||||||
|
|
||||||
|
1. ty_host:`field_text`/`field_enum` 支持 `visible_when`,经 `field_spec_to_component` 下发。
|
||||||
|
2. `sendFrom.vue`:按依赖字段当前值过滤 `visibleFormComponents`。
|
||||||
|
3. `bind_sync`:绑定码仅「绑定码」可见;手机号+短信仅「手机号」可见;「稍后绑定」无输入项。
|
||||||
|
|
||||||
|
#### 验收
|
||||||
|
|
||||||
|
切换绑定方式时下方表单项随之切换;选「稍后绑定」仅剩方式选择与提交。
|
||||||
|
|
||||||
|
### 5.31 【Z33 · 2026-08-06】仅「数据同步」权限时 GET /apps 403 勿进换绑
|
||||||
|
|
||||||
|
**状态**:**宇恒已改完;智建无需**(产品上给智能体开「读取模块」即可;非接口缺陷)。
|
||||||
|
|
||||||
|
#### 现象
|
||||||
|
|
||||||
|
绑定+换票成功 `permissions=['数据同步'] sync_bound=True`,随后提示「拉取模块列表失败(需 app.read)」并进鉴权/换绑门闸,用户误以为绑定失败。
|
||||||
|
|
||||||
|
#### 根因
|
||||||
|
|
||||||
|
松离线智能体常只开「数据同步」;列模块 `GET /apps` 需要 `app.read`。旧逻辑把列模块失败当成登录失败。
|
||||||
|
|
||||||
|
#### 宇恒改
|
||||||
|
|
||||||
|
`host_bootstrap`:403/缺 app.read 且本机已有同步落点或换票含同步权 → `apps=[]`,提示管理员开「读取模块」,仍弹出模块操作(可换绑/新建等;无列表时新建受限按原逻辑)。
|
||||||
|
|
||||||
|
### 5.32 【Z34 / Z34b · 2026-08-06】换机:同宇恒账号自动恢复本地同步配置
|
||||||
|
|
||||||
|
**状态**:
|
||||||
|
- **Z34 宇恒**:已改完(有手机号路径)
|
||||||
|
- **Z34b 智建**:**已落实**(`POST /api/v1/auth/yuheng/restore-by-host`;gateway 公开;凭票 `scope=sync_restore` 时 phone 可空)
|
||||||
|
|
||||||
|
#### 诉求
|
||||||
|
|
||||||
|
同步绑定跟**宇恒账号**,不跟电脑。换机登录同一宇恒账号后:
|
||||||
|
|
||||||
|
1. 用**宇恒用户 `_id`(即绑定用的 `host_key`)向智建查询**是否已有同步绑定;
|
||||||
|
2. 若有 → **直接回传**可落盘的配置(`channel_id` / `client_id` / `client_secret` / `online_db_id` 等),宇恒本机自动写 `yuheng_sync.env` + 技能 config;
|
||||||
|
3. 若无 → 再走绑定码/手机号表单。
|
||||||
|
|
||||||
|
用户不应依赖本机是否记得手机号;**「账号绑的是哪个手机」也应能由智建按 host_key 查到**(可脱敏返回)。
|
||||||
|
|
||||||
|
#### 为何必须智建配合(Z34b)
|
||||||
|
|
||||||
|
| 现状 | 缺口 |
|
||||||
|
|------|------|
|
||||||
|
| `FindByHostKey` 仅平台内部 | 宇恒终端**没有**公开「按 host_key 查绑定」API |
|
||||||
|
| `phone-lookup` / `phone-confirm` / `ticket-exchange` | **都要手机号**;票内 `phone` 必填 |
|
||||||
|
| 宇恒 Z34 | 只能在本机能解析到手机时静默恢复;**无手机则仍弹表** |
|
||||||
|
|
||||||
|
结论:**仅用宇恒 ID 查绑定并生成配置 = 必须智建新接口(或扩展凭票)**;宇恒不能也不应穿透智建库。
|
||||||
|
|
||||||
|
#### 智建 Z34b(已落实)
|
||||||
|
|
||||||
|
**接口**:`POST /api/v1/auth/yuheng/restore-by-host`(gateway 公开,与 ticket-exchange 同级;须 HMAC)
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticket": "YHT1.<payload>.<sig>",
|
||||||
|
"host_key": "<宇恒用户_id,须与票内一致>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
票内 claims(相对现有凭票):
|
||||||
|
|
||||||
|
| 字段 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| `iss`/`aud`/`exp`/`jti`/`scope` | 同现网;`scope` 可用 `sync_bind` 或 `sync_restore` |
|
||||||
|
| `host_key` | **必填** = 宇恒 `_id` |
|
||||||
|
| `yuheng_user_id` | 建议必填,且与 `host_key` 相同(防错绑) |
|
||||||
|
| `phone` | **可空**(`sync_restore` / restore 验票路径) |
|
||||||
|
|
||||||
|
校验:`YuhengTicket.Secret` 验签 → `FindByHostKey(host_key)` → 须 `StatusActive` 且已有 `channel_id`(sync_bound)→ 轮换 secret;顺带自愈通道;可选签发智能体 JWT。
|
||||||
|
|
||||||
|
成功响应(与绑定成功对齐,便于宇恒 `apply_bind_success` 复用):含 `ok`/`sync_bound`/`client_id`/`client_secret`/`channel_id`/`online_db_id`/`tenant_id`/`agent_id`/`host_key`/`phone_masked`/`access_token` 等。
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
| 情况 | 建议 |
|
||||||
|
|------|------|
|
||||||
|
| 无此 host_key / 未 sync_bound | `404` + `need_bind=true`(宇恒弹绑定表) |
|
||||||
|
| 签名无效 / 过期 | `401` |
|
||||||
|
| host_key 与票不一致 / yuheng_user_id 不符 | `403` |
|
||||||
|
|
||||||
|
#### 宇恒 Z34(已改)
|
||||||
|
|
||||||
|
1. `try_restore_bind_for_account`:本机无 CHANNEL 时,`host_key=YXD_USER_ID` + 可解析手机 → 静默 ticket / phone-confirm → `apply_bind_success`。
|
||||||
|
2. 登录写入 `YXD_USER_ID` / `YXD_USER_PHONE`(若有)。
|
||||||
|
3. **待接入**:优先调 `restore-by-host`(无手机也可);失败再回落手机路径 / 绑定表。
|
||||||
|
|
||||||
|
#### 验收
|
||||||
|
|
||||||
|
1. 机 A 用账号 U 绑定成功。
|
||||||
|
2. 机 B 登录同一 U,**本机无手机号缓存**。
|
||||||
|
3. 进 AI建站技能 → 调 Z34b → 自动落盘 CHANNEL/client,**不弹绑定表**。
|
||||||
|
4. `phone_masked` 可展示「已恢复(手机 135****1944)」类提示(可选)。
|
||||||
|
5. 从未绑定的新账号 → `need_bind` → 仍走绑定表。
|
||||||
|
|
||||||
|
#### 分工
|
||||||
|
|
||||||
|
| 方 | 做什么 |
|
||||||
|
|----|--------|
|
||||||
|
| **智建** | ✅ Z34b + gateway 路由 + 文档;生产 pull |
|
||||||
|
| **宇恒** | 接入 `restore-by-host`(优先于手机静默路径);联调 |
|
||||||
|
|
||||||
## 6. 联系与附件
|
## 6. 联系与附件
|
||||||
|
|
||||||
- **待改清单(优先看)**:§0.2(**A′ 宇恒配合 · B′ 智建运维**;开发项已清空);**改代码前先写本意见**(§0.0);配合见 **§0.3**
|
- **待改清单(优先看)**:§0.2(**A′ 宇恒接入 restore-by-host · B′ 运维 pull**);**改代码前先写本意见**(§0.0);配合见 **§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