From d195aa4804ebf63eb6185f38fe1bb6276c3f9c01 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Wed, 5 Aug 2026 15:09:49 +0800 Subject: [PATCH] feat: add Yuheng ticket bind, trial SMS off, shared bindings Ship ticket-exchange and bind/policy for Z13, keep trial binds SMS-free, allow shared company bindings, and align SyncPage plus sync docs. Co-authored-by: Cursor --- docs/数据同步-开通说明.md | 5 +- gateway/gateway.go | 6 + platform/etc/platform.docker.yaml | 13 ++ platform/etc/platform.yaml | 11 +- platform/internal/apidef/catalog.go | 2 + platform/internal/config/config.go | 14 ++ platform/internal/dbsync/binding.go | 47 +++- platform/internal/handler/agent_sync.go | 2 +- platform/internal/handler/routes.go | 24 ++ platform/internal/handler/sync_binding.go | 21 +- platform/internal/logic/applogic/bind_flow.go | 159 ++++++++----- .../internal/logic/applogic/yuheng_ticket.go | 210 ++++++++++++++++++ platform/internal/svc/servicecontext.go | 7 + platform/internal/yuhticket/ticket.go | 187 ++++++++++++++++ platform/internal/yuhticket/ticket_test.go | 51 +++++ web/src/SyncPage.tsx | 111 ++++++++- web/src/api.ts | 60 +++++ 宇恒-松离线数据同步使用文档.md | 68 +++++- 联调后修改意见-宇恒松离线.md | 55 +++-- 19 files changed, 957 insertions(+), 96 deletions(-) create mode 100644 platform/internal/logic/applogic/yuheng_ticket.go create mode 100644 platform/internal/yuhticket/ticket.go create mode 100644 platform/internal/yuhticket/ticket_test.go diff --git a/docs/数据同步-开通说明.md b/docs/数据同步-开通说明.md index 46da910..528a744 100644 --- a/docs/数据同步-开通说明.md +++ b/docs/数据同步-开通说明.md @@ -47,15 +47,18 @@ postgres://user:pass@127.0.0.1:5432/app_online?sslmode=disable | 1 | 确认公司默认同步 DSN(`DefaultRemoteDSN`)指向生产 Postgres | | 2 | 「用户管理」**启用智能体** → 平台自动创建 `is_system_default` 通道,并写回该智能体 `channel_id` / `online_db_id` | | 3 | (可选)`POST /api/v1/admin/bind-codes` 生成绑定码,发给终端用户 | -| 4 | 终端:已绑则换票带 `sync_bound=true`;未绑则 **绑定码兑换** 或 **手机号 lookup→确认**(同号必须弹窗,禁止静默绑) | +| 4 | 终端:已绑则换票带 `sync_bound=true`;未绑则 **绑定码兑换**、**宇恒凭票**或 **手机号 lookup→确认**(同号必须弹窗,禁止静默绑) | | 5 | 宇恒本机库选「同步」→ 自动 Binding + agent drain;本地未装 agent 仍可保存,文案「需 agent 才上云」 | 同号硬约束:宇恒手机 = 公司成员手机(联调样例 **`13531041944`**)→ 必须询问「已有账号是否绑定」;**勿用**超管号 `13531041945`。 +**试运行**:`SMS.RequireForBind=false`(或 `Provider=off`)关闭绑定短信;仍须确认。正式接短信后改为 `true`。宇恒可先 `GET /api/v1/auth/bind/policy` 看 `trial_mode` / `yuheng_ticket_enabled`。 + 换票 / 自查: - `POST /api/v1/auth/token` → `channel_id` / `online_db_id` / `database_name` / `sync_bound` - `GET /api/v1/agents/me`(智能体 Bearer) +- `POST /api/v1/auth/yuheng/ticket-exchange`(仅宇恒 HMAC 凭票) ## 运维高级:手建通道(过渡 / 多机) diff --git a/gateway/gateway.go b/gateway/gateway.go index 8f82906..1f22670 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -39,6 +39,12 @@ func main() { "/api/v1/auth/register", "/api/v1/auth/token", "/api/v1/auth/agent/register", + "/api/v1/auth/bind-code/redeem", + "/api/v1/auth/bind/phone-lookup", + "/api/v1/auth/bind/phone-confirm", + "/api/v1/auth/bind/policy", + "/api/v1/auth/yuheng/ticket-exchange", + "/api/v1/auth/sms/send", "/api/v1/meta/", "/api/v1/public/", "/ai/", diff --git a/platform/etc/platform.docker.yaml b/platform/etc/platform.docker.yaml index 5ceed14..a07cb68 100644 --- a/platform/etc/platform.docker.yaml +++ b/platform/etc/platform.docker.yaml @@ -19,6 +19,13 @@ Auth: PhoneLoginOnly: false RequirePhoneBound: false +SMS: + Provider: off + CodeTTLSeconds: 300 + ResendSeconds: 60 + # 试运行关绑定短信;正式接短信后 Provider=dev|真实厂商 且 RequireForBind=true + RequireForBind: false + License: Enabled: false Customer: "" @@ -32,6 +39,12 @@ License: Agent: CapsuleSecret: "dev-agent-capsule-secret" RegisterSecret: "dev-only-change-me" + YuhengTicket: + Enabled: true + Secret: "dev-yuheng-ticket-secret-change-me" + Issuer: yuheng + Audience: aijianzhan + MaxAgeSec: 120 Storage: Driver: local diff --git a/platform/etc/platform.yaml b/platform/etc/platform.yaml index c0a1713..3dd0517 100644 --- a/platform/etc/platform.yaml +++ b/platform/etc/platform.yaml @@ -19,10 +19,12 @@ Auth: RequirePhoneBound: false SMS: - Provider: dev + Provider: off CodeTTLSeconds: 300 # 短信验证码有效期(秒) ResendSeconds: 60 # DevFixedCode: "123456" + # 试运行未接短信平台:false;正式接入后改为 true,且 Provider 勿用 off + RequireForBind: false # 外公司部署:每次续费/延期生成独立签名文件(目录非固定单文件) License: @@ -39,6 +41,13 @@ License: Agent: CapsuleSecret: "dev-agent-capsule-secret" RegisterSecret: "dev-only-change-me" + # 仅宇恒:HMAC 凭票免登录(生产务必换 Secret,并与宇恒后端共享) + YuhengTicket: + Enabled: true + Secret: "dev-yuheng-ticket-secret-change-me" + Issuer: yuheng + Audience: aijianzhan + MaxAgeSec: 120 Storage: Driver: local diff --git a/platform/internal/apidef/catalog.go b/platform/internal/apidef/catalog.go index b518f25..01f9c4c 100644 --- a/platform/internal/apidef/catalog.go +++ b/platform/internal/apidef/catalog.go @@ -30,6 +30,8 @@ var Catalog = []Entry{ {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"}, + {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/invites/accept", OperationID: "acceptInvite", Summary: "接受邀请加入租户", Group: "auth"}, {Method: "POST", Path: "/api/v1/tenants", OperationID: "createTenant", Summary: "pending 用户创建自己的公司", Group: "auth"}, diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index cde138f..b148a38 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -54,11 +54,25 @@ type SMSConf struct { CodeTTLSeconds int `json:",default=300"` ResendSeconds int `json:",default=60"` DevFixedCode string `json:",optional"` // 开发可固定如 123456 + // RequireForBind:手机号确认绑定时是否强制短信。 + // 试运行/未接短信平台:false(关闭验证);正式接入后改为 true。 + RequireForBind bool `json:",optional"` } type AgentConf struct { CapsuleSecret string `json:",optional"` RegisterSecret string `json:",optional"` // 宿主自注册防刷;空则回退 IssueSecret/AccessSecret + // YuhengTicket:仅宇恒平台签名凭票免登录(HMAC);其它客户端勿用。 + YuhengTicket YuhengTicketConf `json:",optional"` +} + +// YuhengTicketConf 宇恒 → 智建 短时凭票(scope=sync_bind)。 +type YuhengTicketConf struct { + Enabled bool `json:",optional"` + Secret string `json:",optional"` // 与宇恒共享;生产必换 + Issuer string `json:",default=yuheng"` + Audience string `json:",default=aijianzhan"` + MaxAgeSec int `json:",default=120"` // 签发端建议 TTL;验票另限 exp 不超过 now+10m } type StorageConf struct { diff --git a/platform/internal/dbsync/binding.go b/platform/internal/dbsync/binding.go index c6a0cd8..c958db1 100644 --- a/platform/internal/dbsync/binding.go +++ b/platform/internal/dbsync/binding.go @@ -19,6 +19,8 @@ type Binding struct { LocalDatabaseID string `json:"local_database_id"` OnlineDBID string `json:"online_db_id"` ChannelID string `json:"channel_id,omitempty"` + // Shared:公司共享库(Z12c-2);同租户任意成员可 push/pull 该 online_db_id + Shared bool `json:"shared,omitempty"` // 可读名(与宇恒 database_name / display_name 对齐;控制台优先展示) DatabaseName string `json:"database_name,omitempty"` // 本地库可读名 DisplayName string `json:"display_name,omitempty"` // 线上库可读名 @@ -70,6 +72,7 @@ func (s *FileStore) EnsureBinding(b Binding) (Binding, error) { if b.UserID > 0 { list[i].UserID = b.UserID } + list[i].Shared = b.Shared list[i].UpdatedAt = now if err := s.writeBindingsUnlocked(list); err != nil { return b, err @@ -117,18 +120,33 @@ func (s *FileStore) ListBindingsFiltered(tenantID, userID int64, localDatabaseID return out, nil } -// UserOwnsOnlineDB 用户是否登记了该 online_db_id(可选限定 channel)。 +// UserOwnsOnlineDB 用户是否登记了该 online_db_id(可选限定 channel);或该库为公司 Shared。 func (s *FileStore) UserOwnsOnlineDB(tenantID, userID int64, channelID, onlineDBID string) bool { if tenantID <= 0 || userID <= 0 || strings.TrimSpace(onlineDBID) == "" { return false } - list, err := s.ListBindingsFiltered(tenantID, userID, "") + online := strings.TrimSpace(onlineDBID) + ch := strings.TrimSpace(channelID) + own, err := s.ListBindingsFiltered(tenantID, userID, "") + if err == nil { + for _, b := range own { + if strings.TrimSpace(b.OnlineDBID) != online { + continue + } + if ch != "" && b.ChannelID != "" && b.ChannelID != ch { + continue + } + return true + } + } + all, err := s.ListBindingsFiltered(tenantID, 0, "") if err != nil { return false } - online := strings.TrimSpace(onlineDBID) - ch := strings.TrimSpace(channelID) - for _, b := range list { + for _, b := range all { + if !b.Shared { + continue + } if strings.TrimSpace(b.OnlineDBID) != online { continue } @@ -140,17 +158,28 @@ func (s *FileStore) UserOwnsOnlineDB(tenantID, userID int64, channelID, onlineDB return false } -// UserCanAccessChannel 用户是否登记了指向该通道的 Binding(channel_id 空视为未限定通道)。 +// UserCanAccessChannel 用户是否登记了指向该通道的 Binding(channel_id 空视为未限定通道);含 Shared。 func (s *FileStore) UserCanAccessChannel(tenantID, userID int64, channelID string) bool { if tenantID <= 0 || userID <= 0 || strings.TrimSpace(channelID) == "" { return false } + ch := strings.TrimSpace(channelID) list, err := s.ListBindingsFiltered(tenantID, userID, "") - if err != nil || len(list) == 0 { + if err == nil { + for _, b := range list { + if b.ChannelID == "" || b.ChannelID == ch { + return true + } + } + } + all, err := s.ListBindingsFiltered(tenantID, 0, "") + if err != nil { return false } - ch := strings.TrimSpace(channelID) - for _, b := range list { + for _, b := range all { + if !b.Shared { + continue + } if b.ChannelID == "" || b.ChannelID == ch { return true } diff --git a/platform/internal/handler/agent_sync.go b/platform/internal/handler/agent_sync.go index 460fd98..17d1950 100644 --- a/platform/internal/handler/agent_sync.go +++ b/platform/internal/handler/agent_sync.go @@ -216,7 +216,7 @@ func authorizeUserOnlineDB(svcCtx *svc.ServiceContext, w http.ResponseWriter, r uid := authx.UserID(r.Context()) tid := syncTenantID(r) if !svcCtx.DBSync.Store().UserOwnsOnlineDB(tid, uid, channelID, online) { - authx.WriteError(w, http.StatusForbidden, "无权访问该 online_db_id(非本人 Binding)") + authx.WriteError(w, http.StatusForbidden, "无权访问该 online_db_id(非本人 Binding / 非公司共享库)") return false } return true diff --git a/platform/internal/handler/routes.go b/platform/internal/handler/routes.go index 1df473d..a2d59e9 100644 --- a/platform/internal/handler/routes.go +++ b/platform/internal/handler/routes.go @@ -44,6 +44,8 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) { {Method: http.MethodPost, Path: "/api/v1/auth/bind-code/redeem", Handler: rl(bindCodeRedeemHandler(svcCtx))}, {Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-lookup", Handler: rl(bindPhoneLookupHandler(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.MethodPost, Path: "/api/v1/auth/yuheng/ticket-exchange", Handler: rl(yuhengTicketExchangeHandler(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/sms/send", Handler: rl(sendLoginSMSHandler(svcCtx))}, @@ -388,6 +390,12 @@ func bindPhoneLookupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { } } +func bindPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + httpx.OkJson(w, applogic.NewAuthLogic(r.Context(), svcCtx).BindPolicy()) + } +} + func bindPhoneConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req applogic.PhoneConfirmReq @@ -404,6 +412,22 @@ func bindPhoneConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { } } +func yuhengTicketExchangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req applogic.YuhengTicketExchangeReq + 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).ExchangeYuhengTicket(req) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, resp) + } +} + func roleListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { items, err := applogic.NewRoleAdminLogic(r.Context(), svcCtx).List() diff --git a/platform/internal/handler/sync_binding.go b/platform/internal/handler/sync_binding.go index 7aa9831..85a8f97 100644 --- a/platform/internal/handler/sync_binding.go +++ b/platform/internal/handler/sync_binding.go @@ -26,6 +26,21 @@ func syncBindingsListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { list, err = svcCtx.DBSync.Store().ListBindings(tid, localID) } else { list, err = svcCtx.DBSync.Store().ListBindingsFiltered(tid, authx.UserID(r.Context()), localID) + if err == nil { + // 附带公司共享库 Binding,便于客户端展示 + all, e2 := svcCtx.DBSync.Store().ListBindings(tid, localID) + if e2 == nil { + seen := map[string]bool{} + for _, b := range list { + seen[b.ID] = true + } + for _, b := range all { + if b.Shared && !seen[b.ID] { + list = append(list, b) + } + } + } + } } if err != nil { authx.WriteError(w, http.StatusBadRequest, err.Error()) @@ -52,8 +67,12 @@ func syncBindingsEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { body.UserID = uid } } else { - // 用户自助:强制绑定到本人,禁止冒用他人 user_id + // 用户自助:强制绑定到本人,禁止冒用他人 user_id;禁止自助标共享库 body.UserID = uid + if body.Shared { + authx.WriteError(w, http.StatusForbidden, "仅公司管理员可将 Binding 标为公司共享库(shared)") + return + } } saved, err := svcCtx.DBSync.Store().EnsureBinding(body) if err != nil { diff --git a/platform/internal/logic/applogic/bind_flow.go b/platform/internal/logic/applogic/bind_flow.go index 0acf23d..955d572 100644 --- a/platform/internal/logic/applogic/bind_flow.go +++ b/platform/internal/logic/applogic/bind_flow.go @@ -10,6 +10,7 @@ import ( "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/bindcodestore" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/smsstore" "aijianzhan/platform/internal/userstore" ) @@ -192,6 +193,50 @@ type PhoneLookupResp struct { MaskedName string `json:"masked_name,omitempty"` NeedConfirm bool `json:"need_confirm"` Message string `json:"message,omitempty"` + // Z13c-2:试运行 RequireForBind=false 时为 false;正式开启后为 true(宇恒同号可 attested 免验) + SMSRequiredUnlessAttested bool `json:"sms_required_unless_attested"` +} + +func (l *AuthLogic) bindSMSRequired() bool { + if l == nil || l.svcCtx == nil { + return false + } + if strings.EqualFold(strings.TrimSpace(l.svcCtx.Config.SMS.Provider), "off") { + return false + } + return l.svcCtx.Config.SMS.RequireForBind +} + +// BindPolicy 公开策略,供宇恒决定是否弹短信 / 走凭票。 +type BindPolicyResp struct { + RequireForBind bool `json:"require_for_bind"` + SMSProvider string `json:"sms_provider"` + YuhengTicketEnabled bool `json:"yuheng_ticket_enabled"` + TrialMode bool `json:"trial_mode"` // !require_for_bind + Message string `json:"message,omitempty"` +} + +func (l *AuthLogic) BindPolicy() BindPolicyResp { + req := l.bindSMSRequired() + ticketOn := l.svcCtx != nil && l.svcCtx.Config.Agent.YuhengTicket.Enabled + prov := "" + if l.svcCtx != nil { + prov = strings.TrimSpace(l.svcCtx.Config.SMS.Provider) + } + msg := "试运行:绑定可不校验短信,仍须用户确认" + if req { + msg = "正式:异号须 sms_code;同号请用宇恒凭票 ticket-exchange(或 attested,若未启凭票)" + if ticketOn { + msg = "正式:异号须 sms_code;同号请用 POST /api/v1/auth/yuheng/ticket-exchange(已禁 attested_same_phone)" + } + } + return BindPolicyResp{ + RequireForBind: req, + SMSProvider: prov, + YuhengTicketEnabled: ticketOn, + TrialMode: !req, + Message: msg, + } } func maskDisplayName(name string) string { @@ -210,31 +255,41 @@ func maskDisplayName(name string) string { } func (l *AuthLogic) PhoneLookup(req PhoneLookupReq) (*PhoneLookupResp, error) { + smsReq := l.bindSMSRequired() if l.svcCtx.Users == nil { return nil, fmt.Errorf("user store unavailable") } phone, err := userstore.NormalizePhone(req.Phone) if err != nil { - return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确"}, nil + return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确", SMSRequiredUnlessAttested: smsReq}, nil } u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone) if err != nil || u == nil { - return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员"}, nil + return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员", SMSRequiredUnlessAttested: smsReq}, nil } if u.TenantID <= 0 { - return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司"}, nil + return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司", SMSRequiredUnlessAttested: smsReq}, nil } tenantName := "" if t, err := l.svcCtx.Users.GetTenant(l.ctx, u.TenantID); err == nil && t != nil { tenantName = t.Name } + msg := fmt.Sprintf("已找到账号「%s」所属「%s」,是否绑定到本机?", maskDisplayName(u.DisplayName), tenantName) + if !smsReq { + msg += "(试运行:短信验证已关闭,确认即可)" + } else if l.svcCtx.Config.Agent.YuhengTicket.Enabled { + msg += "(正式:同号请用宇恒凭票 ticket-exchange;异号须 sms_code)" + } else { + msg += "(正式:宇恒同号可 attested_same_phone=true;异号须 sms_code)" + } return &PhoneLookupResp{ - Exists: true, - TenantID: u.TenantID, - TenantName: tenantName, - MaskedName: maskDisplayName(u.DisplayName), - NeedConfirm: true, - Message: fmt.Sprintf("已找到账号「%s」所属「%s」,是否绑定到本机?", maskDisplayName(u.DisplayName), tenantName), + Exists: true, + TenantID: u.TenantID, + TenantName: tenantName, + MaskedName: maskDisplayName(u.DisplayName), + NeedConfirm: true, + SMSRequiredUnlessAttested: smsReq, + Message: msg, }, nil } @@ -244,6 +299,9 @@ type PhoneConfirmReq struct { Name string `json:"name"` Confirm bool `json:"confirm"` // 必须 true LocalDBID string `json:"local_database_id"` + // Z13c-2:输入号=宇恒已绑手机时,宇恒置 true 可免短信;异号必须带 sms_code + AttestedSamePhone bool `json:"attested_same_phone"` + SMSCode string `json:"sms_code"` } type PhoneConfirmResp struct { @@ -257,6 +315,35 @@ type PhoneConfirmResp struct { Message string `json:"message,omitempty"` } +func (l *AuthLogic) requirePhoneBindProof(phone string, attested bool, smsCode string) error { + // 正式模式且启用宇恒凭票:禁止明文 attested(须走 ticket-exchange) + formal := l.bindSMSRequired() + if attested && formal && l.svcCtx.Config.Agent.YuhengTicket.Enabled { + return fmt.Errorf("已启用宇恒凭票:请使用 POST /api/v1/auth/yuheng/ticket-exchange,勿再传 attested_same_phone") + } + if attested { + return nil + } + // 试运行:RequireForBind=false 或 Provider=off,跳过短信 + if !formal { + return nil + } + code := strings.TrimSpace(smsCode) + if code == "" { + return fmt.Errorf("须提供 sms_code,或使用宇恒凭票 ticket-exchange(同号)") + } + if l.svcCtx.SMS == nil { + return fmt.Errorf("短信服务未启用,无法校验验证码") + } + if err := l.svcCtx.SMS.Consume(smsstore.PurposeBind, phone, code); err == nil { + return nil + } + if err := l.svcCtx.SMS.Consume(smsstore.PurposeLogin, phone, code); err == nil { + return nil + } + return fmt.Errorf("短信验证码无效或已过期") +} + func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) { if !req.Confirm { return nil, fmt.Errorf("须明确确认绑定(confirm=true)") @@ -268,6 +355,9 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) if err != nil { return nil, fmt.Errorf("手机号格式不正确") } + if err := l.requirePhoneBindProof(phone, req.AttestedSamePhone, req.SMSCode); err != nil { + return nil, err + } hostKey := strings.TrimSpace(req.HostKey) if hostKey == "" { return nil, fmt.Errorf("host_key required") @@ -279,58 +369,13 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) if u.TenantID <= 0 { return nil, fmt.Errorf("该账号尚未加入公司") } - cfg := l.svcCtx.Config.DBSync - driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) - if driver == "" { - driver = dbsync.DriverPostgres - } - ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(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 { - name := strings.TrimSpace(req.Name) - if name == "" { - name = "离线终端 · " + maskDisplayName(u.DisplayName) - } - created, _, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey) - if regErr != nil { - return nil, regErr - } - acc = created - } - online := dbsync.ResolveOnlineDBID("", ch.ID) - // 个人落点:按用户隔离 online_db_id - online = fmt.Sprintf("%s_u%d", online, u.UserID) - dbName := fmt.Sprintf("%s", strings.TrimSpace(u.DisplayName)) - if dbName == "" { - dbName = fmt.Sprintf("user_%d", u.UserID) - } - updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true) + updated, _, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm") if err != nil { return nil, err } - localID := strings.TrimSpace(req.LocalDBID) - 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: dbName, - DisplayName: dbName, - Note: "phone-confirm", - }) + _ = l.ensureAgentSyncPerm(updated) _ = l.writeBindAudit("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{ - "phone": phone, "user_id": u.UserID, "channel_id": ch.ID, "online_db_id": online, + "phone": phone, "user_id": u.UserID, "channel_id": updated.ChannelID, "online_db_id": updated.OnlineDBID, }) return &PhoneConfirmResp{ OK: true, diff --git a/platform/internal/logic/applogic/yuheng_ticket.go b/platform/internal/logic/applogic/yuheng_ticket.go new file mode 100644 index 0000000..24dee7b --- /dev/null +++ b/platform/internal/logic/applogic/yuheng_ticket.go @@ -0,0 +1,210 @@ +package applogic + +import ( + "fmt" + "strings" + + "aijianzhan/platform/internal/agentcap" + "aijianzhan/platform/internal/agentstore" + "aijianzhan/platform/internal/authx" + "aijianzhan/platform/internal/dbsync" + "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, secret, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket") + if err != nil { + return nil, err + } + if err := l.ensureAgentSyncPerm(acc); err != nil { + return nil, err + } + // 重新加载 perms + acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID) + if err != nil { + return nil, 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 +} + +func (l *AuthLogic) ensureAgentSyncPerm(acc *agentstore.Account) error { + if acc == nil { + return nil + } + 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...) + if !has { + perms = append(perms, authx.Perm数据同步) + } + st := agentstore.StatusActive + _, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{ + Status: &st, + Perms: &perms, + }) + return err +} + +// bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。 +func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, string, error) { + cfg := l.svcCtx.Config.DBSync + driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) + if driver == "" { + driver = dbsync.DriverPostgres + } + ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(dbsync.DefaultChannelOpts{ + TenantID: u.TenantID, + RemoteDriver: driver, + RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), + }) + if err != nil { + return nil, "", fmt.Errorf("ensure channel: %w", err) + } + var secret string + 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, secret = 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) + dbName := strings.TrimSpace(u.DisplayName) + if dbName == "" { + dbName = fmt.Sprintf("user_%d", 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: dbName, + DisplayName: dbName, + Note: note, + }) + return updated, secret, nil +} diff --git a/platform/internal/svc/servicecontext.go b/platform/internal/svc/servicecontext.go index b5a1662..23afd22 100644 --- a/platform/internal/svc/servicecontext.go +++ b/platform/internal/svc/servicecontext.go @@ -26,6 +26,7 @@ import ( "aijianzhan/platform/internal/storage" "aijianzhan/platform/internal/tenantperm" "aijianzhan/platform/internal/userstore" + "aijianzhan/platform/internal/yuhticket" _ "github.com/lib/pq" ) @@ -49,6 +50,7 @@ type ServiceContext struct { JWT authx.JWTConfig DBSync *dbsync.Manager BindCodes bindcodestore.Store + YuhengJTI *yuhticket.JTIStore TenantPerm tenantperm.Store SMS *smsstore.Store License *license.Manager @@ -189,6 +191,11 @@ func NewServiceContext(c config.Config) *ServiceContext { } else { ctx.BindCodes = bc } + if jti, err := yuhticket.NewJTIStore(dir); err != nil { + log.Printf("yuheng ticket jti: %v", err) + } else { + ctx.YuhengJTI = jti + } } } diff --git a/platform/internal/yuhticket/ticket.go b/platform/internal/yuhticket/ticket.go new file mode 100644 index 0000000..da54467 --- /dev/null +++ b/platform/internal/yuhticket/ticket.go @@ -0,0 +1,187 @@ +package yuhticket + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const Prefix = "YHT1" + +// Claims 宇恒签发的短时凭票(仅用于智建 sync 绑定/免登录换票)。 +type Claims struct { + Iss string `json:"iss"` + Aud string `json:"aud"` + Phone string `json:"phone"` + HostKey string `json:"host_key"` + Name string `json:"name,omitempty"` + LocalDatabaseID string `json:"local_database_id,omitempty"` + Exp int64 `json:"exp"` + JTI string `json:"jti"` + Scope string `json:"scope,omitempty"` // sync_bind + YuhengUserID string `json:"yuheng_user_id,omitempty"` +} + +type VerifyOpts struct { + Secret string + Issuer string // default yuheng + Audience string // default aijianzhan + Now time.Time + MaxSkew time.Duration // clock skew; default 30s +} + +// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。 +func Sign(secret string, c Claims) (string, error) { + secret = strings.TrimSpace(secret) + if secret == "" { + 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 == "" { + c.Iss = "yuheng" + } + if c.Aud == "" { + c.Aud = "aijianzhan" + } + if c.Scope == "" { + c.Scope = "sync_bind" + } + raw, err := json.Marshal(c) + if err != nil { + return "", err + } + payload := base64.RawURLEncoding.EncodeToString(raw) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(Prefix + "." + payload)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return Prefix + "." + payload + "." + sig, nil +} + +func Verify(ticket string, opts VerifyOpts) (*Claims, error) { + ticket = strings.TrimSpace(ticket) + secret := strings.TrimSpace(opts.Secret) + if secret == "" { + return nil, fmt.Errorf("宇恒凭票未配置(Agent.YuhengTicket.Secret)") + } + parts := strings.Split(ticket, ".") + if len(parts) != 3 || parts[0] != Prefix { + return nil, fmt.Errorf("invalid ticket format") + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(parts[0] + "." + parts[1])) + want := mac.Sum(nil) + got, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || !hmac.Equal(want, got) { + return nil, fmt.Errorf("ticket signature invalid") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("ticket payload invalid") + } + var c Claims + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("ticket claims invalid") + } + iss := strings.TrimSpace(opts.Issuer) + if iss == "" { + iss = "yuheng" + } + aud := strings.TrimSpace(opts.Audience) + if aud == "" { + aud = "aijianzhan" + } + if c.Iss != iss { + return nil, fmt.Errorf("ticket issuer not allowed") + } + if c.Aud != aud { + return nil, fmt.Errorf("ticket audience mismatch") + } + if c.Scope != "" && c.Scope != "sync_bind" { + return nil, fmt.Errorf("ticket scope not allowed") + } + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + skew := opts.MaxSkew + if skew <= 0 { + skew = 30 * time.Second + } + exp := time.Unix(c.Exp, 0).UTC() + if now.After(exp.Add(skew)) { + return nil, fmt.Errorf("ticket expired") + } + // 拒绝过远未来的 exp(防永久票) + if exp.After(now.Add(10 * time.Minute)) { + return nil, fmt.Errorf("ticket exp too far") + } + if strings.TrimSpace(c.Phone) == "" || strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" { + return nil, fmt.Errorf("ticket missing phone/host_key/jti") + } + return &c, nil +} + +// JTIStore 防重放(短 TTL)。 +type JTIStore struct { + mu sync.Mutex + path string + seen map[string]int64 // jti -> exp unix +} + +func NewJTIStore(dir string) (*JTIStore, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + s := &JTIStore{path: filepath.Join(dir, "yuheng_ticket_jti.json"), seen: map[string]int64{}} + _ = s.load() + return s, nil +} + +func (s *JTIStore) load() error { + b, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return json.Unmarshal(b, &s.seen) +} + +func (s *JTIStore) save() error { + b, err := json.Marshal(s.seen) + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// Consume 若 jti 已用则报错;否则记入至 exp。 +func (s *JTIStore) Consume(jti string, expUnix int64) error { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().Unix() + for k, exp := range s.seen { + if exp < now { + delete(s.seen, k) + } + } + if _, ok := s.seen[jti]; ok { + return fmt.Errorf("ticket already used") + } + s.seen[jti] = expUnix + return s.save() +} diff --git a/platform/internal/yuhticket/ticket_test.go b/platform/internal/yuhticket/ticket_test.go new file mode 100644 index 0000000..0fbd172 --- /dev/null +++ b/platform/internal/yuhticket/ticket_test.go @@ -0,0 +1,51 @@ +package yuhticket + +import ( + "testing" + "time" +) + +func TestSignVerifyRoundTrip(t *testing.T) { + secret := "test-secret" + now := time.Unix(1_700_000_000, 0).UTC() + c := Claims{ + Iss: "yuheng", Aud: "aijianzhan", Phone: "13531041944", + HostKey: "host-1", Exp: now.Add(90 * time.Second).Unix(), JTI: "jti-ok", Scope: "sync_bind", + } + tok, err := Sign(secret, c) + if err != nil { + t.Fatal(err) + } + got, err := Verify(tok, VerifyOpts{Secret: secret, Now: now}) + if err != nil { + t.Fatal(err) + } + if got.Phone != c.Phone || got.HostKey != c.HostKey { + t.Fatalf("claims mismatch: %+v", got) + } +} + +func TestVerifyBadSig(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + tok, _ := Sign("a", Claims{ + Phone: "13800000001", HostKey: "h", Exp: now.Add(60 * time.Second).Unix(), JTI: "j1", + }) + if _, err := Verify(tok, VerifyOpts{Secret: "b", Now: now}); err == nil { + t.Fatal("expected bad sig") + } +} + +func TestJTIConsume(t *testing.T) { + dir := t.TempDir() + st, err := NewJTIStore(dir) + if err != nil { + t.Fatal(err) + } + exp := time.Now().Add(time.Minute).Unix() + if err := st.Consume("x", exp); err != nil { + t.Fatal(err) + } + if err := st.Consume("x", exp); err == nil { + t.Fatal("expected replay") + } +} diff --git a/web/src/SyncPage.tsx b/web/src/SyncPage.tsx index 9ed5000..b5c2787 100644 --- a/web/src/SyncPage.tsx +++ b/web/src/SyncPage.tsx @@ -25,6 +25,7 @@ import { } from "@ant-design/icons"; import { AgentAccount, + BindCode, Session, SyncBinding, SyncChannel, @@ -38,6 +39,9 @@ import { listApps, listSyncBindings, listSyncChannels, + listBindCodes, + createBindCode, + revokeBindCode, previewSyncTable, reconcileSyncChannel, startSyncChannel, @@ -163,6 +167,7 @@ export function SyncPage(props: { const { message } = AntApp.useApp(); const [items, setItems] = useState([]); const [bindings, setBindings] = useState([]); + const [bindCodes, setBindCodes] = useState([]); const [agents, setAgents] = useState([]); const [appOptions, setAppOptions] = useState<{ value: string; label: string }[]>([]); const [loading, setLoading] = useState(false); @@ -190,15 +195,17 @@ export function SyncPage(props: { async function refresh() { setLoading(true); try { - const [ch, bind, ag, apps] = await Promise.all([ + const [ch, bind, ag, apps, codes] = await Promise.all([ listSyncChannels(session), listSyncBindings(session).catch(() => ({ items: [] as SyncBinding[] })), listAgents(session).catch(() => ({ items: [] as AgentAccount[] })), listApps(session).catch(() => ({ items: [] as { slug: string; name: string; status: string }[] })), + listBindCodes(session).catch(() => ({ items: [] as BindCode[] })), ]); setItems(ch.items || []); setBindings(bind.items || []); setAgents(ag.items || []); + setBindCodes(codes.items || []); setAppOptions( (apps.items || []) .filter((a) => a.status === "published" || !a.status) @@ -581,6 +588,101 @@ export function SyncPage(props: { ]} /> + + 绑定码(发给终端开通) + + + 关联本公司默认同步落点;终端用 POST /auth/bind-code/redeem{" "} + 兑换。生产联调成员手机请用 13531041944,勿用超管号。 + + + + + {c}, + }, + { + title: "通道", + dataIndex: "channel_id", + render: (id: string) => + id ? {id.slice(0, 8)}… : "—", + }, + { + title: "次数", + render: (_: unknown, r: BindCode) => `${r.used_count}/${r.max_uses}`, + width: 80, + }, + { + title: "过期", + dataIndex: "expires_at", + render: (t: string) => (t ? new Date(t).toLocaleString() : "—"), + }, + { + title: "状态", + width: 90, + render: (_: unknown, r: BindCode) => { + if (r.revoked) return 已撤销; + if (r.used_count >= r.max_uses) return 已用尽; + if (r.expires_at && new Date(r.expires_at).getTime() < Date.now()) + return 已过期; + return 可用; + }, + }, + { + title: "操作", + width: 90, + render: (_: unknown, r: BindCode) => ( + + ), + }, + ]} + /> + 库绑定(本地 ↔ 线上) @@ -677,6 +779,12 @@ export function SyncPage(props: { }, }, { title: "备注", dataIndex: "note", ellipsis: true }, + { + title: "共享", + width: 70, + render: (_: unknown, b: SyncBinding) => + b.shared ? 共享 : 个人, + }, ]} /> @@ -684,6 +792,7 @@ export function SyncPage(props: { 冲突已改为自动 LWW;覆盖审计仅平台超级管理员可查。「同步修复」有最小间隔限流(默认 5 分钟)。 ↑ 推送次数 = agent push 成功次数,不是业务表数量。 本地有、线上没有的表(如尚未 push 的业务表)属正常,不是 Binding 映射错了。 + Binding 标 shared=true 为公司共享库(仅管理员可设)。 .", + "host_key": "<本机稳定宿主机 key>", + "local_database_id": "可选" +} +``` + +成功直接返回智能体 `access_token` + `sync_bound` / `channel_id` / `online_db_id`(**无需**智建账号密码,也**不要**再传 `attested_same_phone`)。 + +凭票 payload(JSON,HMAC-SHA256,前缀 `YHT1`)字段: + +| 字段 | 说明 | +|------|------| +| `iss` | 固定 `yuheng` | +| `aud` | 固定 `aijianzhan` | +| `phone` | 宇恒当前已绑手机(须已是智建公司成员) | +| `host_key` | 与请求一致 | +| `exp` | Unix 秒;建议 60–120s,智建拒 >now+10m | +| `jti` | 一次性随机串(防重放) | +| `scope` | `sync_bind` | +| `name` / `local_database_id` | 可选 | + +配置(智建 `platform.yaml` → `Agent.YuhengTicket`):`Enabled` + 与宇恒共享的 `Secret`。生产必换 Secret;**其它客户端不得使用**。 + +启动前可拉策略(公开,无需登录): + +```http +GET /api/v1/auth/bind/policy +``` + +| 字段 | 说明 | +|------|------| +| `trial_mode` | `true` = 试运行(不强制绑定短信) | +| `require_for_bind` | 是否强制短信(与 `SMS.RequireForBind` + Provider≠off 一致) | +| `yuheng_ticket_enabled` | 是否启用凭票换票 | +| `message` | 给人看的提示文案 | + +--- + +仍可用手机号 API(无凭票或试运行): + ```http POST /api/v1/auth/bind/phone-lookup Content-Type: application/json @@ -163,7 +210,7 @@ Content-Type: application/json { "phone": "13531041944" } ``` -响应要点:`exists`、`tenant_name`、`masked_name`、`need_confirm`、`message`(**不**执行绑定)。 +响应要点:`exists`、`tenant_name`、`masked_name`、`need_confirm`、`sms_required_unless_attested`、`message`(**不**执行绑定)。 ```http POST /api/v1/auth/bind/phone-confirm @@ -173,16 +220,25 @@ Content-Type: application/json "phone": "13531041944", "host_key": "<本机 host_key>", "confirm": true, - "local_database_id": "可选,本机库 ID" + "local_database_id": "可选,本机库 ID", + "attested_same_phone": true, + "sms_code": "" } ``` +| 字段 | 说明 | +|------|------| +| `attested_same_phone` | **试运行**可传;**正式且已启凭票**时禁用,须改用 ticket-exchange | +| `sms_code` | **试运行**可不传;正式异号必填 | + +> **试运行**(当前):`SMS.RequireForBind=false` 或 `Provider=off` → 绑定不校验短信,仍须 `confirm=true`。正式:`RequireForBind: true` 且 Provider 非 off。 + | 场景 | 行为 | |------|------| -| 同号命中公司成员 | **必须询问**「已有账号是否绑定」;确认才 `phone-confirm` | +| 同号命中公司成员 | **必须询问**「已有账号是否绑定」;确认才 `phone-confirm` / 或凭票换票 | | 用户取消 | 不绑定;可改走绑定码 / 换号 | | 无此成员 | 提示用绑定码;**勿**用超管号 `13531041945` 测 | -| 生产联调样例 | 智建与宇恒均为 **`13531041944`** | +| 生产联调样例 | 智建与宇恒均为 **`13531041944`** → 凭票或弹确认 | ### 未绑定时客户端流程(Z13d · 宇恒待接) @@ -509,6 +565,8 @@ CREATE TABLE IF NOT EXISTS orders ( |------|------|------| | POST | `/api/v1/auth/token` | 换票;响应可含 `channel_id`/`online_db_id`/`sync_bound`(Z12a) | | GET | `/api/v1/agents/me` | 智能体自查落点(Z12b) | +| POST | `/api/v1/auth/yuheng/ticket-exchange` | 宇恒凭票免登录换票(仅宇恒 HMAC) | +| GET | `/api/v1/auth/bind/policy` | 绑定策略:`require_for_bind` / `trial_mode` / `yuheng_ticket_enabled` | | POST | `/api/v1/auth/bind-code/redeem` | 绑定码兑换(公开,Z13b) | | POST | `/api/v1/auth/bind/phone-lookup` | 同号探测,不绑定(Z13c) | | POST | `/api/v1/auth/bind/phone-confirm` | 同号确认后绑定(Z13c-1) | diff --git a/联调后修改意见-宇恒松离线.md b/联调后修改意见-宇恒松离线.md index 7e256f5..d74e749 100644 --- a/联调后修改意见-宇恒松离线.md +++ b/联调后修改意见-宇恒松离线.md @@ -10,6 +10,9 @@ > 再修订:2026-08-05(智建落实 **Z9e/Z9f、Z8g、Z11a/b**;Z12/Z13 仍待) > 再修订:2026-08-05(智建落实 **Z12a/b/c/e、Z13a–c-1** API;Z12c-2/Z13d–e 待宇恒;绑定码控制台 UI 可后补) > 再修订:2026-08-05(`宇恒-松离线数据同步使用文档.md`、`docs/数据同步-开通说明.md` 对齐 Z12/Z13 生产路径) +> 再修订:2026-08-05(**Z13c-2** 同号免验约定;**Z12c-2** 共享库;绑定码 SyncPage UI;成员管理可代绑手机) +> 再修订:2026-08-05(**宇恒凭票免登录** `POST /auth/yuheng/ticket-exchange`;gateway 放行 bind/ticket) +> 再修订:2026-08-05(试运行 `RequireForBind=false`;`GET /auth/bind/policy`;正式+凭票才禁 attested) > 来源:宇恒客户端 `yuhengyihao_client` ↔ 本机智建 `127.0.0.1:8180`(gateway)/ `:8888`(platform) > 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md` @@ -26,7 +29,7 @@ | **Z9 模块导入默认能力(方案 A)** | **已落实(含存量)** | Z9a–f:启动扫库 + 一键开启导入 + 读路径内存兜底;见 §5.6 | | **Z10 空表双侧建齐** | **智建 API 已落实;宇恒待接** | `schema/ensure` + `schema` + pull `columns`;**Z10c 待宇恒** sync 周期调用,见 §5.7 | | **Z11 蓝图↔库列一致** | **智建已落实** | 发布自动 `ADD COLUMN IF NOT EXISTS`;导入 42703 可读提示;见 §5.8 | -| **Z12/Z13 开通/绑定 UX** | **智建 API 已落实;宇恒待接** | Z12a/b/c/e、Z13 绑定码+同号确认 API 已合入;**Z13d/e 待宇恒**;见 §5.9–5.10;**Z12d 通道 ID 可复制已有** | +| **Z12/Z13 开通/绑定 UX** | **智建已基本落实;宇恒待接** | 含凭票、试运行关短信(`RequireForBind=false`)、绑定码 UI;**Z13d/e 待宇恒**;见 §5.9–5.10 | | **仍建议关注** | 性能/运维 | SQLite remote **高并发 push** 易锁;agent 宜单库串行 drain(属宇恒用法,非平台硬改) | 宇恒对照脚本: @@ -66,7 +69,7 @@ | **生产 · 宇信达** | 公司管理 / 同号绑定联调 | **`13531041944`**(密码由开通时设定) | **专用联调号**;智建须在「宇信达」成员/管理员中写入此手机;宇恒 `YXD_SYNC_LOGIN_PHONE` 与 Z13 同号确认均以此号为准 | | 平台超管(禁止联调) | 控制台「填入超管手机」 | `13531041945` / `ljk_admin` | **最顶级管理员**;seed `ensureDevPhone(ljk_admin)`;**禁止**用作同步登录 / 绑定联调,会与公司账号冲突 | -> **智建待办**:生产「宇信达」写入成员手机 **`13531041944`**(公司管理员即可),勿再把同步联调绑到 `13531041945`。 +> **智建待办**:生产「宇信达」写入成员手机 **`13531041944`**(成员管理「绑定」即可;勿绑到超管 `13531041945`)。 --- @@ -452,7 +455,7 @@ POST .../schema | **Z12b** | **P0** | **智能体自查绑定** | `GET /api/v1/agents/me`:智能体 Bearer 可读自己的通道/线上库/状态 | **智建已落实** | | **Z12c** | **P0** | **启用即自动绑通道** | 创建/启用智能体 → `EnsureSystemDefaultChannel`(`IsSystemDefault`,表白名单可空);写回 `channel_id`+`online_db_id`;DSN 见 `DBSync.DefaultRemoteDSN`(空则 sqlite 联调文件) | **智建已落实** | | **Z12c-1** | **P0** | **账号级隔离** | Binding / push 校验:用户 JWT 只能写本人 `online_db_id` | **智建已有**(自助 Binding) | -| **Z12c-2** | **P1** | **公司共享库** | 支持标记 `shared=true` 的 `online_db_id` | **待智建** | +| **Z12c-2** | **P1** | **公司共享库** | Binding `shared=true`:同租户成员可访问该 `online_db_id`;仅管理员可设 | **智建已落实** | | **Z12d** | **P1** | **SyncPage 展示通道 ID** | 运维可见 + copyable | **智建已落实** | | **Z12e** | **P1** | **唯一默认通道** | 每公司至多 1 条 `is_system_default`;复用已有默认 | **智建已落实** + 宇恒「仅 1 条则写入 env」 | | **Z12f** | **P1** | **开通 UX** | 启用智能体即可;DSN 用公司级默认 | **智建已落实**(控制台引导文案可再收) | @@ -489,45 +492,57 @@ POST .../schema ├─ 已绑定本公司 → 静默换票;本机库选「同步」即可用 │ └─ 未绑定 - ├─ 读取当前宇恒账号手机号(若有) - │ └─ 智建侧已存在「相同手机号」的公司成员/管理员账号 - │ → 【必弹】「检测到手机号 xxx 在「宇信达」已有账号,是否绑定到该账号?」 - │ ├─ 确认 → 绑定到该已有账号(继承公司 + 默认同步落点) - │ └─ 取消 → 改走绑定码 / 换号 - ├─ 无同号 或 用户选择手动 - │ → 表单:【绑定码】或【手机号】 - │ · 填绑定码 → redeem - │ · 填手机号 → 若命中已有账号,同样走「是否绑定」确认,禁止静默绑错人 + ├─ 宇恒已绑手机号,且智建侧同号有成员 + │ → 【必弹】「检测到手机号 xxx 在「宇信达」已有账号,是否绑定?」 + │ ├─ 确认 → 绑定(免短信) + │ └─ 取消 → 改走绑定码 / 换号 + ├─ 表单:【绑定码】或【手机号】(宇恒未绑手机时也允许直接填手机号) + │ · 填绑定码 → redeem + │ · 填手机号 + │ ├─ 【试运行】短信未接入:一律免短信,仅弹「是否绑定」确认 + │ ├─ 【正式】宇恒已绑号且输入=已绑号 → 免短信 → lookup → 确认 + │ ├─ 【正式】宇恒未绑 / 异号 → 须短信 → lookup → 确认 + │ └─ 智建无此成员 → 提示引导用绑定码 └─ 都没有 → 明确提示联系管理员要绑定码 → 绑定成功后:本机选「同步」→ 直接 drain,零通道配置 ``` -**同号规则(产品硬约束)** +> **试运行约定(当前)**:尚未接入短信平台 → `SMS.RequireForBind=false`(或 `Provider=off`)**关闭绑定短信验证**;仍须用户确认「是否绑定」。接入短信后改为 `RequireForBind=true`,启用下表正式规则。 + +**同号 / 验证规则(正式目标;试运行短信步骤可跳过)** | 场景 | 行为 | |------|------| -| 宇恒手机号 = 公司内已有成员手机号 | **必须先询问**「已有账号,是否绑定」;不得静默绑定 | +| **试运行 / 短信未接入** | **不校验短信**;填手机号 + 确认即可绑定 | +| 宇恒**未**绑定手机号 | **仍可直接输入手机号**;正式环境须短信 | +| 宇恒已绑手机,输入 = 已绑号 | **免短信**;命中智建账号仍弹「是否绑定」 | +| 宇恒已绑手机,输入 ≠ 已绑号 | 正式环境**必须短信** | +| 宇恒/输入手机 = 公司内已有成员 | **必须先询问**「已有账号,是否绑定」;不得静默绑定 | | 用户点确认 | 绑定到该已有账号;智能体 host_key 挂到该公司 | | 用户点取消 | 不绑定;可改输绑定码或其他手机号 | | 手机号在公司不存在 | 提示无此成员;引导用绑定码 | -| 生产联调样例 | 宇恒号与智建均为 **`13531041944`** → 应弹出「宇信达已有账号是否绑定」;**勿用** `13531041945` | +| 生产联调样例 | 宇恒号与智建均为 **`13531041944`** → 弹确认;**勿用** `13531041945` | | 编号 | 优先级 | 诉求 | 说明 | 负责 | |------|--------|------|------|------| -| **Z13a** | **P0** | **后台生成绑定码** | `POST/GET/DELETE /api/v1/admin/bind-codes`(需「数据同步」);关联默认通道;过期/次数/撤销 | **智建已落实**(API;控制台 UI 可后补) | +| **Z13a** | **P0** | **后台生成绑定码** | `POST/GET/DELETE /api/v1/admin/bind-codes`;SyncPage「绑定码」区可生成/复制/撤销 | **智建已落实**(API + 控制台) | | **Z13b** | **P0** | **绑定码兑换 API** | `POST /api/v1/auth/bind-code/redeem`(`host_key` + `code`)→ 激活并绑定落点 | **智建已落实** | -| **Z13c** | **P0** | **手机号探测 API** | `POST /api/v1/auth/bind/phone-lookup` → `{ exists, tenant_name, masked_name, need_confirm }`,**不**直接绑定 | **智建已落实** | +| **Z13c** | **P0** | **手机号探测 API** | `POST /api/v1/auth/bind/phone-lookup` → `{ exists, tenant_name, masked_name, need_confirm, sms_required_unless_attested }`,**不**直接绑定 | **智建已落实** | | **Z13c-1** | **P0** | **同号确认绑定** | `POST /api/v1/auth/bind/phone-confirm`(`confirm=true` 才绑);写审计 | **智建已落实** + **宇恒弹窗待接** | +| **Z13c-2** | **P0** | **验证策略 / 凭票** | **试运行**:关短信(`RequireForBind=false` / `Provider=off`);`GET /auth/bind/policy` 可探测。**正式**:异号 `sms_code`;同号凭票优先,**正式且启凭票**才禁 attested | **智建开关+凭票+policy 已落实** + **宇恒对接** | | **Z13d** | **P0** | **未绑定检测与提示** | `sync_bound=false`:有手机号先 lookup;命中则弹确认;未命中再出绑定码/手机号表单 | **宇恒技能**(智建字段已提供) | | **Z13e** | **P0** | **选同步即用** | 库选「同步」→ 自动 Binding + drain;零手填通道 | **宇恒**(依赖 Z12/Z13) | -| **Z13f** | **P1** | **安全** | 路由级限速已有;确认绑定写审计;lookup 不回明文全名 | **智建部分落实**(防枚举可再加强) | +| **Z13f** | **P1** | **安全** | 试运行关短信;正式后异号强制短信;确认绑定写审计 | **智建开关已落实** | **验收** 1. 管理员生成绑定码 → 用户填码 → 绑定成功 → 库选「同步」→ 数据进该公司线上库。 2. **宇恒与智建同号 `13531041944`** → 必须弹出「已有账号是否绑定」;确认后绑定,取消则不绑(禁止用超管号 `13531041945` 测)。 -3. 未绑定用户:先同号探测,再绑定码/手机表单;**不**出现 DSN/通道 ID。 -4. 他人绑定码 / 未确认的同号,不能把数据绑进别的公司或别人账号。 +3. **宇恒未绑手机** → 仍可直接输入手机号;**试运行免短信**,正式后须短信。 +4. **试运行**:`RequireForBind=false` 时不带 `sms_code` 也可 confirm(仍须 `confirm=true`);`GET /auth/bind/policy` 返回 `trial_mode=true`。 +5. 未绑定用户:先同号探测,再绑定码/手机表单;**不**出现 DSN/通道 ID。 +6. 他人绑定码 / 未确认的同号,不能把数据绑进别的公司或别人账号。 +7. **正式且启凭票**:同号走 `ticket-exchange`,不再依赖明文 `attested_same_phone`。 --- ## 6. 联系与附件