Create and restart paths enable IsSystemDefault channels; SyncPage auto-starts after save. Co-authored-by: Cursor <cursoragent@cursor.com>
404 lines
13 KiB
Go
404 lines
13 KiB
Go
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/smsstore"
|
||
"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)
|
||
}
|
||
_ = l.svcCtx.DBSync.StartChannel(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.EnsureAndStartSystemDefaultChannel(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"`
|
||
// 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 {
|
||
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) {
|
||
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: "手机号格式不正确", SMSRequiredUnlessAttested: smsReq}, nil
|
||
}
|
||
u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone)
|
||
if err != nil || u == nil {
|
||
return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员", SMSRequiredUnlessAttested: smsReq}, nil
|
||
}
|
||
if u.TenantID <= 0 {
|
||
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,
|
||
SMSRequiredUnlessAttested: smsReq,
|
||
Message: msg,
|
||
}, 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"`
|
||
// Z13c-2:输入号=宇恒已绑手机时,宇恒置 true 可免短信;异号必须带 sms_code
|
||
AttestedSamePhone bool `json:"attested_same_phone"`
|
||
SMSCode string `json:"sms_code"`
|
||
}
|
||
|
||
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) 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)")
|
||
}
|
||
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("手机号格式不正确")
|
||
}
|
||
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")
|
||
}
|
||
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("该账号尚未加入公司")
|
||
}
|
||
updated, _, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_ = l.ensureAgentSyncPerm(updated)
|
||
_ = l.writeBindAudit("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{
|
||
"phone": phone, "user_id": u.UserID, "channel_id": updated.ChannelID, "online_db_id": updated.OnlineDBID,
|
||
})
|
||
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))
|
||
}
|