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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 凭票)
|
||||
|
||||
## 运维高级:手建通道(过渡 / 多机)
|
||||
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
210
platform/internal/logic/applogic/yuheng_ticket.go
Normal file
210
platform/internal/logic/applogic/yuheng_ticket.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
187
platform/internal/yuhticket/ticket.go
Normal file
187
platform/internal/yuhticket/ticket.go
Normal file
@@ -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()
|
||||
}
|
||||
51
platform/internal/yuhticket/ticket_test.go
Normal file
51
platform/internal/yuhticket/ticket_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<SyncChannel[]>([]);
|
||||
const [bindings, setBindings] = useState<SyncBinding[]>([]);
|
||||
const [bindCodes, setBindCodes] = useState<BindCode[]>([]);
|
||||
const [agents, setAgents] = useState<AgentAccount[]>([]);
|
||||
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: {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
绑定码(发给终端开通)
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
关联本公司默认同步落点;终端用 <Typography.Text code>POST /auth/bind-code/redeem</Typography.Text>{" "}
|
||||
兑换。生产联调成员手机请用 <Typography.Text code>13531041944</Typography.Text>,勿用超管号。
|
||||
</Typography.Paragraph>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const bc = await createBindCode(session, { max_uses: 1, expires_hours: 168 });
|
||||
message.success(`已生成绑定码 ${bc.code}`);
|
||||
setInfo(`绑定码 ${bc.code}(可复制发给用户)`);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
生成绑定码
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="code"
|
||||
loading={loading}
|
||||
dataSource={bindCodes}
|
||||
pagination={false}
|
||||
locale={{ emptyText: "暂无绑定码" }}
|
||||
columns={[
|
||||
{
|
||||
title: "绑定码",
|
||||
dataIndex: "code",
|
||||
render: (c: string) => <Typography.Text copyable code>{c}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "通道",
|
||||
dataIndex: "channel_id",
|
||||
render: (id: string) =>
|
||||
id ? <Typography.Text copyable={{ text: id }}>{id.slice(0, 8)}…</Typography.Text> : "—",
|
||||
},
|
||||
{
|
||||
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 <Tag color="default">已撤销</Tag>;
|
||||
if (r.used_count >= r.max_uses) return <Tag>已用尽</Tag>;
|
||||
if (r.expires_at && new Date(r.expires_at).getTime() < Date.now())
|
||||
return <Tag color="warning">已过期</Tag>;
|
||||
return <Tag color="success">可用</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 90,
|
||||
render: (_: unknown, r: BindCode) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
disabled={!!r.revoked || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await revokeBindCode(session, r.code);
|
||||
setInfo(`已撤销 ${r.code}`);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
库绑定(本地 ↔ 线上)
|
||||
</Typography.Title>
|
||||
@@ -677,6 +779,12 @@ export function SyncPage(props: {
|
||||
},
|
||||
},
|
||||
{ title: "备注", dataIndex: "note", ellipsis: true },
|
||||
{
|
||||
title: "共享",
|
||||
width: 70,
|
||||
render: (_: unknown, b: SyncBinding) =>
|
||||
b.shared ? <Tag color="blue">共享</Tag> : <Typography.Text type="secondary">个人</Typography.Text>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -684,6 +792,7 @@ export function SyncPage(props: {
|
||||
冲突已改为自动 LWW;覆盖审计仅平台超级管理员可查。「同步修复」有最小间隔限流(默认 5 分钟)。
|
||||
↑ 推送次数 = agent push 成功次数,不是业务表数量。
|
||||
本地有、线上没有的表(如尚未 push 的业务表)属正常,不是 Binding 映射错了。
|
||||
Binding 标 <Typography.Text code>shared=true</Typography.Text> 为公司共享库(仅管理员可设)。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1143,6 +1143,7 @@ export async function ensureSyncBinding(
|
||||
display_name?: string;
|
||||
note?: string;
|
||||
user_id?: number;
|
||||
shared?: boolean;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/sync/bindings`, {
|
||||
@@ -1167,11 +1168,70 @@ export type SyncBinding = {
|
||||
channel_id?: string;
|
||||
database_name?: string;
|
||||
display_name?: string;
|
||||
shared?: boolean;
|
||||
note?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type BindCode = {
|
||||
code: string;
|
||||
tenant_id: number;
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
max_uses: number;
|
||||
used_count: number;
|
||||
revoked?: boolean;
|
||||
expires_at?: string;
|
||||
created_by?: number;
|
||||
created_at?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export async function listBindCodes(session: Session) {
|
||||
const res = await apiFetch(`/api/v1/admin/bind-codes`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "list bind codes failed");
|
||||
return data as { items: BindCode[] };
|
||||
}
|
||||
|
||||
export async function createBindCode(
|
||||
session: Session,
|
||||
body?: {
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
max_uses?: number;
|
||||
expires_hours?: number;
|
||||
note?: string;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/bind-codes`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "create bind code failed");
|
||||
return data as BindCode;
|
||||
}
|
||||
|
||||
export async function revokeBindCode(session: Session, code: string) {
|
||||
const res = await apiFetch(`/api/v1/admin/bind-codes/${encodeURIComponent(code)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "revoke bind code failed");
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
export async function listSyncBindings(session: Session, localDatabaseId?: string) {
|
||||
const q = localDatabaseId
|
||||
? `?local_database_id=${encodeURIComponent(localDatabaseId)}`
|
||||
|
||||
@@ -152,10 +152,57 @@ Content-Type: application/json
|
||||
|
||||
成功 → 智能体挂到该公司 + 默认同步落点,`sync_bound=true`。
|
||||
|
||||
### 同号探测 / 确认(Z13c / Z13c-1 · 硬约束)
|
||||
### 同号探测 / 确认(Z13c / Z13c-1)
|
||||
|
||||
**禁止静默绑定**。有手机号时必须先 lookup,命中后**弹窗确认**再 confirm。
|
||||
|
||||
**推荐(仅宇恒 · 安全凭票免登录)**:用户已登录宇恒时,由**宇恒后端**用共享 Secret 签发短时凭票,客户端调:
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/yuheng/ticket-exchange
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ticket": "YHT1.<payload>.<hmac>",
|
||||
"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) |
|
||||
|
||||
@@ -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. 联系与附件
|
||||
|
||||
Reference in New Issue
Block a user