07955ddf07
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感), 不接管消息推送,副作用最小。 流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 → 回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized → PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。 - 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets; - ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis); - User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一: 存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败 —— 与之前 NULL 余额同类的坑,这次提前避开。 单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、 建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了, 留待部署后真机扫码。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
467 lines
17 KiB
Go
467 lines
17 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// Space 是一个「工作区/协作空间」——共享的资源容器(增量3,见 SPACE_DESIGN.md)。
|
||
// 资源(Agent/KB…)挂在 Space 上而非 user 上,让"个人私有 / 项目临时组队 / 整租户共享"三种场景
|
||
// 出自同一模型。Space 属单一租户(隔离/计费边界仍在租户)。
|
||
// 与 Tenant/TenantMember 一样是"基础设施表",不标 isTenantScoped(tenant 过滤在查询里显式做)。
|
||
type Space struct {
|
||
BaseModel
|
||
TenantID string `gorm:"size:64;index"`
|
||
Name string `gorm:"size:128"`
|
||
Kind string `gorm:"size:16;index"` // personal / project / tenant
|
||
Creator string `gorm:"size:64;index"` // 建者 user.id
|
||
Archived bool // 软归档(临时空间完事即归档,从切换器隐藏)
|
||
}
|
||
|
||
func (Space) TableName() string { return "sundynix_space" }
|
||
|
||
// SpaceMember 是空间成员关系(谁在这个空间 + 空间内角色)。复用租户角色语义(RoleRank 阶梯)。
|
||
type SpaceMember struct {
|
||
BaseModel
|
||
SpaceID string `gorm:"size:64;uniqueIndex:idx_space_user;index"`
|
||
UserID string `gorm:"size:64;uniqueIndex:idx_space_user;index"`
|
||
Role string `gorm:"size:16"`
|
||
Status string `gorm:"size:16"` // active / removed
|
||
}
|
||
|
||
func (SpaceMember) TableName() string { return "sundynix_space_member" }
|
||
|
||
// 空间类型常量。
|
||
const (
|
||
SpaceKindPersonal = "personal"
|
||
SpaceKindProject = "project"
|
||
SpaceKindTenant = "tenant"
|
||
)
|
||
|
||
// ---- 基础查询 ----
|
||
|
||
// tenantIDForSpace 返回某空间的租户 id(异步入库路径按 space 补 tenant_id 用)。
|
||
func (p *Postgres) tenantIDForSpace(ctx context.Context, spaceID string) string {
|
||
if p.db == nil || spaceID == "" {
|
||
return ""
|
||
}
|
||
var s Space
|
||
if err := p.db.WithContext(ctx).Select("tenant_id").First(&s, "id = ?", spaceID).Error; err != nil {
|
||
return ""
|
||
}
|
||
return s.TenantID
|
||
}
|
||
|
||
// GetSpace 按 id 取空间(不存在返回 nil)。
|
||
func (p *Postgres) GetSpace(ctx context.Context, id string) (*Space, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
var s Space
|
||
if err := p.db.WithContext(ctx).First(&s, "id = ?", id).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return &s, nil
|
||
}
|
||
|
||
// SpaceMemberRole 返回用户在某空间的角色(无 active 成员关系返回空)。
|
||
func (p *Postgres) SpaceMemberRole(ctx context.Context, spaceID, userID string) string {
|
||
if p.db == nil {
|
||
return ""
|
||
}
|
||
var m SpaceMember
|
||
if err := p.db.WithContext(ctx).
|
||
Where("space_id = ? AND user_id = ? AND status = ?", spaceID, userID, "active").
|
||
First(&m).Error; err != nil {
|
||
return ""
|
||
}
|
||
return m.Role
|
||
}
|
||
|
||
// addSpaceMember 幂等加成员(命中唯一 (space_id,user_id) 则改角色并置 active,复活已移除者)。
|
||
func (p *Postgres) addSpaceMember(ctx context.Context, spaceID, userID, role string) error {
|
||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||
Columns: []clause.Column{{Name: "space_id"}, {Name: "user_id"}},
|
||
DoUpdates: clause.Assignments(map[string]any{"role": role, "status": "active", "updated_at": time.Now()}),
|
||
}).Create(&SpaceMember{SpaceID: spaceID, UserID: userID, Role: role, Status: "active"}).Error
|
||
}
|
||
|
||
// ---- 个人空间(迁移地基)----
|
||
|
||
// personalSpaceID 返回用户在某租户的个人空间 id(无则空)。
|
||
func (p *Postgres) personalSpaceID(ctx context.Context, tenantID, userID string) string {
|
||
var s Space
|
||
if err := p.db.WithContext(ctx).Select("id").
|
||
Where("tenant_id = ? AND creator = ? AND kind = ?", tenantID, userID, SpaceKindPersonal).
|
||
Order("created_at asc").First(&s).Error; err != nil {
|
||
return ""
|
||
}
|
||
return s.ID
|
||
}
|
||
|
||
// EnsurePersonalSpace 幂等保证用户在某租户有个人空间(无则建 + 建者为 owner 成员)。
|
||
func (p *Postgres) EnsurePersonalSpace(ctx context.Context, tenantID, userID string) (*Space, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
if id := p.personalSpaceID(ctx, tenantID, userID); id != "" {
|
||
return p.GetSpace(ctx, id)
|
||
}
|
||
s := &Space{TenantID: tenantID, Name: "个人空间", Kind: SpaceKindPersonal, Creator: userID}
|
||
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if err := p.addSpaceMember(ctx, s.ID, userID, RoleOwner); err != nil {
|
||
return nil, err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// ---- 活跃空间解析 / 切换 ----
|
||
|
||
// ActiveSpaceForUser 解析用户在某租户的活跃空间:user.active_space_id 若有效(存在、属该租户、且用户是
|
||
// active 成员)→ 用之;否则回退该租户的个人空间(并保证其存在)。SpaceContext 中间件每请求调用。
|
||
func (p *Postgres) ActiveSpaceForUser(ctx context.Context, tenantID, userID string) (*Space, error) {
|
||
def, err := p.EnsurePersonalSpace(ctx, tenantID, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var u User
|
||
if e := p.db.WithContext(ctx).Select("active_space_id").First(&u, "id = ?", userID).Error; e == nil {
|
||
if u.ActiveSpaceID != "" && u.ActiveSpaceID != def.ID {
|
||
if s, _ := p.GetSpace(ctx, u.ActiveSpaceID); s != nil && s.TenantID == tenantID && !s.Archived &&
|
||
p.SpaceMemberRole(ctx, s.ID, userID) != "" {
|
||
return s, nil
|
||
}
|
||
}
|
||
}
|
||
return def, nil
|
||
}
|
||
|
||
// SetActiveSpace 切换用户活跃空间(须为其 active 成员)。
|
||
func (p *Postgres) SetActiveSpace(ctx context.Context, userID, spaceID string) error {
|
||
if p.db == nil {
|
||
return errStoreDisabled
|
||
}
|
||
if p.SpaceMemberRole(ctx, spaceID, userID) == "" {
|
||
return errors.New("你不是该空间的成员")
|
||
}
|
||
return p.db.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Update("active_space_id", spaceID).Error
|
||
}
|
||
|
||
// ---- 空间列表 / 创建 ----
|
||
|
||
// SpaceInfo 是空间列表一行(含当前用户角色 + 成员数)。
|
||
type SpaceInfo struct {
|
||
ID string `json:"id"`
|
||
TenantID string `json:"tenant_id"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Creator string `json:"creator"`
|
||
Archived bool `json:"archived"`
|
||
Role string `json:"role"` // 当前用户在此空间的角色
|
||
Members int64 `json:"members"` // active 成员数
|
||
}
|
||
|
||
// MySpaces 返回某用户在某租户内所属(active 成员)的空间(供切换器;含角色 + 成员数)。
|
||
func (p *Postgres) MySpaces(ctx context.Context, tenantID, userID string) ([]SpaceInfo, error) {
|
||
if p.db == nil {
|
||
return nil, nil
|
||
}
|
||
var out []SpaceInfo
|
||
err := p.db.WithContext(ctx).Table("sundynix_space s").
|
||
Select("s.id, s.tenant_id, s.name, s.kind, s.creator, s.archived, m.role, "+
|
||
"(SELECT count(*) FROM sundynix_space_member m2 WHERE m2.space_id = s.id AND m2.status = 'active') as members").
|
||
Joins("JOIN sundynix_space_member m ON m.space_id = s.id").
|
||
Where("m.user_id = ? AND m.status = 'active' AND s.tenant_id = ? AND s.deleted_at IS NULL", userID, tenantID).
|
||
Order("s.kind = 'personal' desc, s.created_at asc").Scan(&out).Error
|
||
return out, err
|
||
}
|
||
|
||
// CreateSpace 在某租户建一个空间(建者自动成为 owner 成员)。kind 空则 project。
|
||
func (p *Postgres) CreateSpace(ctx context.Context, tenantID, name, kind, creator string) (*Space, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
if kind == "" {
|
||
kind = SpaceKindProject
|
||
}
|
||
if strings.TrimSpace(name) == "" {
|
||
return nil, errors.New("空间名必填")
|
||
}
|
||
s := &Space{TenantID: tenantID, Name: strings.TrimSpace(name), Kind: kind, Creator: creator}
|
||
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if err := p.addSpaceMember(ctx, s.ID, creator, RoleOwner); err != nil {
|
||
return nil, err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// ---- 全员空间(tenant-kind:整租户共享,自动纳入全部成员)----
|
||
|
||
// tenantSpaceID 返回某租户的全员空间 id(无则空 = 未启用)。
|
||
func (p *Postgres) tenantSpaceID(ctx context.Context, tenantID string) string {
|
||
var s Space
|
||
if err := p.db.WithContext(ctx).Select("id").
|
||
Where("tenant_id = ? AND kind = ? AND deleted_at IS NULL AND archived = false", tenantID, SpaceKindTenant).
|
||
Order("created_at asc").First(&s).Error; err != nil {
|
||
return ""
|
||
}
|
||
return s.ID
|
||
}
|
||
|
||
// spaceRoleFromTenant 把租户角色映射为空间角色:billing_admin(财务只读)→viewer,其余照搬。
|
||
func spaceRoleFromTenant(role string) string {
|
||
if role == RoleBillingAdmin {
|
||
return RoleViewer
|
||
}
|
||
return role
|
||
}
|
||
|
||
// EnableTenantSpace 幂等启用某租户的全员空间:已启用则返回;否则建 kind=tenant 空间 +
|
||
// 把当前全部 active 租户成员按其租户角色纳入(owner→owner…billing_admin→viewer)。
|
||
// 新加入租户的成员由 AddMemberByEmail 自动补纳(见其实现)。
|
||
func (p *Postgres) EnableTenantSpace(ctx context.Context, tenantID string) (*Space, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
if id := p.tenantSpaceID(ctx, tenantID); id != "" {
|
||
return p.GetSpace(ctx, id)
|
||
}
|
||
members, err := p.ListMembers(ctx, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
creator := ""
|
||
for _, m := range members {
|
||
if m.Role == RoleOwner {
|
||
creator = m.UserID
|
||
break
|
||
}
|
||
}
|
||
s := &Space{TenantID: tenantID, Name: "全员空间", Kind: SpaceKindTenant, Creator: creator}
|
||
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for _, m := range members {
|
||
if m.Status != "active" {
|
||
continue
|
||
}
|
||
if err := p.addSpaceMember(ctx, s.ID, m.UserID, spaceRoleFromTenant(m.Role)); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// autoJoinTenantSpace 若某租户已启用全员空间,把用户按其租户角色纳入(AddMemberByEmail 调用)。
|
||
func (p *Postgres) autoJoinTenantSpace(ctx context.Context, tenantID, userID, tenantRole string) {
|
||
if sid := p.tenantSpaceID(ctx, tenantID); sid != "" {
|
||
_ = p.addSpaceMember(ctx, sid, userID, spaceRoleFromTenant(tenantRole))
|
||
}
|
||
}
|
||
|
||
// ArchiveSpace 归档空间(软;个人空间不可归档)。
|
||
func (p *Postgres) ArchiveSpace(ctx context.Context, spaceID string) error {
|
||
if p.db == nil {
|
||
return errStoreDisabled
|
||
}
|
||
s, err := p.GetSpace(ctx, spaceID)
|
||
if err != nil || s == nil {
|
||
return errors.New("空间不存在")
|
||
}
|
||
if s.Kind == SpaceKindPersonal {
|
||
return errors.New("个人空间不可归档")
|
||
}
|
||
return p.db.WithContext(ctx).Model(&Space{}).Where("id = ?", spaceID).Update("archived", true).Error
|
||
}
|
||
|
||
// ---- 空间成员 CRUD(照 TenantMember 套路)----
|
||
|
||
// ListSpaceMembers 返回某空间成员(含邮箱/名字/角色),排除已移除。
|
||
func (p *Postgres) ListSpaceMembers(ctx context.Context, spaceID string) ([]MemberInfo, error) {
|
||
if p.db == nil {
|
||
return nil, nil
|
||
}
|
||
var out []MemberInfo
|
||
err := p.db.WithContext(ctx).Table("sundynix_space_member m").
|
||
Select("m.user_id, u.email, u.name, m.role, m.status, m.created_at as joined_at").
|
||
Joins("JOIN sundynix_user u ON u.id = m.user_id").
|
||
Where("m.space_id = ? AND m.status <> ?", spaceID, "removed").
|
||
Order("m.created_at asc").Scan(&out).Error
|
||
return out, err
|
||
}
|
||
|
||
// AddSpaceMemberByEmail 按邮箱把已注册用户加入空间(幂等;须对方先注册)。
|
||
// 调用方应先确保该用户是租户成员(空间成员应是租户成员的子集)。
|
||
func (p *Postgres) AddSpaceMemberByEmail(ctx context.Context, spaceID, email, role string) (*MemberInfo, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
if !ValidRole(role) {
|
||
return nil, errors.New("非法角色")
|
||
}
|
||
u, err := p.GetUserByEmail(ctx, strings.ToLower(strings.TrimSpace(email)))
|
||
if errors.Is(err, gorm.ErrRecordNotFound) || u == nil {
|
||
return nil, errors.New("该邮箱尚未注册,请对方先注册后再加入")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s, err := p.GetSpace(ctx, spaceID)
|
||
if err != nil || s == nil {
|
||
return nil, errors.New("空间不存在")
|
||
}
|
||
if p.MemberRole(ctx, s.TenantID, u.ID) == "" {
|
||
return nil, errors.New("对方不是本租户成员,请先加入租户")
|
||
}
|
||
if err := p.addSpaceMember(ctx, spaceID, u.ID, role); err != nil {
|
||
return nil, err
|
||
}
|
||
return &MemberInfo{UserID: u.ID, Email: u.Email, Name: u.Name, Role: role, Status: "active"}, nil
|
||
}
|
||
|
||
// SetSpaceMemberRole 改空间成员角色(不能改/授 owner)。
|
||
func (p *Postgres) SetSpaceMemberRole(ctx context.Context, spaceID, userID, role string) error {
|
||
if p.db == nil {
|
||
return errStoreDisabled
|
||
}
|
||
if !ValidRole(role) {
|
||
return errors.New("非法角色")
|
||
}
|
||
if p.SpaceMemberRole(ctx, spaceID, userID) == RoleOwner {
|
||
return errors.New("不能改 owner 的角色")
|
||
}
|
||
if role == RoleOwner {
|
||
return errors.New("不能直接授予 owner")
|
||
}
|
||
return p.db.WithContext(ctx).Model(&SpaceMember{}).
|
||
Where("space_id = ? AND user_id = ?", spaceID, userID).Update("role", role).Error
|
||
}
|
||
|
||
// RemoveSpaceMember 软移除空间成员(不能移除 owner)。
|
||
func (p *Postgres) RemoveSpaceMember(ctx context.Context, spaceID, userID string) error {
|
||
if p.db == nil {
|
||
return errStoreDisabled
|
||
}
|
||
if p.SpaceMemberRole(ctx, spaceID, userID) == RoleOwner {
|
||
return errors.New("不能移除 owner")
|
||
}
|
||
return p.db.WithContext(ctx).Model(&SpaceMember{}).
|
||
Where("space_id = ? AND user_id = ?", spaceID, userID).Update("status", "removed").Error
|
||
}
|
||
|
||
// ---- 迁移回填(启动时幂等调用)----
|
||
|
||
// BackfillPersonalSpaces 给每个 active 租户成员补建个人空间(幂等)。返回补建数。
|
||
func (p *Postgres) BackfillPersonalSpaces(ctx context.Context) (int, error) {
|
||
if p.db == nil {
|
||
return 0, errStoreDisabled
|
||
}
|
||
var members []TenantMember
|
||
if err := p.db.WithContext(ctx).Where("status = ?", "active").Find(&members).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
n := 0
|
||
for _, m := range members {
|
||
if p.personalSpaceID(ctx, m.TenantID, m.UserID) != "" {
|
||
continue
|
||
}
|
||
if _, err := p.EnsurePersonalSpace(ctx, m.TenantID, m.UserID); err != nil {
|
||
return n, err
|
||
}
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// MigrateAgentSpaces 把存量 Agent 的 space_id 回填为其 (tenant_id, owner) 个人空间,
|
||
// 然后 DROP 旧唯一索引 idx_agent_on(owner,name) 并建新唯一索引 idx_agent_sn(space_id,name)。
|
||
// 顺序关键:先回填再建唯一索引,否则存量 space_id 空会撞车。全程幂等。
|
||
func (p *Postgres) MigrateAgentSpaces(ctx context.Context) error {
|
||
if p.db == nil {
|
||
return errStoreDisabled
|
||
}
|
||
// 回填 space_id:按 agent.tenant_id + agent.owner 命中个人空间。
|
||
sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = a.tenant_id AND s.creator = a.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1"
|
||
if err := p.db.WithContext(ctx).Exec(
|
||
"UPDATE sundynix_agent a SET space_id = (" + sub + ") WHERE (a.space_id IS NULL OR a.space_id = '') AND a.owner <> ''",
|
||
).Error; err != nil {
|
||
return err
|
||
}
|
||
// 旧唯一索引 DROP(AutoMigrate 不删;留着会过约束"同人跨空间不能重名")。
|
||
if err := p.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS idx_agent_on").Error; err != nil {
|
||
return err
|
||
}
|
||
// 新唯一索引(回填后建,安全)。
|
||
if err := p.db.WithContext(ctx).Exec(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sn ON sundynix_agent(space_id, name)",
|
||
).Error; err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DocForRestorage 是存储层重灌迁移用的一条文档视图(拿回原文所需的键)。
|
||
type DocForRestorage struct {
|
||
ID string
|
||
SpaceID string
|
||
Owner string
|
||
KB string
|
||
Name string
|
||
Content string
|
||
ObjectKey string
|
||
}
|
||
|
||
// DocsForRestorage 列出全部有 space_id 的存量文档(系统级跨租户;供存储层 owner/kb→space/kb 重灌)。
|
||
func (p *Postgres) DocsForRestorage(ctx context.Context) ([]DocForRestorage, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
var rows []DocForRestorage
|
||
err := p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_doc").
|
||
Select("id, space_id, owner, kb, name, content, object_key").
|
||
Where("space_id <> '' AND owner <> ''").Order("created_at asc").Scan(&rows).Error
|
||
return rows, err
|
||
}
|
||
|
||
// MigrateKBSpaces 把存量 KB/Doc/DocLink 的 space_id 回填为其 (tenant_id, owner) 个人空间,
|
||
// 再 DROP 旧唯一索引、建新唯一索引(同 MigrateAgentSpaces 的顺序坑规避)。全程幂等。
|
||
// 注:仅迁移 PG 元数据作用域;Milvus/Bleve/Neo4j 的存量向量/全文/图谱另由 kb 存储重灌迁移。
|
||
func (p *Postgres) MigrateKBSpaces(ctx context.Context) error {
|
||
if p.db == nil {
|
||
return errStoreDisabled
|
||
}
|
||
sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = t.tenant_id AND s.creator = t.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1"
|
||
for _, tbl := range []string{"sundynix_kb", "sundynix_doc", "sundynix_doc_link"} {
|
||
if err := p.db.WithContext(ctx).Exec(
|
||
"UPDATE " + tbl + " t SET space_id = (" + sub + ") WHERE (t.space_id IS NULL OR t.space_id = '') AND t.owner <> ''",
|
||
).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// 旧唯一索引 DROP + 新唯一索引建(回填后,安全)。DocLink 无唯一约束,不涉及。
|
||
stmts := []string{
|
||
"DROP INDEX IF EXISTS idx_kb_owner_name",
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_sn ON sundynix_kb(space_id, name)",
|
||
"DROP INDEX IF EXISTS idx_doc_okn",
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_skn ON sundynix_doc(space_id, kb, name)",
|
||
}
|
||
for _, s := range stmts {
|
||
if err := p.db.WithContext(ctx).Exec(s).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|