feat: add Z12/Z13 bind APIs, stock import, and sync docs

Enable auto default sync channels on agent activate, bind-code/phone confirm flows, publish ALTER, and align admin/yuheng docs with the production bind path.
This commit is contained in:
whm
2026-08-05 11:47:20 +08:00
parent b04b180d30
commit cb56e6847e
31 changed files with 1882 additions and 91 deletions

View File

@@ -0,0 +1,357 @@
package applogic
import (
"encoding/json"
"fmt"
"strings"
"time"
"aijianzhan/platform/internal/agentstore"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/bindcodestore"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/userstore"
)
// AgentMe Z12b智能体自查绑定无需「管理智能体」
func (l *AuthLogic) AgentMe() (*agentstore.Account, error) {
if !authx.IsAgent(authx.Role(l.ctx)) {
return nil, fmt.Errorf("仅智能体可访问")
}
if l.svcCtx.Agents == nil {
return nil, fmt.Errorf("agent store unavailable")
}
tid := authx.TenantID(l.ctx)
aid := authx.UserID(l.ctx)
acc, err := l.svcCtx.Agents.Get(l.ctx, tid, aid)
if err != nil {
return nil, err
}
return acc, nil
}
type BindCodeCreateReq struct {
ChannelID string `json:"channel_id"`
OnlineDBID string `json:"online_db_id"`
DatabaseName string `json:"database_name"`
MaxUses int `json:"max_uses"`
ExpiresHours int `json:"expires_hours"` // 0=168h
Note string `json:"note"`
}
func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCode, error) {
if l.svcCtx.BindCodes == nil {
return nil, fmt.Errorf("bind code store unavailable")
}
tid := authx.TenantID(l.ctx)
if tid <= 0 {
return nil, fmt.Errorf("未加入公司")
}
channelID := strings.TrimSpace(req.ChannelID)
online := strings.TrimSpace(req.OnlineDBID)
dbName := strings.TrimSpace(req.DatabaseName)
if channelID == "" && l.svcCtx.DBSync != nil {
if ch, err := l.svcCtx.DBSync.Store().FindSystemDefaultChannel(tid); err == nil && ch != nil {
channelID = ch.ID
if online == "" {
online = dbsync.ResolveOnlineDBID("", ch.ID)
}
} else {
// 尝试创建默认同步通道
cfg := l.svcCtx.Config.DBSync
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
if driver == "" {
driver = dbsync.DriverPostgres
}
saved, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(dbsync.DefaultChannelOpts{
TenantID: tid,
RemoteDriver: driver,
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
})
if err != nil {
return nil, fmt.Errorf("无默认同步通道:%w", err)
}
channelID = saved.ID
if online == "" {
online = dbsync.ResolveOnlineDBID("", saved.ID)
}
}
}
if channelID == "" {
return nil, fmt.Errorf("请先启用智能体以生成默认同步通道,或指定 channel_id")
}
if online == "" {
online = dbsync.ResolveOnlineDBID("", channelID)
}
exp := time.Duration(req.ExpiresHours) * time.Hour
return l.svcCtx.BindCodes.Create(l.ctx, tid, authx.UserID(l.ctx), bindcodestore.CreateInput{
ChannelID: channelID,
OnlineDBID: online,
DatabaseName: dbName,
MaxUses: req.MaxUses,
ExpiresIn: exp,
Note: req.Note,
})
}
func (l *AuthLogic) ListBindCodes() ([]bindcodestore.BindCode, error) {
if l.svcCtx.BindCodes == nil {
return nil, fmt.Errorf("bind code store unavailable")
}
tid := authx.TenantID(l.ctx)
return l.svcCtx.BindCodes.List(l.ctx, tid)
}
func (l *AuthLogic) RevokeBindCode(code string) error {
if l.svcCtx.BindCodes == nil {
return fmt.Errorf("bind code store unavailable")
}
return l.svcCtx.BindCodes.Revoke(l.ctx, authx.TenantID(l.ctx), code)
}
type BindCodeRedeemReq struct {
Code string `json:"code"`
HostKey string `json:"host_key"`
Name string `json:"name"` // 可选:无 agent 时注册用
}
type BindCodeRedeemResp struct {
OK bool `json:"ok"`
TenantID int64 `json:"tenant_id"`
AgentID int64 `json:"agent_id"`
ClientID string `json:"client_id,omitempty"`
ChannelID string `json:"channel_id"`
OnlineDBID string `json:"online_db_id"`
DatabaseName string `json:"database_name,omitempty"`
SyncBound bool `json:"sync_bound"`
Message string `json:"message,omitempty"`
}
func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp, error) {
if l.svcCtx.BindCodes == nil || l.svcCtx.Agents == nil {
return nil, fmt.Errorf("bind service unavailable")
}
code := strings.TrimSpace(req.Code)
hostKey := strings.TrimSpace(req.HostKey)
if code == "" || hostKey == "" {
return nil, fmt.Errorf("code and host_key required")
}
bc, err := l.svcCtx.BindCodes.Redeem(l.ctx, code)
if err != nil {
return nil, err
}
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
if err != nil {
name := strings.TrimSpace(req.Name)
if name == "" {
name = "离线终端"
}
created, secret, _, regErr := l.svcCtx.Agents.Register(l.ctx, bc.TenantID, name, hostKey)
if regErr != nil {
return nil, fmt.Errorf("register agent: %w", regErr)
}
_ = secret
acc = created
}
online := strings.TrimSpace(bc.OnlineDBID)
if online == "" {
online = dbsync.ResolveOnlineDBID("", bc.ChannelID)
}
dbName := strings.TrimSpace(bc.DatabaseName)
if dbName == "" {
dbName = fmt.Sprintf("agent_%d", acc.AgentID)
}
updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, bc.TenantID, bc.ChannelID, online, dbName, true)
if err != nil {
return nil, err
}
_ = l.writeBindAudit("bind_code_redeem", updated.TenantID, updated.AgentID, map[string]any{
"code": bc.Code, "channel_id": bc.ChannelID, "online_db_id": online,
})
return &BindCodeRedeemResp{
OK: true,
TenantID: updated.TenantID,
AgentID: updated.AgentID,
ClientID: updated.ClientID,
ChannelID: updated.ChannelID,
OnlineDBID: updated.OnlineDBID,
DatabaseName: updated.DatabaseName,
SyncBound: true,
Message: "绑定成功",
}, nil
}
type PhoneLookupReq struct {
Phone string `json:"phone"`
}
type PhoneLookupResp struct {
Exists bool `json:"exists"`
TenantID int64 `json:"tenant_id,omitempty"`
TenantName string `json:"tenant_name,omitempty"`
MaskedName string `json:"masked_name,omitempty"`
NeedConfirm bool `json:"need_confirm"`
Message string `json:"message,omitempty"`
}
func maskDisplayName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "***"
}
r := []rune(name)
if len(r) == 1 {
return string(r[0]) + "*"
}
if len(r) == 2 {
return string(r[0]) + "*"
}
return string(r[0]) + strings.Repeat("*", len(r)-2) + string(r[len(r)-1])
}
func (l *AuthLogic) PhoneLookup(req PhoneLookupReq) (*PhoneLookupResp, error) {
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
}
u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone)
if err != nil || u == nil {
return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员"}, nil
}
if u.TenantID <= 0 {
return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司"}, nil
}
tenantName := ""
if t, err := l.svcCtx.Users.GetTenant(l.ctx, u.TenantID); err == nil && t != nil {
tenantName = t.Name
}
return &PhoneLookupResp{
Exists: true,
TenantID: u.TenantID,
TenantName: tenantName,
MaskedName: maskDisplayName(u.DisplayName),
NeedConfirm: true,
Message: fmt.Sprintf("已找到账号「%s」所属「%s」是否绑定到本机", maskDisplayName(u.DisplayName), tenantName),
}, nil
}
type PhoneConfirmReq struct {
Phone string `json:"phone"`
HostKey string `json:"host_key"`
Name string `json:"name"`
Confirm bool `json:"confirm"` // 必须 true
LocalDBID string `json:"local_database_id"`
}
type PhoneConfirmResp struct {
OK bool `json:"ok"`
TenantID int64 `json:"tenant_id"`
AgentID int64 `json:"agent_id"`
ChannelID string `json:"channel_id"`
OnlineDBID string `json:"online_db_id"`
DatabaseName string `json:"database_name,omitempty"`
SyncBound bool `json:"sync_bound"`
Message string `json:"message,omitempty"`
}
func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) {
if !req.Confirm {
return nil, fmt.Errorf("须明确确认绑定confirm=true")
}
if l.svcCtx.Users == nil || l.svcCtx.Agents == nil || l.svcCtx.DBSync == nil {
return nil, fmt.Errorf("bind service unavailable")
}
phone, err := userstore.NormalizePhone(req.Phone)
if err != nil {
return nil, fmt.Errorf("手机号格式不正确")
}
hostKey := strings.TrimSpace(req.HostKey)
if hostKey == "" {
return nil, fmt.Errorf("host_key required")
}
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("该账号尚未加入公司")
}
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)
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.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,
})
return &PhoneConfirmResp{
OK: true,
TenantID: updated.TenantID,
AgentID: updated.AgentID,
ChannelID: updated.ChannelID,
OnlineDBID: updated.OnlineDBID,
DatabaseName: updated.DatabaseName,
SyncBound: true,
Message: "绑定成功",
}, nil
}
func (l *AuthLogic) writeBindAudit(action string, tenantID, agentID int64, detail map[string]any) error {
if l.svcCtx.Audit == nil {
return nil
}
if detail == nil {
detail = map[string]any{}
}
detail["agent_id"] = agentID
b, _ := json.Marshal(detail)
return l.svcCtx.Audit.Log(l.ctx, tenantID, authx.UserID(l.ctx), action, string(b))
}