Scope knowledge and image libraries by brand
Frontend CI / Frontend (push) Successful in 2m59s
Backend CI / Backend (push) Successful in 14m58s

This commit is contained in:
2026-06-30 12:30:10 +08:00
parent 95ddb3057f
commit ced0c4ec0f
35 changed files with 1136 additions and 326 deletions
@@ -353,6 +353,7 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
ctx,
job.TenantID,
int64(extractInt(job.InputParams, "brand_id")),
knowledgeGroupIDs,
buildImitationKnowledgeQuery(job.InputParams),
)
+11 -6
View File
@@ -492,6 +492,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, brandI
ctx,
s.pool,
tenantID,
brandID,
markdownValue,
item.CoverImageAssetID,
)
@@ -628,6 +629,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
ctx,
tx,
actor.TenantID,
brandID,
markdown,
coverImageAssetID,
)
@@ -1037,12 +1039,13 @@ func (s *ArticleService) resolveArticleImageMetadata(
ctx context.Context,
q imageAssetLookupQueryer,
tenantID int64,
brandID int64,
markdown string,
coverImageAssetID *int64,
) (string, []int64, *int64, error) {
referencedImageAssetIDs := extractReferencedImageAssetIDsFromMarkdown(markdown)
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
activeImageAssetIDs, err := s.loadActiveImageAssetIDSet(ctx, q, tenantID, imageAssetIDsToValidate)
activeImageAssetIDs, err := s.loadActiveImageAssetIDSet(ctx, q, tenantID, brandID, imageAssetIDsToValidate)
if err != nil {
return "", nil, nil, err
}
@@ -1064,6 +1067,7 @@ func (s *ArticleService) loadActiveImageAssetIDSet(
ctx context.Context,
q imageAssetLookupQueryer,
tenantID int64,
brandID int64,
imageAssetIDs []int64,
) (map[int64]struct{}, error) {
if len(imageAssetIDs) == 0 {
@@ -1073,11 +1077,12 @@ func (s *ArticleService) loadActiveImageAssetIDSet(
rows, err := q.Query(ctx, `
SELECT id
FROM image_assets
WHERE tenant_id = $1
AND id = ANY($2::BIGINT[])
AND status = 'active'
AND deleted_at IS NULL
`, tenantID, imageAssetIDs)
WHERE tenant_id = $1
AND brand_id = $2
AND id = ANY($3::BIGINT[])
AND status = 'active'
AND deleted_at IS NULL
`, tenantID, brandID, imageAssetIDs)
if err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
}
@@ -809,6 +809,7 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
contentHTML, coverURL := s.prepareEnterpriseSitePublishAssets(
ctx,
actor.TenantID,
brandID,
contentHTML,
coverURLInput,
coverImageAssetID,
@@ -1865,20 +1866,20 @@ func enterpriseSitePublishCoverInput(article enterpriseSitePublishArticle, req P
return coverURL, coverImageAssetID
}
func (s *EnterpriseSiteService) prepareEnterpriseSitePublishAssets(ctx context.Context, tenantID int64, contentHTML, coverURL string, coverImageAssetID *int64) (string, string) {
func (s *EnterpriseSiteService) prepareEnterpriseSitePublishAssets(ctx context.Context, tenantID int64, brandID int64, contentHTML, coverURL string, coverImageAssetID *int64) (string, string) {
base := s.enterpriseSitePublicBaseURL()
preparedHTML := s.prepareEnterpriseSiteImageURLs(ctx, tenantID, contentHTML, base)
preparedCover := s.prepareEnterpriseSiteCoverURL(ctx, tenantID, coverURL, coverImageAssetID, base)
preparedHTML := s.prepareEnterpriseSiteImageURLs(ctx, tenantID, brandID, contentHTML, base)
preparedCover := s.prepareEnterpriseSiteCoverURL(ctx, tenantID, brandID, coverURL, coverImageAssetID, base)
return preparedHTML, preparedCover
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteCoverURL(ctx context.Context, tenantID int64, coverURL string, coverImageAssetID *int64, baseURL string) string {
func (s *EnterpriseSiteService) prepareEnterpriseSiteCoverURL(ctx context.Context, tenantID int64, brandID int64, coverURL string, coverImageAssetID *int64, baseURL string) string {
if coverImageAssetID != nil && *coverImageAssetID > 0 {
if dataURI := s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, *coverImageAssetID); dataURI != "" {
if dataURI := s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, brandID, *coverImageAssetID); dataURI != "" {
return dataURI
}
}
return s.prepareEnterpriseSiteAssetURL(ctx, tenantID, coverURL, baseURL)
return s.prepareEnterpriseSiteAssetURL(ctx, tenantID, brandID, coverURL, baseURL)
}
func (s *EnterpriseSiteService) enterpriseSitePublicBaseURL() string {
@@ -1918,7 +1919,7 @@ func normalizeEnterpriseSiteAssetBaseURL(value string) string {
return strings.TrimRight(parsed.String(), "/")
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLs(ctx context.Context, tenantID int64, fragment string, baseURL string) string {
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLs(ctx context.Context, tenantID int64, brandID int64, fragment string, baseURL string) string {
if strings.TrimSpace(fragment) == "" || !strings.Contains(strings.ToLower(fragment), "<img") {
return fragment
}
@@ -1929,37 +1930,37 @@ func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLs(ctx context.Conte
}
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
if err != nil {
return s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, fragment, baseURL)
return s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, brandID, fragment, baseURL)
}
for _, node := range nodes {
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, node, baseURL)
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, brandID, node, baseURL)
}
nodes = enterpriseSitePruneEmptyImageWrapperNodes(nodes)
return enterpriseSiteRenderHTMLNodes(nodes, s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, fragment, baseURL))
return enterpriseSiteRenderHTMLNodes(nodes, s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, brandID, fragment, baseURL))
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageNodeURLs(ctx context.Context, tenantID int64, node *html.Node, baseURL string) {
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageNodeURLs(ctx context.Context, tenantID int64, brandID int64, node *html.Node, baseURL string) {
if node == nil {
return
}
if node.Type == html.ElementNode && strings.EqualFold(node.Data, "img") {
if !s.prepareEnterpriseSiteImageAttrs(ctx, tenantID, node, baseURL) {
if !s.prepareEnterpriseSiteImageAttrs(ctx, tenantID, brandID, node, baseURL) {
enterpriseSiteRemoveHTMLNode(node)
return
}
}
for child := node.FirstChild; child != nil; {
next := child.NextSibling
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, child, baseURL)
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, brandID, child, baseURL)
child = next
}
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageAttrs(ctx context.Context, tenantID int64, node *html.Node, baseURL string) bool {
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageAttrs(ctx context.Context, tenantID int64, brandID int64, node *html.Node, baseURL string) bool {
if node == nil {
return true
}
dataURI := s.enterpriseSiteDataURIFromImageNode(ctx, tenantID, node)
dataURI := s.enterpriseSiteDataURIFromImageNode(ctx, tenantID, brandID, node)
hasImageURL := false
for idx := range node.Attr {
key := strings.ToLower(strings.TrimSpace(node.Attr[idx].Key))
@@ -1971,7 +1972,7 @@ func (s *EnterpriseSiteService) prepareEnterpriseSiteImageAttrs(ctx context.Cont
node.Attr[idx].Val = dataURI
continue
}
node.Attr[idx].Val = s.prepareEnterpriseSiteAssetURL(ctx, tenantID, node.Attr[idx].Val, baseURL)
node.Attr[idx].Val = s.prepareEnterpriseSiteAssetURL(ctx, tenantID, brandID, node.Attr[idx].Val, baseURL)
if strings.TrimSpace(node.Attr[idx].Val) == "" {
return false
}
@@ -2046,12 +2047,12 @@ func enterpriseSitePruneEmptyImageWrapperNodes(nodes []*html.Node) []*html.Node
return pruned
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteAssetURL(ctx context.Context, tenantID int64, value string, baseURL string) string {
func (s *EnterpriseSiteService) prepareEnterpriseSiteAssetURL(ctx context.Context, tenantID int64, brandID int64, value string, baseURL string) string {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(strings.ToLower(value), "data:") {
return value
}
if dataURI := s.enterpriseSiteDataURIFromAssetURL(ctx, tenantID, value); dataURI != "" {
if dataURI := s.enterpriseSiteDataURIFromAssetURL(ctx, tenantID, brandID, value); dataURI != "" {
return dataURI
}
if enterpriseSiteLooksLikeSaaSAssetURL(value) {
@@ -2060,34 +2061,34 @@ func (s *EnterpriseSiteService) prepareEnterpriseSiteAssetURL(ctx context.Contex
return enterpriseSiteAbsoluteAssetURL(value, baseURL)
}
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLsFallback(ctx context.Context, tenantID int64, fragment string, baseURL string) string {
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLsFallback(ctx context.Context, tenantID int64, brandID int64, fragment string, baseURL string) string {
return enterpriseSiteImageAttrPattern.ReplaceAllStringFunc(fragment, func(match string) string {
parts := enterpriseSiteImageAttrPattern.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
return parts[1] + s.prepareEnterpriseSiteAssetURL(ctx, tenantID, parts[2], baseURL) + parts[3]
return parts[1] + s.prepareEnterpriseSiteAssetURL(ctx, tenantID, brandID, parts[2], baseURL) + parts[3]
})
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromAssetURL(ctx context.Context, tenantID int64, value string) string {
objectKey, ok := s.enterpriseSiteObjectKeyFromAssetURL(ctx, tenantID, value)
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromAssetURL(ctx context.Context, tenantID int64, brandID int64, value string) string {
objectKey, ok := s.enterpriseSiteObjectKeyFromAssetURL(ctx, tenantID, brandID, value)
if !ok || s == nil || s.storage == nil {
return ""
}
return s.enterpriseSiteDataURIFromObjectKey(ctx, objectKey)
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageNode(ctx context.Context, tenantID int64, node *html.Node) string {
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageNode(ctx context.Context, tenantID int64, brandID int64, node *html.Node) string {
assetID := enterpriseSiteImageNodeAssetID(node)
if assetID <= 0 {
return ""
}
return s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, assetID)
return s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, brandID, assetID)
}
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageAssetID(ctx context.Context, tenantID, assetID int64) string {
objectKey, ok := s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, assetID)
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageAssetID(ctx context.Context, tenantID, brandID, assetID int64) string {
objectKey, ok := s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, brandID, assetID)
if !ok || s == nil || s.storage == nil {
return ""
}
@@ -2143,27 +2144,54 @@ func enterpriseSiteImageNodeAssetID(node *html.Node) int64 {
return 0
}
func (s *EnterpriseSiteService) enterpriseSiteObjectKeyFromAssetURL(ctx context.Context, tenantID int64, value string) (string, bool) {
func (s *EnterpriseSiteService) enterpriseSiteObjectKeyFromAssetURL(ctx context.Context, tenantID int64, brandID int64, value string) (string, bool) {
if objectKey, ok := enterpriseSiteObjectKeyFromPublicAssetURL(value, tenantID, s.enterpriseSiteAssetTokenSecret()); ok {
if !s.enterpriseSiteImageObjectKeyBelongsToBrand(ctx, tenantID, brandID, objectKey) {
return "", false
}
return objectKey, true
}
assetID, ok := enterpriseSiteAssetIDFromURL(value)
if !ok {
return "", false
}
return s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, assetID)
return s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, brandID, assetID)
}
func (s *EnterpriseSiteService) loadEnterpriseSiteImageAssetObjectKey(ctx context.Context, tenantID, assetID int64) (string, bool) {
if s == nil || s.pool == nil || tenantID <= 0 || assetID <= 0 {
func (s *EnterpriseSiteService) enterpriseSiteImageObjectKeyBelongsToBrand(ctx context.Context, tenantID, brandID int64, objectKey string) bool {
if s == nil || s.pool == nil || !enterpriseSiteObjectKeyIsImageLibraryAsset(objectKey, tenantID) {
return true
}
if tenantID <= 0 || brandID <= 0 || strings.TrimSpace(objectKey) == "" {
return false
}
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM image_assets
WHERE tenant_id = $1
AND brand_id = $2
AND object_key = $3
AND status = 'active'
AND deleted_at IS NULL
)
`, tenantID, brandID, objectKey).Scan(&exists); err != nil {
return false
}
return exists
}
func (s *EnterpriseSiteService) loadEnterpriseSiteImageAssetObjectKey(ctx context.Context, tenantID, brandID, assetID int64) (string, bool) {
if s == nil || s.pool == nil || tenantID <= 0 || brandID <= 0 || assetID <= 0 {
return "", false
}
var objectKey string
if err := s.pool.QueryRow(ctx, `
SELECT object_key
FROM image_assets
WHERE id = $1 AND tenant_id = $2 AND status = 'active' AND deleted_at IS NULL
`, assetID, tenantID).Scan(&objectKey); err != nil {
SELECT object_key
FROM image_assets
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND status = 'active' AND deleted_at IS NULL
`, assetID, tenantID, brandID).Scan(&objectKey); err != nil {
return "", false
}
if !enterpriseSiteObjectKeyAllowedForTenant(objectKey, tenantID) {
@@ -2264,6 +2292,14 @@ func enterpriseSiteObjectKeyAllowedForTenant(objectKey string, tenantID int64) b
strings.HasPrefix(objectKey, tenantPrefix+"articles/")
}
func enterpriseSiteObjectKeyIsImageLibraryAsset(objectKey string, tenantID int64) bool {
if tenantID <= 0 {
return false
}
tenantPrefix := "tenants/" + strconv.FormatInt(tenantID, 10) + "/"
return strings.HasPrefix(strings.TrimSpace(objectKey), tenantPrefix+"images/")
}
func enterpriseSiteAbsoluteImageURLs(fragment string, baseURL string) string {
if strings.TrimSpace(fragment) == "" || !strings.Contains(strings.ToLower(fragment), "<img") {
return fragment
@@ -245,6 +245,7 @@ func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImages(t *testing.T) {
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
11,
`<p><img src="/api/public/assets/`+token+`" data-src="/api/public/assets/`+token+`" /></p>`,
"http://127.0.0.1:8080",
)
@@ -271,6 +272,7 @@ func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImagesWithStaleSignature
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
11,
`<p><img src="http://localhost:5178/api/public/assets/`+token+`" /></p>`,
"http://127.0.0.1:8080",
)
@@ -297,6 +299,7 @@ func TestPrepareEnterpriseSiteImageURLsStripsSaaSImageAttrs(t *testing.T) {
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
11,
`<p><img src="/api/public/assets/`+token+`" alt="" data-asset-id="35" asset-id="35" /></p>`,
"http://127.0.0.1:8080",
)
@@ -320,6 +323,7 @@ func TestPrepareEnterpriseSiteImageURLsDropsUnavailableSaaSAssetImage(t *testing
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
11,
`<p>前文</p><p class="article-editor-image article-editor-image--center" align="center"><img src="http://localhost:5178/api/public/assets/`+token+`" alt="" /></p><p>后文</p>`,
"http://127.0.0.1:8080",
)
@@ -346,6 +350,7 @@ func TestPrepareEnterpriseSiteImageURLsEmbedsIncompletePublicAssetPaths(t *testi
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
11,
`<p><img src="api/public/assets/`+token+`?format=png" /></p>`,
"http://127.0.0.1:8080",
)
@@ -373,7 +378,7 @@ func TestMarkdownRenderedEnterpriseSiteImageEmbedsIncompletePublicAssetPath(t *t
t.Fatalf("render markdown: %v", err)
}
got := svc.prepareEnterpriseSiteImageURLs(context.Background(), 7, html, "http://127.0.0.1:8080")
got := svc.prepareEnterpriseSiteImageURLs(context.Background(), 7, 11, html, "http://127.0.0.1:8080")
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
@@ -398,6 +403,7 @@ func TestPrepareEnterpriseSitePublishAssetsEmbedsIncompleteCoverAssetPath(t *tes
got := svc.prepareEnterpriseSiteCoverURL(
context.Background(),
7,
11,
"api/public/assets/"+token+"?format=png",
nil,
"http://127.0.0.1:8080",
@@ -423,6 +429,7 @@ func TestPrepareEnterpriseSiteCoverURLDropsUnavailableSaaSAssetURL(t *testing.T)
got := svc.prepareEnterpriseSiteCoverURL(
context.Background(),
7,
11,
"http://localhost:5178/api/public/assets/"+token,
nil,
"http://127.0.0.1:8080",
@@ -376,6 +376,7 @@ func (s *PromptRuleGenerationService) ProcessQueuedTask(ctx context.Context, tas
knowledgeContext, err := s.knowledge.ResolveContext(
ctx,
task.TenantID,
int64(extractInt(inputParams, "brand_id")),
knowledgeGroupIDs,
buildPromptRuleKnowledgeQuery(rule, inputParams),
)
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
@@ -48,9 +49,13 @@ type ImageReferenceArticleBrief struct {
func (s *ImageFolderService) List(ctx context.Context) ([]ImageFolderItem, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
repo := repository.NewImageFolderRepository(s.pool)
folders, err := repo.List(ctx, tenantID)
folders, err := repo.List(ctx, tenantID, brandID)
if err != nil {
return nil, err
}
@@ -77,11 +82,15 @@ func (s *ImageFolderService) Create(ctx context.Context, name string) (int64, er
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return 0, err
}
repo := repository.NewImageFolderRepository(s.pool)
id, err := repo.Create(ctx, tenantID, name)
id, err := repo.Create(ctx, tenantID, brandID, name)
if err != nil {
if isConstraintError(err, "uq_image_folders_tenant_name") {
if isConstraintError(err, "uq_image_folders_tenant_name") || isConstraintError(err, "uq_image_folders_tenant_brand_name") {
return 0, response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists")
}
return 0, err
@@ -96,13 +105,17 @@ func (s *ImageFolderService) Update(ctx context.Context, id int64, name string)
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
repo := repository.NewImageFolderRepository(s.pool)
if _, err := repo.Get(ctx, tenantID, id); err != nil {
if _, err := repo.Get(ctx, tenantID, brandID, id); err != nil {
return mapImageFolderNotFound(err)
}
if err := repo.Update(ctx, tenantID, id, name); err != nil {
if isConstraintError(err, "uq_image_folders_tenant_name") {
if err := repo.Update(ctx, tenantID, brandID, id, name); err != nil {
if isConstraintError(err, "uq_image_folders_tenant_name") || isConstraintError(err, "uq_image_folders_tenant_brand_name") {
return response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists")
}
return err
@@ -112,13 +125,17 @@ func (s *ImageFolderService) Update(ctx context.Context, id int64, name string)
func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64) (*FolderDeletePreview, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
folderRepo := repository.NewImageFolderRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil {
if _, err := folderRepo.Get(ctx, tenantID, brandID, folderID); err != nil {
return nil, mapImageFolderNotFound(err)
}
imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID)
imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, brandID, folderID)
if err != nil {
return nil, err
}
@@ -131,19 +148,23 @@ func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64)
func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force bool) error {
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
folderRepo := repository.NewImageFolderRepository(s.pool)
imageRepo := repository.NewImageRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil {
if _, err := folderRepo.Get(ctx, tenantID, brandID, folderID); err != nil {
return mapImageFolderNotFound(err)
}
imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID)
imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, brandID, folderID)
if err != nil {
return err
}
if imageCount == 0 {
return folderRepo.Delete(ctx, tenantID, folderID)
return folderRepo.Delete(ctx, tenantID, brandID, folderID)
}
if !force {
@@ -159,7 +180,7 @@ func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force b
}
}
images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID)
images, err := imageRepo.ListActiveByFolder(ctx, tenantID, brandID, folderID)
if err != nil {
return err
}
@@ -174,10 +195,10 @@ func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force b
imageRepoTx := repository.NewImageRepository(tx)
folderRepoTx := repository.NewImageFolderRepository(tx)
if err := imageRepoTx.MarkImagesInFolderPendingDelete(ctx, tenantID, folderID); err != nil {
if err := imageRepoTx.MarkImagesInFolderPendingDelete(ctx, tenantID, brandID, folderID); err != nil {
return response.ErrInternal(50048, "folder_image_delete_prepare_failed", "failed to prepare folder images for deletion")
}
if err := folderRepoTx.Delete(ctx, tenantID, folderID); err != nil {
if err := folderRepoTx.Delete(ctx, tenantID, brandID, folderID); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
@@ -185,7 +206,8 @@ func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force b
}
for _, image := range images {
if err := s.imageSvc.finalizeDeleteNowOrEnqueue(ctx, tenantID, image.ID); err != nil {
deleteCtx := auth.WithCurrentBrandID(ctx, brandID)
if err := s.imageSvc.finalizeDeleteNowOrEnqueue(deleteCtx, tenantID, image.ID); err != nil {
return err
}
}
+74 -28
View File
@@ -158,9 +158,13 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
actor := auth.MustActor(ctx)
if folderID != nil {
if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil {
if err := s.ensureFolderExists(ctx, tenantID, brandID, *folderID); err != nil {
return nil, err
}
}
@@ -181,7 +185,7 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
sizeBytes := int64(len(webpBytes))
contentHash := hashImageContent(webpBytes)
existingAsset, err := s.findActiveImageAssetByContentHash(ctx, tenantID, contentHash)
existingAsset, err := s.findActiveImageAssetByContentHash(ctx, tenantID, brandID, contentHash)
if err != nil {
return nil, response.ErrInternal(50012, "image_asset_lookup_failed", "failed to check existing image asset")
}
@@ -219,9 +223,9 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
return nil, response.ErrInternal(50015, "image_quota_reserve_failed", "failed to reserve image storage quota")
}
assetID, err := s.insertPendingImageAsset(ctx, tx, tenantID, folderID, objectKey, displayName, sizeBytes, contentHash, actor.UserID)
assetID, err := s.insertPendingImageAsset(ctx, tx, tenantID, brandID, folderID, objectKey, displayName, sizeBytes, contentHash, actor.UserID)
if err != nil {
if isConstraintError(err, "fk_image_assets_folder") {
if isConstraintError(err, "fk_image_assets_folder") || isConstraintError(err, "fk_image_assets_folder_brand") {
return nil, response.ErrNotFound(40402, "image_folder_not_found", "folder not found")
}
return nil, response.ErrInternal(50016, "image_asset_create_failed", "failed to create image asset")
@@ -241,7 +245,7 @@ func (s *ImageService) Upload(ctx context.Context, fileName string, content []by
}
imageRepo := repository.NewImageRepository(s.pool)
if err := imageRepo.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil {
if err := imageRepo.UpdateStatus(ctx, tenantID, brandID, assetID, "active"); err != nil {
s.logError("failed to mark image active after upload", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err))
if queueErr := s.enqueueFinalizeUploadTask(tenantID, assetID); queueErr == nil {
return &ImageUploadResult{
@@ -282,9 +286,14 @@ func (s *ImageService) List(ctx context.Context, folderID *int64, query *string,
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
repo := repository.NewImageRepository(s.pool)
assets, total, err := repo.List(ctx, repository.ImageAssetListParams{
TenantID: tenantID,
BrandID: brandID,
FolderID: folderID,
SearchQuery: searchQuery,
PageSize: pageSize,
@@ -323,9 +332,13 @@ func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequ
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
repo := repository.NewImageRepository(s.pool)
asset, err := repo.Get(ctx, tenantID, id)
asset, err := repo.Get(ctx, tenantID, brandID, id)
if err != nil {
return mapImageNotFound(err)
}
@@ -341,7 +354,7 @@ func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequ
if len(name) > 255 {
return response.ErrBadRequest(40002, "invalid_name", "name must be at most 255 characters")
}
if err := repo.UpdateName(ctx, tenantID, id, name); err != nil {
if err := repo.UpdateName(ctx, tenantID, brandID, id, name); err != nil {
return err
}
}
@@ -352,12 +365,12 @@ func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequ
return err
}
if folderID != nil {
if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil {
if err := s.ensureFolderExists(ctx, tenantID, brandID, *folderID); err != nil {
return err
}
}
if err := repo.UpdateFolder(ctx, tenantID, id, folderID); err != nil {
if isConstraintError(err, "fk_image_assets_folder") {
if err := repo.UpdateFolder(ctx, tenantID, brandID, id, folderID); err != nil {
if isConstraintError(err, "fk_image_assets_folder") || isConstraintError(err, "fk_image_assets_folder_brand") {
return response.ErrNotFound(40402, "image_folder_not_found", "folder not found")
}
return err
@@ -374,7 +387,7 @@ type existingImageAsset struct {
SizeBytes int64
}
func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, tenantID int64, contentHash string) (*existingImageAsset, error) {
func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, tenantID, brandID int64, contentHash string) (*existingImageAsset, error) {
if strings.TrimSpace(contentHash) == "" {
return nil, nil
}
@@ -384,12 +397,13 @@ func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, te
SELECT id, object_key, name, size_bytes
FROM image_assets
WHERE tenant_id = $1
AND content_hash = $2
AND brand_id = $2
AND content_hash = $3
AND status = 'active'
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 1
`, tenantID, contentHash).Scan(&asset.ID, &asset.ObjectKey, &asset.Name, &asset.SizeBytes)
`, tenantID, brandID, contentHash).Scan(&asset.ID, &asset.ObjectKey, &asset.Name, &asset.SizeBytes)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -404,6 +418,7 @@ func (s *ImageService) insertPendingImageAsset(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
brandID int64,
folderID *int64,
objectKey string,
name string,
@@ -412,10 +427,10 @@ func (s *ImageService) insertPendingImageAsset(
createdBy int64,
) (int64, error) {
row := tx.QueryRow(ctx, `
INSERT INTO image_assets (tenant_id, folder_id, object_key, name, size_bytes, status, content_hash, created_by)
VALUES ($1, $2, $3, $4, $5, 'pending_upload', $6, $7)
INSERT INTO image_assets (tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, content_hash, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'pending_upload', $7, $8)
RETURNING id
`, tenantID, nullableImageFolderValue(folderID), objectKey, name, sizeBytes, contentHash, createdBy)
`, tenantID, brandID, nullableImageFolderValue(folderID), objectKey, name, sizeBytes, contentHash, createdBy)
var assetID int64
if err := row.Scan(&assetID); err != nil {
@@ -426,10 +441,14 @@ RETURNING id
func (s *ImageService) GetReferences(ctx context.Context, imageID int64) (*ImageReferenceDetail, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
asset, err := imageRepo.Get(ctx, tenantID, imageID)
asset, err := imageRepo.Get(ctx, tenantID, brandID, imageID)
if err != nil {
return nil, mapImageNotFound(err)
}
@@ -494,10 +513,14 @@ func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error {
}
tenantID := middleware.TenantIDFromCtx(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
asset, err := imageRepo.Get(ctx, tenantID, id)
asset, err := imageRepo.Get(ctx, tenantID, brandID, id)
if err != nil {
return mapImageNotFound(err)
}
@@ -515,7 +538,7 @@ func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error {
}
}
if err := imageRepo.UpdateStatus(ctx, tenantID, id, "pending_delete"); err != nil {
if err := imageRepo.UpdateStatus(ctx, tenantID, brandID, id, "pending_delete"); err != nil {
return err
}
@@ -556,7 +579,13 @@ func (s *ImageService) rollbackUpload(ctx context.Context, tenantID, assetID, si
imageRepo := repository.NewImageRepository(s.pool)
usageRepo := repository.NewImageUsageRepository(s.pool)
if err := imageRepo.UpdateStatus(ctx, tenantID, assetID, "upload_failed"); err != nil {
brandID, err := s.lookupImageAssetBrandID(ctx, tenantID, assetID)
if err != nil {
s.logError("failed to resolve image brand after upload rollback", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err))
return
}
if err := imageRepo.UpdateStatus(ctx, tenantID, brandID, assetID, "upload_failed"); err != nil {
s.logError("failed to mark image upload as failed", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err))
}
if err := usageRepo.Release(ctx, tenantID, sizeBytes); err != nil {
@@ -605,11 +634,11 @@ func (s *ImageService) ProcessFinalizeUpload(ctx context.Context, tenantID, asse
imageRepoTx := repository.NewImageRepository(tx)
if exists {
if err := imageRepoTx.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil {
if err := imageRepoTx.UpdateStatus(ctx, tenantID, lockedAsset.BrandID, assetID, "active"); err != nil {
return response.ErrInternal(50033, "image_upload_finalize_failed", "failed to activate uploaded image")
}
} else {
if err := imageRepoTx.UpdateStatus(ctx, tenantID, assetID, "upload_failed"); err != nil {
if err := imageRepoTx.UpdateStatus(ctx, tenantID, lockedAsset.BrandID, assetID, "upload_failed"); err != nil {
return response.ErrInternal(50034, "image_upload_finalize_failed", "failed to mark image upload as failed")
}
usageRepoTx := repository.NewImageUsageRepository(tx)
@@ -680,7 +709,7 @@ WHERE tenant_id = $1 AND image_asset_id = $2
imageRepoTx := repository.NewImageRepository(tx)
usageRepoTx := repository.NewImageUsageRepository(tx)
if err := imageRepoTx.MarkDeleted(ctx, tenantID, assetID); err != nil {
if err := imageRepoTx.MarkDeleted(ctx, tenantID, lockedAsset.BrandID, assetID); err != nil {
return response.ErrInternal(50044, "image_delete_finalize_failed", "failed to finalize image deletion")
}
if err := usageRepoTx.Release(ctx, tenantID, lockedAsset.SizeBytes); err != nil {
@@ -725,10 +754,15 @@ func (s *ImageService) enqueueFinalizeDeleteTask(tenantID, assetID int64) error
}
func (s *ImageService) loadImageAsset(ctx context.Context, tenantID, assetID int64) (*repository.ImageAsset, error) {
return repository.NewImageRepository(s.pool).Get(ctx, tenantID, assetID)
brandID, err := s.lookupImageAssetBrandID(ctx, tenantID, assetID)
if err != nil {
return nil, err
}
return repository.NewImageRepository(s.pool).Get(ctx, tenantID, brandID, assetID)
}
type lockedImageAsset struct {
BrandID int64
ObjectKey string
SizeBytes int64
Status string
@@ -739,11 +773,11 @@ func (s *ImageService) lockImageAsset(ctx context.Context, tx pgx.Tx, tenantID,
var asset lockedImageAsset
var deletedAt *time.Time
err := tx.QueryRow(ctx, `
SELECT object_key, size_bytes, status, deleted_at
SELECT brand_id, object_key, size_bytes, status, deleted_at
FROM image_assets
WHERE tenant_id = $1 AND id = $2
FOR UPDATE
`, tenantID, assetID).Scan(&asset.ObjectKey, &asset.SizeBytes, &asset.Status, &deletedAt)
`, tenantID, assetID).Scan(&asset.BrandID, &asset.ObjectKey, &asset.SizeBytes, &asset.Status, &deletedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
@@ -754,14 +788,26 @@ FOR UPDATE
return &asset, true, nil
}
func (s *ImageService) ensureFolderExists(ctx context.Context, tenantID, folderID int64) error {
func (s *ImageService) ensureFolderExists(ctx context.Context, tenantID, brandID, folderID int64) error {
folderRepo := repository.NewImageFolderRepository(s.pool)
if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil {
if _, err := folderRepo.Get(ctx, tenantID, brandID, folderID); err != nil {
return mapImageFolderNotFound(err)
}
return nil
}
func (s *ImageService) lookupImageAssetBrandID(ctx context.Context, tenantID, assetID int64) (int64, error) {
var brandID int64
if err := s.pool.QueryRow(ctx, `
SELECT brand_id
FROM image_assets
WHERE tenant_id = $1 AND id = $2
`, tenantID, assetID).Scan(&brandID); err != nil {
return 0, err
}
return brandID, nil
}
func (s *ImageService) lookupImageQuotaBytes(ctx context.Context, tenantID int64) (int64, error) {
var quotaBytes int64
err := s.pool.QueryRow(ctx, `
+178 -102
View File
@@ -61,6 +61,7 @@ type knowledgeParseJob struct {
type knowledgeItemRecord struct {
ID int64
TenantID int64
BrandID int64
GroupID int64
GroupName string
Name string
@@ -207,12 +208,16 @@ func (s *KnowledgeService) runtimeConfig() knowledgeRuntimeConfig {
func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, parent_id, sort_order, created_at
FROM knowledge_groups
WHERE tenant_id = $1 AND deleted_at IS NULL
WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL
ORDER BY sort_order, created_at, id
`, actor.TenantID)
`, actor.TenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50050, "knowledge_groups_query_failed", "failed to list knowledge groups")
}
@@ -240,6 +245,7 @@ func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResp
SELECT ki.group_id, COUNT(DISTINCT ki.id)::bigint
FROM knowledge_items ki
WHERE ki.tenant_id = $1
AND ki.brand_id = $2
AND ki.deleted_at IS NULL
AND ki.status = 'completed'
AND EXISTS (
@@ -251,7 +257,7 @@ func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResp
AND kcm.status = 'active'
)
GROUP BY ki.group_id
`, actor.TenantID)
`, actor.TenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50050, "knowledge_group_counts_query_failed", "failed to list knowledge group counts")
}
@@ -310,6 +316,10 @@ func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResp
func (s *KnowledgeService) CreateGroup(ctx context.Context, req KnowledgeGroupRequest) (*KnowledgeGroupResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(req.Name)
if name == "" {
@@ -318,7 +328,7 @@ func (s *KnowledgeService) CreateGroup(ctx context.Context, req KnowledgeGroupRe
var parentID *int64
if req.ParentID != nil && *req.ParentID > 0 {
if err := s.ensureRootGroup(ctx, actor.TenantID, *req.ParentID); err != nil {
if err := s.ensureRootGroup(ctx, actor.TenantID, brandID, *req.ParentID); err != nil {
return nil, err
}
parentID = req.ParentID
@@ -333,11 +343,11 @@ func (s *KnowledgeService) CreateGroup(ctx context.Context, req KnowledgeGroupRe
groupID int64
createdAt time.Time
)
err := s.pool.QueryRow(ctx, `
INSERT INTO knowledge_groups (tenant_id, name, parent_id, sort_order)
VALUES ($1, $2, $3, $4)
err = s.pool.QueryRow(ctx, `
INSERT INTO knowledge_groups (tenant_id, brand_id, name, parent_id, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, actor.TenantID, name, parentID, sortOrder).Scan(&groupID, &createdAt)
`, actor.TenantID, brandID, name, parentID, sortOrder).Scan(&groupID, &createdAt)
if err != nil {
return nil, response.ErrConflict(40950, "knowledge_group_create_failed", "knowledge group already exists or cannot be created")
}
@@ -355,6 +365,10 @@ func (s *KnowledgeService) CreateGroup(ctx context.Context, req KnowledgeGroupRe
func (s *KnowledgeService) UpdateGroup(ctx context.Context, groupID int64, req KnowledgeGroupRequest) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
name := strings.TrimSpace(req.Name)
if name == "" {
@@ -369,8 +383,8 @@ func (s *KnowledgeService) UpdateGroup(ctx context.Context, groupID int64, req K
tag, err := s.pool.Exec(ctx, `
UPDATE knowledge_groups
SET name = $1, sort_order = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL
`, name, sortOrder, groupID, actor.TenantID)
WHERE id = $3 AND tenant_id = $4 AND brand_id = $5 AND deleted_at IS NULL
`, name, sortOrder, groupID, actor.TenantID, brandID)
if err != nil {
return response.ErrConflict(40950, "knowledge_group_update_failed", "knowledge group already exists or cannot be updated")
}
@@ -382,6 +396,10 @@ func (s *KnowledgeService) UpdateGroup(ctx context.Context, groupID int64, req K
func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -389,7 +407,7 @@ func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error
}
defer func() { _ = tx.Rollback(ctx) }()
groupIDs, err := s.listGroupSubtreeIDs(ctx, tx, actor.TenantID, groupID)
groupIDs, err := s.listGroupSubtreeIDs(ctx, tx, actor.TenantID, brandID, groupID)
if err != nil {
return err
}
@@ -397,7 +415,7 @@ func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error
return response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
}
itemIDs, err := s.listActiveKnowledgeItemIDs(ctx, tx, actor.TenantID, groupIDs)
itemIDs, err := s.listActiveKnowledgeItemIDs(ctx, tx, actor.TenantID, brandID, groupIDs)
if err != nil {
return err
}
@@ -407,9 +425,10 @@ func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error
UPDATE knowledge_items
SET deleted_at = NOW(), updated_at = NOW(), status = 'deleted'
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
AND id = ANY($2::bigint[])
`, actor.TenantID, itemIDs); err != nil {
AND id = ANY($3::bigint[])
`, actor.TenantID, brandID, itemIDs); err != nil {
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to delete knowledge items")
}
@@ -448,9 +467,10 @@ func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error
UPDATE knowledge_groups
SET deleted_at = NOW(), updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
AND id = ANY($2::bigint[])
`, actor.TenantID, groupIDs); err != nil {
AND id = ANY($3::bigint[])
`, actor.TenantID, brandID, groupIDs); err != nil {
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to delete knowledge groups")
}
@@ -475,8 +495,12 @@ func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error
func (s *KnowledgeService) ListItems(ctx context.Context, groupID *int64) ([]KnowledgeItemResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if groupID != nil && *groupID > 0 {
if err := s.ensureGroupAccessible(ctx, actor.TenantID, *groupID); err != nil {
if err := s.ensureGroupAccessible(ctx, actor.TenantID, brandID, *groupID); err != nil {
return nil, err
}
}
@@ -485,23 +509,24 @@ func (s *KnowledgeService) ListItems(ctx context.Context, groupID *int64) ([]Kno
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.status,
ki.size_bytes, ki.error_message, ki.created_at, ki.updated_at
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id AND kg.deleted_at IS NULL
WHERE ki.tenant_id = $1
AND ki.brand_id = $2
AND ki.deleted_at IS NULL
`
args := []any{actor.TenantID}
args := []any{actor.TenantID, brandID}
if groupID != nil && *groupID > 0 {
query += `
AND ki.group_id IN (
WITH RECURSIVE selected_groups AS (
SELECT id
FROM knowledge_groups
WHERE id = $2 AND tenant_id = $1 AND deleted_at IS NULL
WHERE id = $3 AND tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL
UNION ALL
SELECT child.id
FROM knowledge_groups child
JOIN selected_groups parent ON child.parent_id = parent.id
WHERE child.tenant_id = $1 AND child.deleted_at IS NULL
WHERE child.tenant_id = $1 AND child.brand_id = $2 AND child.deleted_at IS NULL
)
SELECT id FROM selected_groups
)
@@ -551,6 +576,10 @@ func (s *KnowledgeService) ListItems(ctx context.Context, groupID *int64) ([]Kno
func (s *KnowledgeService) GetItemDetail(ctx context.Context, itemID int64) (*KnowledgeItemDetailResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
var (
item KnowledgeItemDetailResponse
@@ -560,13 +589,13 @@ func (s *KnowledgeService) GetItemDetail(ctx context.Context, itemID int64) (*Kn
createdAt, updatedAt time.Time
)
err := s.pool.QueryRow(ctx, `
err = s.pool.QueryRow(ctx, `
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.status,
ki.size_bytes, ki.error_message, ki.markdown_content, ki.created_at, ki.updated_at
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NULL
`, itemID, actor.TenantID).Scan(
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id AND kg.deleted_at IS NULL
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.brand_id = $3 AND ki.deleted_at IS NULL
`, itemID, actor.TenantID, brandID).Scan(
&item.ID,
&item.GroupID,
&item.GroupName,
@@ -597,6 +626,10 @@ func (s *KnowledgeService) GetItemDetail(ctx context.Context, itemID int64) (*Kn
func (s *KnowledgeService) CreateTextItem(ctx context.Context, req KnowledgeTextItemRequest) (*KnowledgeItemResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if err := s.validateIngestionDependencies("text"); err != nil {
return nil, err
@@ -612,7 +645,7 @@ func (s *KnowledgeService) CreateTextItem(ctx context.Context, req KnowledgeText
return nil, response.ErrBadRequest(40055, "knowledge_text_name_too_long", "knowledge text name must be at most 20 characters")
}
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
return s.queueKnowledgeItem(ctx, actor.TenantID, brandID, knowledgeIngestInput{
GroupID: req.GroupID,
Name: name,
SourceType: "text",
@@ -623,6 +656,10 @@ func (s *KnowledgeService) CreateTextItem(ctx context.Context, req KnowledgeText
func (s *KnowledgeService) CreateURLItem(ctx context.Context, req KnowledgeURLItemRequest) (*KnowledgeItemResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if err := s.validateIngestionDependencies("website"); err != nil {
return nil, err
@@ -633,7 +670,7 @@ func (s *KnowledgeService) CreateURLItem(ctx context.Context, req KnowledgeURLIt
return nil, response.ErrBadRequest(40052, "knowledge_url_invalid", "knowledge url is invalid")
}
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
return s.queueKnowledgeItem(ctx, actor.TenantID, brandID, knowledgeIngestInput{
GroupID: req.GroupID,
Name: strings.TrimSpace(req.Name),
SourceType: "website",
@@ -649,6 +686,10 @@ func (s *KnowledgeService) CreateFileItem(
content []byte,
) (*KnowledgeItemResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
if err := s.validateIngestionDependencies("document"); err != nil {
return nil, err
@@ -664,7 +705,7 @@ func (s *KnowledgeService) CreateFileItem(
return nil, response.ErrServiceUnavailable(50357, "knowledge_storage_unavailable", err.Error())
}
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
return s.queueKnowledgeItem(ctx, actor.TenantID, brandID, knowledgeIngestInput{
GroupID: groupID,
Name: displayName,
SourceType: "document",
@@ -675,6 +716,10 @@ func (s *KnowledgeService) CreateFileItem(
func (s *KnowledgeService) DeleteItem(ctx context.Context, itemID int64) error {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -685,8 +730,8 @@ func (s *KnowledgeService) DeleteItem(ctx context.Context, itemID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE knowledge_items
SET deleted_at = NOW(), updated_at = NOW(), status = 'deleted'
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, itemID, actor.TenantID)
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, itemID, actor.TenantID, brandID)
if err != nil {
return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to delete knowledge item")
}
@@ -734,14 +779,18 @@ func (s *KnowledgeService) DeleteItem(ctx context.Context, itemID int64) error {
func (s *KnowledgeService) RetryItem(ctx context.Context, itemID int64) (*KnowledgeItemResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
var sourceType string
var status string
err := s.pool.QueryRow(ctx, `
err = s.pool.QueryRow(ctx, `
SELECT source_type, status
FROM knowledge_items
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, itemID, actor.TenantID).Scan(&sourceType, &status)
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, itemID, actor.TenantID, brandID).Scan(&sourceType, &status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40450, "knowledge_item_not_found", "knowledge item not found")
@@ -793,9 +842,10 @@ func (s *KnowledgeService) RetryItem(ctx context.Context, itemID int64) (*Knowle
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND brand_id = $3
AND deleted_at IS NULL
AND status = 'failed'
`, itemID, actor.TenantID)
`, itemID, actor.TenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to reset knowledge item")
}
@@ -816,7 +866,7 @@ func (s *KnowledgeService) RetryItem(ctx context.Context, itemID int64) (*Knowle
return nil, response.ErrServiceUnavailable(50356, "knowledge_queue_unavailable", "knowledge parse queue is busy, please retry")
}
items, err := s.listKnowledgeItemsByIDs(ctx, actor.TenantID, []int64{itemID})
items, err := s.listKnowledgeItemsByIDs(ctx, actor.TenantID, brandID, []int64{itemID})
if err != nil {
return nil, err
}
@@ -829,6 +879,7 @@ func (s *KnowledgeService) RetryItem(ctx context.Context, itemID int64) (*Knowle
func (s *KnowledgeService) ResolveContext(
ctx context.Context,
tenantID int64,
brandID int64,
groupIDs []int64,
query string,
) (*KnowledgeContext, error) {
@@ -871,7 +922,7 @@ func (s *KnowledgeService) ResolveContext(
return nil, response.ErrServiceUnavailable(50352, "qdrant_unavailable", err.Error())
}
searchGroupIDs, groupNames, err := s.resolveKnowledgeSearchScope(ctx, tenantID, requestedGroupIDs)
searchGroupIDs, groupNames, err := s.resolveKnowledgeSearchScope(ctx, tenantID, brandID, requestedGroupIDs)
if err != nil {
return nil, err
}
@@ -883,7 +934,7 @@ func (s *KnowledgeService) ResolveContext(
GroupIDs: searchGroupIDs,
GroupNames: groupNames,
Snippets: []KnowledgeSnippet{},
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, nil),
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, nil),
}
vectors, err := s.provider.Embed(ctx, queries)
@@ -903,6 +954,7 @@ func (s *KnowledgeService) ResolveContext(
points, err := s.store.Search(ctx, retrieval.SearchRequest{
Vector: vector,
TenantID: tenantID,
BrandID: brandID,
GroupIDs: searchGroupIDs,
Limit: runtimeCfg.RecallLimit,
})
@@ -922,7 +974,7 @@ func (s *KnowledgeService) ResolveContext(
return baseContext, nil
}
candidates, err = s.filterActiveKnowledgeSnippets(ctx, tenantID, candidates)
candidates, err = s.filterActiveKnowledgeSnippets(ctx, tenantID, brandID, candidates)
if err != nil {
return nil, err
}
@@ -939,17 +991,17 @@ func (s *KnowledgeService) ResolveContext(
reranked, err := s.provider.Rerank(ctx, rerankQuery, documents, minInt(runtimeCfg.RerankTopN, len(documents)))
if err != nil {
selected := selectKnowledgeCandidateSnippets(candidates, runtimeCfg.RerankTopN)
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected)
baseContext.Snippets = selected
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "rerank_failed_fallback", queryRewriteStage, queryRewriteModel, candidates, selected)
return baseContext, nil
}
selected := selectKnowledgeRerankedSnippets(candidates, reranked, runtimeCfg.RerankTopN)
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected)
baseContext.Snippets = selected
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "resolved", queryRewriteStage, queryRewriteModel, candidates, selected)
return baseContext, nil
}
@@ -1341,6 +1393,7 @@ type knowledgePromptItemRecord struct {
func (s *KnowledgeService) enrichKnowledgePromptSnippets(
ctx context.Context,
tenantID int64,
brandID int64,
snippets []KnowledgeSnippet,
) []KnowledgeSnippet {
if len(snippets) == 0 {
@@ -1358,7 +1411,7 @@ func (s *KnowledgeService) enrichKnowledgePromptSnippets(
return snippets
}
items, err := s.loadKnowledgePromptItems(ctx, tenantID, itemIDs)
items, err := s.loadKnowledgePromptItems(ctx, tenantID, brandID, itemIDs)
if err != nil {
if s.logger != nil {
s.logger.Warn("load knowledge prompt items failed", zap.Error(err))
@@ -1395,6 +1448,7 @@ func (s *KnowledgeService) enrichKnowledgePromptSnippets(
func (s *KnowledgeService) loadKnowledgePromptItems(
ctx context.Context,
tenantID int64,
brandID int64,
itemIDs []int64,
) ([]knowledgePromptItemRecord, error) {
if len(itemIDs) == 0 {
@@ -1404,12 +1458,13 @@ func (s *KnowledgeService) loadKnowledgePromptItems(
rows, err := s.pool.Query(ctx, `
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.content_text, ki.markdown_content
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id AND kg.deleted_at IS NULL
WHERE ki.tenant_id = $1
AND ki.id = ANY($2::bigint[])
AND ki.brand_id = $2
AND ki.id = ANY($3::bigint[])
AND ki.status = 'completed'
AND ki.deleted_at IS NULL
`, tenantID, itemIDs)
`, tenantID, brandID, itemIDs)
if err != nil {
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to load knowledge prompt items")
}
@@ -1438,6 +1493,7 @@ func (s *KnowledgeService) loadKnowledgePromptItems(
func (s *KnowledgeService) collectKnowledgeContextPreciseFacts(
ctx context.Context,
tenantID int64,
brandID int64,
groupIDs []int64,
snippets []KnowledgeSnippet,
) []string {
@@ -1454,7 +1510,7 @@ func (s *KnowledgeService) collectKnowledgeContextPreciseFacts(
}
matchedItemIDs = normalizeInt64IDs(matchedItemIDs)
items, err := s.loadKnowledgePromptItemsByGroupIDs(ctx, tenantID, groupIDs, matchedItemIDs, 24)
items, err := s.loadKnowledgePromptItemsByGroupIDs(ctx, tenantID, brandID, groupIDs, matchedItemIDs, 24)
if err != nil {
if s.logger != nil {
s.logger.Warn("collect knowledge context precise facts failed", zap.Error(err))
@@ -1506,6 +1562,7 @@ func (s *KnowledgeService) collectKnowledgeContextPreciseFacts(
func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs(
ctx context.Context,
tenantID int64,
brandID int64,
groupIDs []int64,
prioritizedItemIDs []int64,
limit int,
@@ -1524,31 +1581,34 @@ func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs(
SELECT id
FROM knowledge_groups
WHERE tenant_id = $1
AND id = ANY($2::bigint[])
AND brand_id = $2
AND id = ANY($3::bigint[])
AND deleted_at IS NULL
UNION ALL
SELECT child.id
FROM knowledge_groups child
JOIN selected_groups parent ON child.parent_id = parent.id
WHERE child.tenant_id = $1
AND child.brand_id = $2
AND child.deleted_at IS NULL
)
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.content_text, ki.markdown_content
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id AND kg.deleted_at IS NULL
WHERE ki.tenant_id = $1
AND ki.brand_id = $2
AND ki.status = 'completed'
AND ki.deleted_at IS NULL
AND ki.group_id IN (SELECT id FROM selected_groups)
ORDER BY
CASE
WHEN ki.id = ANY($3::bigint[]) THEN 0
WHEN ki.id = ANY($4::bigint[]) THEN 0
ELSE 1
END,
ki.updated_at DESC,
ki.id DESC
LIMIT $4
`, tenantID, groupIDs, prioritizedItemIDs, limit)
LIMIT $5
`, tenantID, brandID, groupIDs, prioritizedItemIDs, limit)
if err != nil {
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to load knowledge prompt items by groups")
}
@@ -1859,9 +1919,10 @@ func (s *KnowledgeService) validateIngestionDependencies(sourceType string) erro
func (s *KnowledgeService) queueKnowledgeItem(
ctx context.Context,
tenantID int64,
brandID int64,
input knowledgeIngestInput,
) (*KnowledgeItemResponse, error) {
resolvedGroupID, err := s.resolveKnowledgeStorageGroupID(ctx, tenantID, input.GroupID)
resolvedGroupID, err := s.resolveKnowledgeStorageGroupID(ctx, tenantID, brandID, input.GroupID)
if err != nil {
return nil, err
}
@@ -1880,7 +1941,7 @@ func (s *KnowledgeService) queueKnowledgeItem(
input.ContentText = &contentText
}
itemID, parseTaskID, err := s.createPendingKnowledgeItem(ctx, tenantID, input)
itemID, parseTaskID, err := s.createPendingKnowledgeItem(ctx, tenantID, brandID, input)
if err != nil {
return nil, err
}
@@ -1894,7 +1955,7 @@ func (s *KnowledgeService) queueKnowledgeItem(
return nil, response.ErrServiceUnavailable(50356, "knowledge_queue_unavailable", "knowledge parse queue is busy, please retry")
}
items, err := s.listKnowledgeItemsByIDs(ctx, tenantID, []int64{itemID})
items, err := s.listKnowledgeItemsByIDs(ctx, tenantID, brandID, []int64{itemID})
if err != nil {
return nil, err
}
@@ -2064,6 +2125,7 @@ func (s *KnowledgeService) executeParseJob(ctx context.Context, job knowledgePar
Vector: embeddings[index],
Payload: map[string]any{
"tenant_id": job.TenantID,
"brand_id": item.BrandID,
"group_id": item.GroupID,
"group_name": item.GroupName,
"item_id": item.ID,
@@ -2105,14 +2167,15 @@ func (s *KnowledgeService) loadKnowledgeItemForParse(
var contentText *string
var markdownContent *string
err := s.pool.QueryRow(ctx, `
SELECT ki.id, ki.tenant_id, ki.group_id, kg.name, ki.name, ki.source_type,
SELECT ki.id, ki.tenant_id, ki.brand_id, ki.group_id, kg.name, ki.name, ki.source_type,
ki.source_uri, ki.storage_key, ki.content_text, ki.markdown_content, ki.item_version
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NULL
`, itemID, tenantID).Scan(
&record.ID,
&record.TenantID,
&record.BrandID,
&record.GroupID,
&record.GroupName,
&record.Name,
@@ -2166,6 +2229,7 @@ type knowledgeChunkMetaInput struct {
func (s *KnowledgeService) createPendingKnowledgeItem(
ctx context.Context,
tenantID int64,
brandID int64,
input knowledgeIngestInput,
) (int64, int64, error) {
tx, err := s.pool.Begin(ctx)
@@ -2177,12 +2241,12 @@ func (s *KnowledgeService) createPendingKnowledgeItem(
var itemID int64
err = tx.QueryRow(ctx, `
INSERT INTO knowledge_items (
tenant_id, group_id, source_type, name, source_uri, storage_key,
tenant_id, brand_id, group_id, source_type, name, source_uri, storage_key,
content_text, markdown_content, status, size_bytes, item_version
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, 'pending', $8, 1)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, 'pending', $9, 1)
RETURNING id
`, tenantID, input.GroupID, input.SourceType, input.Name, input.SourceURI, input.StorageKey, input.ContentText, input.SizeBytes).Scan(&itemID)
`, tenantID, brandID, input.GroupID, input.SourceType, input.Name, input.SourceURI, input.StorageKey, input.ContentText, input.SizeBytes).Scan(&itemID)
if err != nil {
return 0, 0, response.ErrConflict(40951, "knowledge_item_create_failed", "failed to create knowledge item")
}
@@ -2434,15 +2498,15 @@ func buildKnowledgeSearchText(input knowledgeSearchTextInput) string {
return strings.Join(metadata, "\n")
}
func (s *KnowledgeService) ensureGroupAccessible(ctx context.Context, tenantID, groupID int64) error {
func (s *KnowledgeService) ensureGroupAccessible(ctx context.Context, tenantID, brandID, groupID int64) error {
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1
FROM knowledge_groups
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
)
`, groupID, tenantID).Scan(&exists)
`, groupID, tenantID, brandID).Scan(&exists)
if err != nil {
return response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
}
@@ -2452,8 +2516,8 @@ func (s *KnowledgeService) ensureGroupAccessible(ctx context.Context, tenantID,
return nil
}
func (s *KnowledgeService) ensureRootGroup(ctx context.Context, tenantID, groupID int64) error {
if err := s.ensureGroupAccessible(ctx, tenantID, groupID); err != nil {
func (s *KnowledgeService) ensureRootGroup(ctx context.Context, tenantID, brandID, groupID int64) error {
if err := s.ensureGroupAccessible(ctx, tenantID, brandID, groupID); err != nil {
return err
}
@@ -2464,10 +2528,11 @@ func (s *KnowledgeService) ensureRootGroup(ctx context.Context, tenantID, groupI
FROM knowledge_groups
WHERE id = $1
AND tenant_id = $2
AND brand_id = $3
AND deleted_at IS NULL
AND parent_id IS NULL
)
`, groupID, tenantID).Scan(&isRoot)
`, groupID, tenantID, brandID).Scan(&isRoot)
if err != nil {
return response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
}
@@ -2477,8 +2542,8 @@ func (s *KnowledgeService) ensureRootGroup(ctx context.Context, tenantID, groupI
return nil
}
func (s *KnowledgeService) ensureStorableGroup(ctx context.Context, tenantID, groupID int64) error {
if err := s.ensureGroupAccessible(ctx, tenantID, groupID); err != nil {
func (s *KnowledgeService) ensureStorableGroup(ctx context.Context, tenantID, brandID, groupID int64) error {
if err := s.ensureGroupAccessible(ctx, tenantID, brandID, groupID); err != nil {
return err
}
@@ -2490,13 +2555,15 @@ func (s *KnowledgeService) ensureStorableGroup(ctx context.Context, tenantID, gr
JOIN knowledge_groups parent
ON parent.id = child.parent_id
AND parent.tenant_id = child.tenant_id
AND parent.brand_id = child.brand_id
AND parent.deleted_at IS NULL
WHERE child.id = $1
AND child.tenant_id = $2
AND child.brand_id = $3
AND child.deleted_at IS NULL
AND parent.parent_id IS NULL
)
`, groupID, tenantID).Scan(&storable)
`, groupID, tenantID, brandID).Scan(&storable)
if err != nil {
return response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
}
@@ -2506,14 +2573,14 @@ func (s *KnowledgeService) ensureStorableGroup(ctx context.Context, tenantID, gr
return nil
}
func (s *KnowledgeService) resolveKnowledgeStorageGroupID(ctx context.Context, tenantID, groupID int64) (int64, error) {
func (s *KnowledgeService) resolveKnowledgeStorageGroupID(ctx context.Context, tenantID, brandID, groupID int64) (int64, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, response.ErrInternal(50055, "knowledge_group_resolve_failed", "failed to begin knowledge group transaction")
}
defer func() { _ = tx.Rollback(ctx) }()
resolvedGroupID, err := s.resolveKnowledgeStorageGroupIDTx(ctx, tx, tenantID, groupID)
resolvedGroupID, err := s.resolveKnowledgeStorageGroupIDTx(ctx, tx, tenantID, brandID, groupID)
if err != nil {
return 0, err
}
@@ -2527,15 +2594,15 @@ func (s *KnowledgeService) resolveKnowledgeStorageGroupID(ctx context.Context, t
func (s *KnowledgeService) resolveKnowledgeStorageGroupIDTx(
ctx context.Context,
tx pgx.Tx,
tenantID, groupID int64,
tenantID, brandID, groupID int64,
) (int64, error) {
var parentID *int64
if err := tx.QueryRow(ctx, `
SELECT parent_id
FROM knowledge_groups
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
FOR UPDATE
`, groupID, tenantID).Scan(&parentID); err != nil {
`, groupID, tenantID, brandID).Scan(&parentID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
}
@@ -2547,8 +2614,8 @@ func (s *KnowledgeService) resolveKnowledgeStorageGroupIDTx(
if err := tx.QueryRow(ctx, `
SELECT parent_id
FROM knowledge_groups
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, *parentID, tenantID).Scan(&grandParentID); err != nil {
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
`, *parentID, tenantID, brandID).Scan(&grandParentID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
}
@@ -2565,22 +2632,23 @@ func (s *KnowledgeService) resolveKnowledgeStorageGroupIDTx(
SELECT id
FROM knowledge_groups
WHERE tenant_id = $1
AND parent_id = $2
AND name = $3
AND brand_id = $2
AND parent_id = $3
AND name = $4
AND deleted_at IS NULL
ORDER BY id
LIMIT 1
`, tenantID, groupID, defaultKnowledgeChildGroupName).Scan(&childGroupID); err == nil {
`, tenantID, brandID, groupID, defaultKnowledgeChildGroupName).Scan(&childGroupID); err == nil {
return childGroupID, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
}
if err := tx.QueryRow(ctx, `
INSERT INTO knowledge_groups (tenant_id, name, parent_id, sort_order)
VALUES ($1, $2, $3, 0)
INSERT INTO knowledge_groups (tenant_id, brand_id, name, parent_id, sort_order)
VALUES ($1, $2, $3, $4, 0)
RETURNING id
`, tenantID, defaultKnowledgeChildGroupName, groupID).Scan(&childGroupID); err == nil {
`, tenantID, brandID, defaultKnowledgeChildGroupName, groupID).Scan(&childGroupID); err == nil {
return childGroupID, nil
}
@@ -2588,12 +2656,13 @@ func (s *KnowledgeService) resolveKnowledgeStorageGroupIDTx(
SELECT id
FROM knowledge_groups
WHERE tenant_id = $1
AND parent_id = $2
AND name = $3
AND brand_id = $2
AND parent_id = $3
AND name = $4
AND deleted_at IS NULL
ORDER BY id
LIMIT 1
`, tenantID, groupID, defaultKnowledgeChildGroupName).Scan(&childGroupID); err != nil {
`, tenantID, brandID, groupID, defaultKnowledgeChildGroupName).Scan(&childGroupID); err != nil {
return 0, response.ErrConflict(40950, "knowledge_group_create_failed", "failed to create default knowledge group")
}
return childGroupID, nil
@@ -2602,22 +2671,22 @@ func (s *KnowledgeService) resolveKnowledgeStorageGroupIDTx(
func (s *KnowledgeService) listGroupSubtreeIDs(
ctx context.Context,
tx pgx.Tx,
tenantID, groupID int64,
tenantID, brandID, groupID int64,
) ([]int64, error) {
rows, err := tx.Query(ctx, `
WITH RECURSIVE selected_groups AS (
SELECT id
FROM knowledge_groups
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
UNION ALL
SELECT child.id
FROM knowledge_groups child
JOIN selected_groups parent ON child.parent_id = parent.id
WHERE child.tenant_id = $2 AND child.deleted_at IS NULL
WHERE child.tenant_id = $2 AND child.brand_id = $3 AND child.deleted_at IS NULL
)
SELECT id
FROM selected_groups
`, groupID, tenantID)
`, groupID, tenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to query knowledge groups")
}
@@ -2638,6 +2707,7 @@ func (s *KnowledgeService) listActiveKnowledgeItemIDs(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
brandID int64,
groupIDs []int64,
) ([]int64, error) {
if len(groupIDs) == 0 {
@@ -2648,9 +2718,10 @@ func (s *KnowledgeService) listActiveKnowledgeItemIDs(
SELECT id
FROM knowledge_items
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
AND group_id = ANY($2::bigint[])
`, tenantID, groupIDs)
AND group_id = ANY($3::bigint[])
`, tenantID, brandID, groupIDs)
if err != nil {
return nil, response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to query knowledge items")
}
@@ -2714,6 +2785,7 @@ func (s *KnowledgeService) listKnowledgePointIDs(ctx context.Context, tenantID,
func (s *KnowledgeService) filterActiveKnowledgeSnippets(
ctx context.Context,
tenantID int64,
brandID int64,
candidates []KnowledgeSnippet,
) ([]KnowledgeSnippet, error) {
if len(candidates) == 0 {
@@ -2735,10 +2807,11 @@ func (s *KnowledgeService) filterActiveKnowledgeSnippets(
SELECT id
FROM knowledge_items
WHERE tenant_id = $1
AND id = ANY($2::bigint[])
AND brand_id = $2
AND id = ANY($3::bigint[])
AND status = 'completed'
AND deleted_at IS NULL
`, tenantID, itemIDs)
`, tenantID, brandID, itemIDs)
if err != nil {
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to filter deleted knowledge items")
}
@@ -2852,14 +2925,15 @@ func (s *KnowledgeService) loadKnowledgeItemForCleanup(
var contentText *string
var markdownContent *string
err := s.pool.QueryRow(ctx, `
SELECT ki.id, ki.tenant_id, ki.group_id, kg.name, ki.name, ki.source_type,
SELECT ki.id, ki.tenant_id, ki.brand_id, ki.group_id, kg.name, ki.name, ki.source_type,
ki.source_uri, ki.storage_key, ki.content_text, ki.markdown_content, ki.item_version
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NOT NULL
`, itemID, tenantID).Scan(
&record.ID,
&record.TenantID,
&record.BrandID,
&record.GroupID,
&record.GroupName,
&record.Name,
@@ -2925,7 +2999,7 @@ func (s *KnowledgeService) clearKnowledgeCleanupError(ctx context.Context, tenan
return err
}
func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID int64, itemIDs []int64) ([]KnowledgeItemResponse, error) {
func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID, brandID int64, itemIDs []int64) ([]KnowledgeItemResponse, error) {
if len(itemIDs) == 0 {
return []KnowledgeItemResponse{}, nil
}
@@ -2933,10 +3007,10 @@ func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.status,
ki.size_bytes, ki.error_message, ki.created_at, ki.updated_at
FROM knowledge_items ki
JOIN knowledge_groups kg ON kg.id = ki.group_id
WHERE ki.tenant_id = $1 AND ki.id = ANY($2::bigint[]) AND ki.deleted_at IS NULL
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id
WHERE ki.tenant_id = $1 AND ki.brand_id = $2 AND ki.id = ANY($3::bigint[]) AND ki.deleted_at IS NULL
ORDER BY ki.created_at DESC, ki.id DESC
`, tenantID, itemIDs)
`, tenantID, brandID, itemIDs)
if err != nil {
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to list knowledge items")
}
@@ -2974,7 +3048,7 @@ func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID
return items, nil
}
func (s *KnowledgeService) resolveKnowledgeSearchScope(ctx context.Context, tenantID int64, groupIDs []int64) ([]int64, []string, error) {
func (s *KnowledgeService) resolveKnowledgeSearchScope(ctx context.Context, tenantID, brandID int64, groupIDs []int64) ([]int64, []string, error) {
groupIDs = normalizeInt64IDs(groupIDs)
if len(groupIDs) == 0 {
return []int64{}, []string{}, nil
@@ -2985,19 +3059,21 @@ func (s *KnowledgeService) resolveKnowledgeSearchScope(ctx context.Context, tena
SELECT id, name, parent_id, sort_order, created_at
FROM knowledge_groups
WHERE tenant_id = $1
AND id = ANY($2::bigint[])
AND brand_id = $2
AND id = ANY($3::bigint[])
AND deleted_at IS NULL
UNION
SELECT child.id, child.name, child.parent_id, child.sort_order, child.created_at
FROM knowledge_groups child
JOIN selected_groups parent ON child.parent_id = parent.id
WHERE child.tenant_id = $1
AND child.brand_id = $2
AND child.deleted_at IS NULL
)
SELECT id, name
FROM selected_groups
ORDER BY sort_order, created_at, id
`, tenantID, groupIDs)
`, tenantID, brandID, groupIDs)
if err != nil {
return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups")
}
@@ -32,15 +32,15 @@ type mediaSupplyArticleImageUpload struct {
Content []byte
}
func (s *MediaSupplyService) prepareMeijiequanArticleContent(ctx context.Context, tenantID int64, content string) (string, error) {
func (s *MediaSupplyService) prepareMeijiequanArticleContent(ctx context.Context, tenantID int64, brandID int64, content string) (string, error) {
html := meijiequanArticleHTML(content)
if strings.TrimSpace(html) == "" {
return "", nil
}
return s.uploadMeijiequanArticleImages(ctx, tenantID, html)
return s.uploadMeijiequanArticleImages(ctx, tenantID, brandID, html)
}
func (s *MediaSupplyService) uploadMeijiequanArticleImages(ctx context.Context, tenantID int64, fragment string) (string, error) {
func (s *MediaSupplyService) uploadMeijiequanArticleImages(ctx context.Context, tenantID int64, brandID int64, fragment string) (string, error) {
if !strings.Contains(strings.ToLower(fragment), "<img") {
return fragment, nil
}
@@ -56,7 +56,7 @@ func (s *MediaSupplyService) uploadMeijiequanArticleImages(ctx context.Context,
cache := make(map[string]string)
for _, node := range nodes {
if err := s.uploadMeijiequanArticleImageNode(ctx, tenantID, node, cache); err != nil {
if err := s.uploadMeijiequanArticleImageNode(ctx, tenantID, brandID, node, cache); err != nil {
return "", err
}
}
@@ -70,31 +70,31 @@ func (s *MediaSupplyService) uploadMeijiequanArticleImages(ctx context.Context,
return builder.String(), nil
}
func (s *MediaSupplyService) uploadMeijiequanArticleImageNode(ctx context.Context, tenantID int64, node *nethtml.Node, cache map[string]string) error {
func (s *MediaSupplyService) uploadMeijiequanArticleImageNode(ctx context.Context, tenantID int64, brandID int64, node *nethtml.Node, cache map[string]string) error {
if node == nil {
return nil
}
if node.Type == nethtml.ElementNode && strings.EqualFold(node.Data, "img") {
if err := s.uploadMeijiequanArticleImageAttrs(ctx, tenantID, node, cache); err != nil {
if err := s.uploadMeijiequanArticleImageAttrs(ctx, tenantID, brandID, node, cache); err != nil {
return err
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if err := s.uploadMeijiequanArticleImageNode(ctx, tenantID, child, cache); err != nil {
if err := s.uploadMeijiequanArticleImageNode(ctx, tenantID, brandID, child, cache); err != nil {
return err
}
}
return nil
}
func (s *MediaSupplyService) uploadMeijiequanArticleImageAttrs(ctx context.Context, tenantID int64, node *nethtml.Node, cache map[string]string) error {
func (s *MediaSupplyService) uploadMeijiequanArticleImageAttrs(ctx context.Context, tenantID int64, brandID int64, node *nethtml.Node, cache map[string]string) error {
src := mediaSupplyHTMLAttrValue(node.Attr, "src")
originalSrc := mediaSupplyHTMLAttrValue(node.Attr, "_src")
dataSrc := mediaSupplyHTMLAttrValue(node.Attr, "data-src")
assetID := mediaSupplyHTMLInt64AttrValue(node.Attr, "data-asset-id")
currentURL := firstNonEmptyText(src, originalSrc, dataSrc)
upload, resolved, err := s.resolveMeijiequanArticleImageUpload(ctx, tenantID, currentURL, assetID)
upload, resolved, err := s.resolveMeijiequanArticleImageUpload(ctx, tenantID, brandID, currentURL, assetID)
if err != nil {
return err
}
@@ -158,8 +158,8 @@ func (s *MediaSupplyService) uploadMeijiequanArticleImageAttrs(ctx context.Conte
return nil
}
func (s *MediaSupplyService) resolveMeijiequanArticleImageUpload(ctx context.Context, tenantID int64, imageURL string, assetID int64) (mediaSupplyArticleImageUpload, bool, error) {
objectKey, internalAssetURL, ok := s.mediaSupplyObjectKeyFromPublicAssetURL(imageURL, tenantID)
func (s *MediaSupplyService) resolveMeijiequanArticleImageUpload(ctx context.Context, tenantID int64, brandID int64, imageURL string, assetID int64) (mediaSupplyArticleImageUpload, bool, error) {
objectKey, internalAssetURL, ok := s.mediaSupplyObjectKeyFromPublicAssetURL(ctx, imageURL, tenantID, brandID)
if internalAssetURL {
if !ok {
if assetID <= 0 {
@@ -171,7 +171,7 @@ func (s *MediaSupplyService) resolveMeijiequanArticleImageUpload(ctx context.Con
}
}
if assetID > 0 {
objectKey, err := s.loadMediaSupplyImageAssetObjectKey(ctx, tenantID, assetID)
objectKey, err := s.loadMediaSupplyImageAssetObjectKey(ctx, tenantID, brandID, assetID)
if err != nil {
return mediaSupplyArticleImageUpload{}, false, err
}
@@ -259,7 +259,7 @@ func mediaSupplyUploadFilename(objectKey, sourceURL, ext string) string {
return name
}
func (s *MediaSupplyService) mediaSupplyObjectKeyFromPublicAssetURL(raw string, tenantID int64) (string, bool, bool) {
func (s *MediaSupplyService) mediaSupplyObjectKeyFromPublicAssetURL(ctx context.Context, raw string, tenantID int64, brandID int64) (string, bool, bool) {
token, ok := mediaSupplyPublicAssetTokenFromURL(raw)
if !ok {
return "", false, false
@@ -268,6 +268,9 @@ func (s *MediaSupplyService) mediaSupplyObjectKeyFromPublicAssetURL(raw string,
if !valid || !mediaSupplyObjectKeyAllowedForTenant(objectKey, tenantID) {
return "", true, false
}
if !s.mediaSupplyImageObjectKeyBelongsToBrand(ctx, tenantID, brandID, objectKey) {
return "", true, false
}
return objectKey, true, true
}
@@ -317,16 +320,48 @@ func mediaSupplyObjectKeyAllowedForTenant(objectKey string, tenantID int64) bool
strings.HasPrefix(objectKey, tenantPrefix+"articles/")
}
func (s *MediaSupplyService) loadMediaSupplyImageAssetObjectKey(ctx context.Context, tenantID, assetID int64) (string, error) {
func mediaSupplyObjectKeyIsImageLibraryAsset(objectKey string, tenantID int64) bool {
if tenantID <= 0 {
return false
}
tenantPrefix := "tenants/" + strconv.FormatInt(tenantID, 10) + "/"
return strings.HasPrefix(strings.TrimSpace(objectKey), tenantPrefix+"images/")
}
func (s *MediaSupplyService) mediaSupplyImageObjectKeyBelongsToBrand(ctx context.Context, tenantID, brandID int64, objectKey string) bool {
if s == nil || s.pool == nil || !mediaSupplyObjectKeyIsImageLibraryAsset(objectKey, tenantID) {
return true
}
if tenantID <= 0 || brandID <= 0 || strings.TrimSpace(objectKey) == "" {
return false
}
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM image_assets
WHERE tenant_id = $1
AND brand_id = $2
AND object_key = $3
AND status = 'active'
AND deleted_at IS NULL
)
`, tenantID, brandID, objectKey).Scan(&exists); err != nil {
return false
}
return exists
}
func (s *MediaSupplyService) loadMediaSupplyImageAssetObjectKey(ctx context.Context, tenantID, brandID, assetID int64) (string, error) {
if s == nil || s.pool == nil {
return "", errMediaSupplyArticleImageUpload
}
var objectKey string
if err := s.pool.QueryRow(ctx, `
SELECT object_key
FROM image_assets
WHERE id = $1 AND tenant_id = $2 AND status = 'active' AND deleted_at IS NULL
`, assetID, tenantID).Scan(&objectKey); err != nil {
SELECT object_key
FROM image_assets
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND status = 'active' AND deleted_at IS NULL
`, assetID, tenantID, brandID).Scan(&objectKey); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", errMediaSupplyArticleImageUpload
}
@@ -534,7 +534,7 @@ func TestPrepareMeijiequanArticleContentUploadsSaaSImages(t *testing.T) {
}).WithObjectStorage(storage)
content := `<p class="article-editor-image article-editor-image--center" align="center"><img src="/api/public/assets/` + token + `" alt="" data-asset-id="5" /></p>`
got, err := svc.prepareMeijiequanArticleContent(context.Background(), 4, content)
got, err := svc.prepareMeijiequanArticleContent(context.Background(), 4, 11, content)
if err != nil {
t.Fatalf("prepare content: %v", err)
}
@@ -32,6 +32,7 @@ type mediaSupplySyncJob struct {
type mediaSupplySubmitOrder struct {
ID int64
TenantID int64
BrandID int64
UserID int64
ModelID int
Title string
@@ -172,7 +173,7 @@ func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error {
if err := w.refreshOrderCostsBeforeSubmit(ctx, order, items); err != nil {
return w.failOrder(ctx, order, err)
}
content, err := w.service.prepareMeijiequanArticleContent(ctx, order.TenantID, order.ContentSnapshot)
content, err := w.service.prepareMeijiequanArticleContent(ctx, order.TenantID, order.BrandID, order.ContentSnapshot)
if err != nil {
return w.failOrder(ctx, order, err)
}
@@ -1143,7 +1144,7 @@ func (w *MediaSupplyWorker) claimSubmitOrder(ctx context.Context) (*mediaSupplyS
var order mediaSupplySubmitOrder
var requestPayload []byte
if err := tx.QueryRow(ctx, `
SELECT id, tenant_id, user_id, model_id, title, content_snapshot, remark, order_brand, attempt_count, request_payload_json
SELECT id, tenant_id, brand_id, user_id, model_id, title, content_snapshot, remark, order_brand, attempt_count, request_payload_json
FROM media_supply_orders
WHERE supplier = $1 AND status = $2 AND next_attempt_at <= NOW() AND deleted_at IS NULL
ORDER BY queued_at ASC, id ASC
@@ -1152,6 +1153,7 @@ func (w *MediaSupplyWorker) claimSubmitOrder(ctx context.Context) (*mediaSupplyS
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusQueued).Scan(
&order.ID,
&order.TenantID,
&order.BrandID,
&order.UserID,
&order.ModelID,
&order.Title,
@@ -355,6 +355,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
ctx,
input.TenantID,
*input.BrandID,
rule.KnowledgeGroupIDs,
buildPromptRuleKnowledgeQuery(rule, inputParams),
)
@@ -254,7 +254,7 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (
if err != nil {
return nil, err
}
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs)
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, brandID, req.KnowledgeGroupIDs)
if err != nil {
return nil, err
}
@@ -325,7 +325,7 @@ func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRule
if err != nil {
return err
}
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs)
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, brandID, req.KnowledgeGroupIDs)
if err != nil {
return err
}
@@ -535,7 +535,7 @@ func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID, brand
item.KnowledgeGroups = []PromptRuleKnowledgeGroup{}
items = append(items, item)
}
groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, tenantID, collectPromptRuleIDs(items))
groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, tenantID, brandID, collectPromptRuleIDs(items))
if err != nil {
return nil, err
}
@@ -572,7 +572,7 @@ func (s *PromptRuleService) loadPromptRuleDetail(ctx context.Context, tenantID,
item.UpdatedAt = fmt.Sprintf("%v", updatedAt)
item.ArticleCount = articleCount
groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, tenantID, []int64{ruleID})
groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, tenantID, brandID, []int64{ruleID})
if err != nil {
return nil, false, err
}
@@ -630,6 +630,7 @@ func (s *PromptRuleService) validatePromptRuleGroup(ctx context.Context, tenantI
func (s *PromptRuleService) validatePromptRuleKnowledgeGroups(
ctx context.Context,
tenantID int64,
brandID int64,
groupIDs []int64,
) ([]PromptRuleKnowledgeGroup, error) {
groupIDs = normalizeInt64IDs(groupIDs)
@@ -640,9 +641,9 @@ func (s *PromptRuleService) validatePromptRuleKnowledgeGroups(
rows, err := s.pool.Query(ctx, `
SELECT id, name
FROM knowledge_groups
WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL
WHERE tenant_id = $1 AND brand_id = $2 AND id = ANY($3::bigint[]) AND deleted_at IS NULL
ORDER BY sort_order, created_at, id
`, tenantID, groupIDs)
`, tenantID, brandID, groupIDs)
if err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query knowledge groups")
}
@@ -669,6 +670,7 @@ func (s *PromptRuleService) validatePromptRuleKnowledgeGroups(
func (s *PromptRuleService) loadPromptRuleKnowledgeGroupMap(
ctx context.Context,
tenantID int64,
brandID int64,
ruleIDs []int64,
) (map[int64][]PromptRuleKnowledgeGroup, error) {
result := make(map[int64][]PromptRuleKnowledgeGroup, len(ruleIDs))
@@ -683,9 +685,10 @@ func (s *PromptRuleService) loadPromptRuleKnowledgeGroupMap(
JOIN knowledge_groups kg ON kg.id = prkg.knowledge_group_id
WHERE prkg.prompt_rule_id = ANY($1::bigint[])
AND kg.tenant_id = $2
AND kg.brand_id = $3
AND kg.deleted_at IS NULL
ORDER BY kg.sort_order, kg.created_at, kg.id
`, ruleIDs, tenantID)
`, ruleIDs, tenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query prompt rule knowledge groups")
}
@@ -1169,7 +1169,11 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
randomScope := ScheduleCoverRandomScopeAll
var randomFolderID *int64
if coverMode == ScheduleCoverModeRandom {
randomScope, randomFolderID, err = s.normalizeScheduleCoverRandomSource(ctx, actor.TenantID, req.CoverRandomScope, req.CoverRandomFolderID)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return config, err
}
randomScope, randomFolderID, err = s.normalizeScheduleCoverRandomSource(ctx, actor.TenantID, brandID, req.CoverRandomScope, req.CoverRandomFolderID)
if err != nil {
return config, err
}
@@ -1218,7 +1222,7 @@ func normalizeScheduleCoverMode(value string) (string, error) {
}
}
func (s *ScheduleTaskService) normalizeScheduleCoverRandomSource(ctx context.Context, tenantID int64, scope string, folderID *int64) (string, *int64, error) {
func (s *ScheduleTaskService) normalizeScheduleCoverRandomSource(ctx context.Context, tenantID, brandID int64, scope string, folderID *int64) (string, *int64, error) {
normalizedScope := strings.TrimSpace(scope)
if normalizedScope == "" {
normalizedScope = ScheduleCoverRandomScopeAll
@@ -1230,7 +1234,7 @@ func (s *ScheduleTaskService) normalizeScheduleCoverRandomSource(ctx context.Con
if folderID == nil || *folderID <= 0 {
return "", nil, response.ErrBadRequest(40077, "cover_random_folder_required", "请选择随机封面图片文件夹")
}
if err := s.ensureImageFolderExists(ctx, tenantID, *folderID); err != nil {
if err := s.ensureImageFolderExists(ctx, tenantID, brandID, *folderID); err != nil {
return "", nil, err
}
return ScheduleCoverRandomScopeFolder, folderID, nil
@@ -1239,7 +1243,7 @@ func (s *ScheduleTaskService) normalizeScheduleCoverRandomSource(ctx context.Con
}
}
func (s *ScheduleTaskService) ensureImageFolderExists(ctx context.Context, tenantID, folderID int64) error {
func (s *ScheduleTaskService) ensureImageFolderExists(ctx context.Context, tenantID, brandID, folderID int64) error {
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
@@ -1247,9 +1251,9 @@ func (s *ScheduleTaskService) ensureImageFolderExists(ctx context.Context, tenan
FROM image_folders
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
AND brand_id = $3
)
`, folderID, tenantID).Scan(&exists); err != nil {
`, folderID, tenantID, brandID).Scan(&exists); err != nil {
return response.ErrInternal(50010, "query_failed", "failed to validate image folder")
}
if !exists {
@@ -490,6 +490,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
ctx,
job.TenantID,
int64(extractInt(job.Params, "brand_id")),
knowledgeGroupIDs,
buildGenerationKnowledgeQuery(job.TemplateKey, job.TemplateName, job.Params),
)