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:
91
platform/internal/logic/applogic/agent_sync_bind.go
Normal file
91
platform/internal/logic/applogic/agent_sync_bind.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package applogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"aijianzhan/platform/internal/agentstore"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
"aijianzhan/platform/internal/svc"
|
||||
"aijianzhan/platform/internal/types"
|
||||
"aijianzhan/platform/internal/userstore"
|
||||
)
|
||||
|
||||
// fillAgentSyncOnToken Z12a:换票带回同步落点。
|
||||
func fillAgentSyncOnToken(resp *types.TokenResp, acc *agentstore.Account) {
|
||||
if resp == nil || acc == nil {
|
||||
return
|
||||
}
|
||||
resp.ChannelID = strings.TrimSpace(acc.ChannelID)
|
||||
resp.OnlineDBID = strings.TrimSpace(acc.OnlineDBID)
|
||||
resp.DatabaseName = strings.TrimSpace(acc.DatabaseName)
|
||||
resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != ""
|
||||
}
|
||||
|
||||
// fillUserSyncOnToken:人类登录优先从本人 Binding 带回落点。
|
||||
func fillUserSyncOnToken(svcCtx *svc.ServiceContext, resp *types.TokenResp, u *userstore.User) {
|
||||
if resp == nil || u == nil || svcCtx == nil || svcCtx.DBSync == nil || u.TenantID <= 0 {
|
||||
return
|
||||
}
|
||||
list, err := svcCtx.DBSync.Store().ListBindingsFiltered(u.TenantID, u.UserID, "")
|
||||
if err != nil || len(list) == 0 {
|
||||
return
|
||||
}
|
||||
b := list[0]
|
||||
for i := range list {
|
||||
if list[i].ChannelID != "" && list[i].OnlineDBID != "" {
|
||||
b = list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.ChannelID = strings.TrimSpace(b.ChannelID)
|
||||
resp.OnlineDBID = strings.TrimSpace(b.OnlineDBID)
|
||||
resp.DatabaseName = strings.TrimSpace(b.DatabaseName)
|
||||
if resp.DatabaseName == "" {
|
||||
resp.DatabaseName = strings.TrimSpace(b.DisplayName)
|
||||
}
|
||||
resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != ""
|
||||
}
|
||||
|
||||
// ensureAgentSyncBind Z12c:启用/创建时若无通道则自动创建公司默认同步通道并写回智能体。
|
||||
func (l *AgentAdminLogic) ensureAgentSyncBind(acc *agentstore.Account) (*agentstore.Account, error) {
|
||||
if acc == nil {
|
||||
return nil, fmt.Errorf("agent nil")
|
||||
}
|
||||
if strings.TrimSpace(acc.ChannelID) != "" && strings.TrimSpace(acc.OnlineDBID) != "" {
|
||||
return acc, nil
|
||||
}
|
||||
if l.svcCtx.DBSync == nil {
|
||||
return acc, nil
|
||||
}
|
||||
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: acc.TenantID,
|
||||
AgentID: acc.AgentID,
|
||||
Name: fmt.Sprintf("默认同步 · %s", acc.Name),
|
||||
RemoteDriver: driver,
|
||||
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
|
||||
DatabaseName: acc.DatabaseName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure default channel: %w", err)
|
||||
}
|
||||
online := dbsync.ResolveOnlineDBID(acc.OnlineDBID, ch.ID)
|
||||
dbName := strings.TrimSpace(acc.DatabaseName)
|
||||
if dbName == "" {
|
||||
dbName = fmt.Sprintf("agent_%d", acc.AgentID)
|
||||
}
|
||||
st, err := l.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := st.AttachSyncBind(l.ctx, acc.AgentID, acc.TenantID, ch.ID, online, dbName, acc.Status == agentstore.StatusActive || acc.Status == "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -111,6 +111,13 @@ func (l *AgentAdminLogic) Create(req *types.AgentCreateReq) (*types.AgentCreateR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acc2, err := l.ensureAgentSyncBind(acc); err == nil && acc2 != nil {
|
||||
acc = acc2
|
||||
} else if err != nil {
|
||||
// 自动绑通道失败不阻断创建,但返回提示
|
||||
l.attachRole(acc)
|
||||
return &types.AgentCreateResp{Account: *acc, ClientSecret: secret}, nil
|
||||
}
|
||||
l.attachRole(acc)
|
||||
return &types.AgentCreateResp{Account: *acc, ClientSecret: secret}, nil
|
||||
}
|
||||
@@ -194,6 +201,12 @@ func (l *AgentAdminLogic) Update(agentID int64, req *types.AgentUpdateReq) (*age
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Z12c:启用为 active 且无通道时自动绑默认同步通道
|
||||
if acc.Status == agentstore.StatusActive {
|
||||
if acc2, err := l.ensureAgentSyncBind(acc); err == nil && acc2 != nil {
|
||||
acc = acc2
|
||||
}
|
||||
}
|
||||
l.attachRole(acc)
|
||||
return acc, nil
|
||||
}
|
||||
@@ -239,7 +252,7 @@ func (l *AuthLogic) IssueClientCredentials(clientID, clientSecret string) (*type
|
||||
if secret == "" {
|
||||
secret = l.svcCtx.JWT.AccessSecret
|
||||
}
|
||||
return &types.TokenResp{
|
||||
resp := &types.TokenResp{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresAt: exp,
|
||||
@@ -252,5 +265,7 @@ func (l *AuthLogic) IssueClientCredentials(clientID, clientSecret string) (*type
|
||||
AgentID: acc.AgentID,
|
||||
Permissions: append([]string{}, acc.Perms...),
|
||||
AppSlugs: append([]string{}, acc.AppSlugs...),
|
||||
}, nil
|
||||
}
|
||||
fillAgentSyncOnToken(resp, acc)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
357
platform/internal/logic/applogic/bind_flow.go
Normal file
357
platform/internal/logic/applogic/bind_flow.go
Normal 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))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package applogic
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"aijianzhan/platform/internal/meta"
|
||||
"aijianzhan/platform/internal/types"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
@@ -24,8 +26,10 @@ func (l *CrudLogic) ImportRows(slug, resource, filename string, r io.Reader) (*t
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Z9f:读路径内存兜底(与 Z9e 扫库双保险)
|
||||
ensureImportOpInMemory(ref)
|
||||
if !hasOp(ref, "import") {
|
||||
return nil, fmt.Errorf("operation import not allowed")
|
||||
return nil, fmt.Errorf("蓝图未开启 import(operations 缺 import)。请管理员执行「一键开启全部业务表导入」或再发布模块;与账号权限 row.import 无关")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
@@ -86,7 +90,11 @@ func (l *CrudLogic) ImportRows(slug, resource, filename string, r io.Reader) (*t
|
||||
if _, err := l.svcCtx.CRUD.Create(l.ctx, ref, tenantID, userID, body); err != nil {
|
||||
resp.Skipped++
|
||||
if len(resp.Errors) < 100 {
|
||||
resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %v", rowNum+1, err))
|
||||
msg := err.Error()
|
||||
if isUndefinedColumnErr(err) {
|
||||
msg = fmt.Sprintf("%v(蓝图字段与库表不一致,请重新发布模块以自动补列,或缩小 Excel 表头至库已有列)", err)
|
||||
}
|
||||
resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %s", rowNum+1, msg))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -107,8 +115,9 @@ func (l *CrudLogic) ExportExcel(slug, resource string) ([]byte, string, error) {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ensureImportOpInMemory(ref)
|
||||
if !hasOp(ref, "export") {
|
||||
return nil, "", fmt.Errorf("operation export not allowed")
|
||||
return nil, "", fmt.Errorf("蓝图未开启 export(operations 缺 export)。请管理员执行「一键开启全部业务表导入」或再发布模块")
|
||||
}
|
||||
items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "")
|
||||
if err != nil {
|
||||
@@ -147,8 +156,9 @@ func (l *CrudLogic) ExportCSV(slug, resource string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ensureImportOpInMemory(ref)
|
||||
if !hasOp(ref, "export") {
|
||||
return nil, fmt.Errorf("operation export not allowed")
|
||||
return nil, fmt.Errorf("蓝图未开启 export(operations 缺 export)。请管理员执行「一键开启全部业务表导入」或再发布模块")
|
||||
}
|
||||
items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "")
|
||||
if err != nil {
|
||||
@@ -237,6 +247,9 @@ func rowEmpty(rec []string) bool {
|
||||
}
|
||||
|
||||
func hasOp(ref *meta.ResourceRef, op string) bool {
|
||||
if ref == nil {
|
||||
return false
|
||||
}
|
||||
for _, o := range ref.Resource.Operations {
|
||||
if o == op {
|
||||
return true
|
||||
@@ -245,6 +258,37 @@ func hasOp(ref *meta.ResourceRef, op string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureImportOpInMemory Z9f:对已加载蓝图做与 EnsureDefaultImportExport 相同的内存补齐,并刷新 ref.Resource。
|
||||
func ensureImportOpInMemory(ref *meta.ResourceRef) {
|
||||
if ref == nil || ref.App == nil || ref.App.Blueprint == nil {
|
||||
return
|
||||
}
|
||||
ref.App.Blueprint.EnsureDefaultImportExport()
|
||||
want := strings.TrimSpace(ref.Resource.Path)
|
||||
if len(want) > 0 && want[0] == '/' {
|
||||
want = want[1:]
|
||||
}
|
||||
for _, r := range ref.App.Blueprint.Apis.Resources {
|
||||
path := r.Path
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
if path == want {
|
||||
ref.Resource = r
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isUndefinedColumnErr(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "42703" {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "42703") || strings.Contains(msg, "字段不存在") || strings.Contains(msg, "does not exist")
|
||||
}
|
||||
|
||||
func mapHeaderToField(ref *meta.ResourceRef, header string) string {
|
||||
h := strings.TrimSpace(header)
|
||||
if h == "" {
|
||||
|
||||
@@ -347,6 +347,26 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
}
|
||||
}
|
||||
|
||||
// Z11a:蓝图新增字段 → ALTER TABLE ADD COLUMN IF NOT EXISTS
|
||||
inspectDB := appDB
|
||||
if inspectDB == nil {
|
||||
inspectDB = l.svcCtx.DB
|
||||
}
|
||||
if inspectDB != nil {
|
||||
alter, err := schema.BuildPostgresAlterDDL(l.ctx, inspectDB, bp)
|
||||
if err != nil {
|
||||
rec.Status = meta.StatusFailed
|
||||
rec.Error = err.Error()
|
||||
_ = l.svcCtx.Meta.Save(l.ctx, rec)
|
||||
return nil, fmt.Errorf("build alter ddl: %w", err)
|
||||
}
|
||||
if len(alter) > 0 {
|
||||
ddl = schema.MergeDDL(ddl, alter)
|
||||
rec.DDL = ddl
|
||||
_ = l.svcCtx.Meta.Save(l.ctx, rec)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runner.ExecDDL(l.ctx, ddl); err != nil {
|
||||
rec.Status = meta.StatusFailed
|
||||
rec.Error = err.Error()
|
||||
|
||||
@@ -28,6 +28,7 @@ func (l *AuthLogic) issueUser(u *userstore.User) (*types.TokenResp, error) {
|
||||
resp.Status = userstore.StatusPending
|
||||
}
|
||||
}
|
||||
fillUserSyncOnToken(l.svcCtx, resp, u)
|
||||
if u.IsPlatformAdmin() {
|
||||
resp.Message = "平台超级管理员工作台:管理全部公司;打开某公司可查看其内部功能"
|
||||
return resp, nil
|
||||
|
||||
Reference in New Issue
Block a user