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:
whm
2026-08-05 15:09:49 +08:00
parent f90245db9c
commit d195aa4804
19 changed files with 957 additions and 96 deletions

View 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
}