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
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { imagesApi } from '@/lib/api'
import { useCompanyStore } from '@/stores/company'
import type { ScheduleCoverMode, ScheduleCoverRandomScope } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
@@ -24,10 +25,12 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const companyStore = useCompanyStore()
const currentBrandId = computed(() => companyStore.currentBrandId)
const foldersQuery = useQuery({
queryKey: ['images', 'folders', 'cover-source'],
enabled: computed(() => !props.disabled),
queryKey: computed(() => ['images', 'folders', 'cover-source', currentBrandId.value]),
enabled: computed(() => !props.disabled && Boolean(currentBrandId.value)),
queryFn: () => imagesApi.listFolders(),
})
@@ -38,6 +41,13 @@ const folderOptions = computed(() =>
})),
)
watch(
currentBrandId,
() => {
emit('update:randomFolderId', null)
},
)
watch(
() => props.mode,
(mode) => {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { imagesApi } from '@/lib/api'
import { formatBytes, formatPercentage } from '@/lib/display'
import { useCompanyStore } from '@/stores/company'
import type { ImageAssetItem } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
@@ -20,6 +21,8 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const companyStore = useCompanyStore()
const currentBrandId = computed(() => companyStore.currentBrandId)
// State
const selectedFolderId = ref<number | null>(null)
@@ -31,7 +34,8 @@ const selectedId = ref<number | null>(null)
// Queries
const foldersQuery = useQuery({
queryKey: ['images', 'folders'],
queryKey: computed(() => ['images', 'folders', currentBrandId.value]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () => imagesApi.listFolders(),
})
@@ -40,6 +44,7 @@ const imagesQuery = useQuery({
'images',
'list',
'picker',
currentBrandId.value,
{
folder_id: selectedFolderId.value,
q: debouncedSearch.value,
@@ -47,6 +52,7 @@ const imagesQuery = useQuery({
page_size: pageSize.value,
},
]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () =>
imagesApi.list({
folder_id: selectedFolderId.value ?? undefined,
@@ -77,6 +83,12 @@ watch(searchKeyword, () => {
}, 300)
})
watch(currentBrandId, () => {
selectedFolderId.value = null
selectedId.value = null
currentPage.value = 1
})
function handleSelect(image: ImageAssetItem) {
selectedId.value = image.id
emit('select', image)
@@ -5,6 +5,7 @@ import { computed, watch } from 'vue'
import MarkdownPreview from '@/components/MarkdownPreview.vue'
import { knowledgeApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { useCompanyStore } from '@/stores/company'
const props = defineProps<{
open: boolean
@@ -16,10 +17,12 @@ const emit = defineEmits<{
}>()
const queryClient = useQueryClient()
const enabled = computed(() => props.open && Boolean(props.itemId))
const companyStore = useCompanyStore()
const currentBrandId = computed(() => companyStore.currentBrandId)
const enabled = computed(() => props.open && Boolean(props.itemId) && Boolean(currentBrandId.value))
const detailQuery = useQuery({
queryKey: computed(() => ['knowledge', 'detail', props.itemId]),
queryKey: computed(() => ['knowledge', 'detail', currentBrandId.value, props.itemId]),
enabled,
queryFn: () => knowledgeApi.detail(props.itemId as number),
})
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { knowledgeApi } from '@/lib/api'
import { useCompanyStore } from '@/stores/company'
interface TreeNode {
title: string
@@ -29,8 +30,12 @@ const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
const companyStore = useCompanyStore()
const currentBrandId = computed(() => companyStore.currentBrandId)
const groupsQuery = useQuery({
queryKey: ['knowledge', 'groups', 'selector'],
queryKey: computed(() => ['knowledge', 'groups', 'selector', currentBrandId.value]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () => knowledgeApi.listGroups(),
})
+5
View File
@@ -355,6 +355,8 @@ const currentBrandScopedPathPatterns = [
/^\/api\/tenant\/enterprise-sites\/[^/]+\/publish(?:\/|$)/,
/^\/api\/tenant\/media-supply\/orders(?:\/|$)/,
/^\/api\/tenant\/prompt-rules(?:\/|$)/,
/^\/api\/tenant\/knowledge(?:\/|$)/,
/^\/api\/tenant\/images(?:\/|$)/,
]
function shouldAttachCurrentBrandHeader(url: unknown): boolean {
@@ -362,6 +364,9 @@ function shouldAttachCurrentBrandHeader(url: unknown): boolean {
return false
}
const pathname = normalizeRequestPath(url)
if (pathname === '/api/tenant/images/storage-usage') {
return false
}
return currentBrandScopedPathPatterns.some((pattern) => pattern.test(pathname))
}
+20 -2
View File
@@ -18,11 +18,14 @@ import { imagesApi } from '@/lib/api'
import { formatBytes, formatDateTime, formatPercentage } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress } from '@/lib/image-webp'
import { useCompanyStore } from '@/stores/company'
import type { ImageAssetItem, ImageFolder } from '@geo/shared-types'
const { t } = useI18n()
const queryClient = useQueryClient()
const imageUploadProgress = useImageUploadProgress()
const companyStore = useCompanyStore()
const currentBrandId = computed(() => companyStore.currentBrandId)
// State
const selectedFolderId = ref<number | null>(null) // null means "All" or is handled by folders query
@@ -51,7 +54,8 @@ const fileInput = ref<HTMLInputElement | null>(null)
// Queries
const foldersQuery = useQuery({
queryKey: ['images', 'folders'],
queryKey: computed(() => ['images', 'folders', currentBrandId.value]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () => imagesApi.listFolders(),
})
@@ -59,6 +63,7 @@ const imagesQuery = useQuery({
queryKey: computed(() => [
'images',
'list',
currentBrandId.value,
{
folder_id: debouncedSearch.value ? undefined : selectedFolderId.value,
q: debouncedSearch.value,
@@ -66,6 +71,7 @@ const imagesQuery = useQuery({
page_size: pageSize.value,
},
]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () =>
imagesApi.list({
folder_id: debouncedSearch.value ? undefined : (selectedFolderId.value ?? undefined),
@@ -76,7 +82,8 @@ const imagesQuery = useQuery({
})
const allImagesCountQuery = useQuery({
queryKey: ['images', 'list', 'all-count'],
queryKey: computed(() => ['images', 'list', 'all-count', currentBrandId.value]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () =>
imagesApi.list({
page: 1,
@@ -181,6 +188,17 @@ watch(searchKeyword, () => {
}, 300)
})
watch(currentBrandId, () => {
selectedFolderId.value = null
movingImage.value = null
previewingImage.value = null
renamingImage.value = null
moveModalOpen.value = false
previewModalOpen.value = false
renameImageModalOpen.value = false
currentPage.value = 1
})
function handleFolderSelect(id: number | null) {
if (debouncedSearch.value) return
selectedFolderId.value = id
+19 -2
View File
@@ -16,6 +16,7 @@ import KnowledgeDetailDrawer from '@/components/KnowledgeDetailDrawer.vue'
import { knowledgeApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
type KnowledgeSourceType = 'document' | 'website' | 'text'
type KnowledgeGroupLevel = 'root' | 'child'
@@ -35,6 +36,8 @@ interface TreeNode {
}
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const currentBrandId = computed(() => companyStore.currentBrandId)
const selectedGroupId = ref<number | null>(null)
const groupModalOpen = ref(false)
@@ -59,12 +62,14 @@ const urlsText = ref('')
const textItems = ref([{ name: '', content: '' }])
const groupsQuery = useQuery({
queryKey: ['knowledge', 'groups'],
queryKey: computed(() => ['knowledge', 'groups', currentBrandId.value]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () => knowledgeApi.listGroups(),
})
const itemsQuery = useQuery({
queryKey: computed(() => ['knowledge', 'items', selectedGroupId.value]),
queryKey: computed(() => ['knowledge', 'items', currentBrandId.value, selectedGroupId.value]),
enabled: computed(() => Boolean(currentBrandId.value)),
queryFn: () => knowledgeApi.listItems(selectedGroupId.value ?? undefined),
refetchInterval: (query) => {
const items = query.state.data as KnowledgeItem[] | undefined
@@ -267,6 +272,18 @@ const retryItemMutation = useMutation({
onError: (error) => message.error(formatError(error)),
})
watch(
currentBrandId,
() => {
selectedGroupId.value = null
groupModalOpen.value = false
editingGroup.value = null
itemModalOpen.value = false
previewItemId.value = null
previewDrawerOpen.value = false
},
)
watch(
() => groupsQuery.data.value,
(groups) => {
@@ -38,6 +38,7 @@ type VectorPoint struct {
type SearchRequest struct {
Vector []float64
TenantID int64
BrandID int64
GroupIDs []int64
Limit int
}
@@ -193,6 +194,9 @@ func (s *qdrantStore) Search(ctx context.Context, req SearchRequest) ([]SearchPo
sdk.NewMatchKeyword("status", "active"),
},
}
if req.BrandID > 0 {
filter.Must = append(filter.Must, sdk.NewMatchInt("brand_id", req.BrandID))
}
groupIDs := normalizeGroupIDs(req.GroupIDs)
if len(groupIDs) > 0 {
filter.Must = append(filter.Must, sdk.NewMatchInts("group_id", groupIDs...))
@@ -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),
)
@@ -15,24 +15,31 @@ const countImageAssets = `-- name: CountImageAssets :one
SELECT COUNT(*)::INT AS total
FROM image_assets
WHERE tenant_id = $1
AND brand_id = $2
AND status = 'active'
AND deleted_at IS NULL
AND (
$2::BIGINT IS NULL
OR ($2::BIGINT = 0 AND folder_id IS NULL)
OR folder_id = $2::BIGINT
$3::BIGINT IS NULL
OR ($3::BIGINT = 0 AND folder_id IS NULL)
OR folder_id = $3::BIGINT
)
AND ($3::TEXT IS NULL OR name ILIKE '%' || $3::TEXT || '%')
AND ($4::TEXT IS NULL OR name ILIKE '%' || $4::TEXT || '%')
`
type CountImageAssetsParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
SearchQuery pgtype.Text `json:"search_query"`
}
func (q *Queries) CountImageAssets(ctx context.Context, arg CountImageAssetsParams) (int32, error) {
row := q.db.QueryRow(ctx, countImageAssets, arg.TenantID, arg.FolderID, arg.SearchQuery)
row := q.db.QueryRow(ctx, countImageAssets,
arg.TenantID,
arg.BrandID,
arg.FolderID,
arg.SearchQuery,
)
var total int32
err := row.Scan(&total)
return total, err
@@ -42,32 +49,42 @@ const countUncategorizedImages = `-- name: CountUncategorizedImages :one
SELECT COUNT(*)::INT AS count
FROM image_assets
WHERE tenant_id = $1
AND brand_id = $2
AND folder_id IS NULL
AND status = 'active'
AND deleted_at IS NULL
`
func (q *Queries) CountUncategorizedImages(ctx context.Context, tenantID int64) (int32, error) {
row := q.db.QueryRow(ctx, countUncategorizedImages, tenantID)
type CountUncategorizedImagesParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) CountUncategorizedImages(ctx context.Context, arg CountUncategorizedImagesParams) (int32, error) {
row := q.db.QueryRow(ctx, countUncategorizedImages, arg.TenantID, arg.BrandID)
var count int32
err := row.Scan(&count)
return count, err
}
const getImageAsset = `-- name: GetImageAsset :one
SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
SELECT id, tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
FROM image_assets
WHERE id = $1 AND tenant_id = $2
WHERE id = $1
AND tenant_id = $2
AND brand_id = $3
`
type GetImageAssetParams struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
type GetImageAssetRow struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
ObjectKey string `json:"object_key"`
Name string `json:"name"`
@@ -79,11 +96,12 @@ type GetImageAssetRow struct {
}
func (q *Queries) GetImageAsset(ctx context.Context, arg GetImageAssetParams) (GetImageAssetRow, error) {
row := q.db.QueryRow(ctx, getImageAsset, arg.ID, arg.TenantID)
row := q.db.QueryRow(ctx, getImageAsset, arg.ID, arg.TenantID, arg.BrandID)
var i GetImageAssetRow
err := row.Scan(
&i.ID,
&i.TenantID,
&i.BrandID,
&i.FolderID,
&i.ObjectKey,
&i.Name,
@@ -97,13 +115,14 @@ func (q *Queries) GetImageAsset(ctx context.Context, arg GetImageAssetParams) (G
}
const insertImageAsset = `-- name: InsertImageAsset :one
INSERT INTO image_assets (tenant_id, folder_id, object_key, name, size_bytes, status, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO image_assets (tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`
type InsertImageAssetParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
ObjectKey string `json:"object_key"`
Name string `json:"name"`
@@ -115,6 +134,7 @@ type InsertImageAssetParams struct {
func (q *Queries) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) {
row := q.db.QueryRow(ctx, insertImageAsset,
arg.TenantID,
arg.BrandID,
arg.FolderID,
arg.ObjectKey,
arg.Name,
@@ -131,13 +151,15 @@ const listActiveImagesByFolder = `-- name: ListActiveImagesByFolder :many
SELECT id, object_key, size_bytes
FROM image_assets
WHERE tenant_id = $1
AND folder_id = $2
AND brand_id = $2
AND folder_id = $3
AND status = 'active'
AND deleted_at IS NULL
`
type ListActiveImagesByFolderParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
}
@@ -148,7 +170,7 @@ type ListActiveImagesByFolderRow struct {
}
func (q *Queries) ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error) {
rows, err := q.db.Query(ctx, listActiveImagesByFolder, arg.TenantID, arg.FolderID)
rows, err := q.db.Query(ctx, listActiveImagesByFolder, arg.TenantID, arg.BrandID, arg.FolderID)
if err != nil {
return nil, err
}
@@ -168,23 +190,25 @@ func (q *Queries) ListActiveImagesByFolder(ctx context.Context, arg ListActiveIm
}
const listImageAssets = `-- name: ListImageAssets :many
SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
SELECT id, tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
FROM image_assets
WHERE tenant_id = $1
AND brand_id = $2
AND status = 'active'
AND deleted_at IS NULL
AND (
$2::BIGINT IS NULL
OR ($2::BIGINT = 0 AND folder_id IS NULL)
OR folder_id = $2::BIGINT
$3::BIGINT IS NULL
OR ($3::BIGINT = 0 AND folder_id IS NULL)
OR folder_id = $3::BIGINT
)
AND ($3::TEXT IS NULL OR name ILIKE '%' || $3::TEXT || '%')
AND ($4::TEXT IS NULL OR name ILIKE '%' || $4::TEXT || '%')
ORDER BY created_at DESC
LIMIT $5 OFFSET $4
LIMIT $6 OFFSET $5
`
type ListImageAssetsParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
SearchQuery pgtype.Text `json:"search_query"`
PageOffset int32 `json:"page_offset"`
@@ -194,6 +218,7 @@ type ListImageAssetsParams struct {
type ListImageAssetsRow struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
ObjectKey string `json:"object_key"`
Name string `json:"name"`
@@ -207,6 +232,7 @@ type ListImageAssetsRow struct {
func (q *Queries) ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error) {
rows, err := q.db.Query(ctx, listImageAssets,
arg.TenantID,
arg.BrandID,
arg.FolderID,
arg.SearchQuery,
arg.PageOffset,
@@ -222,6 +248,7 @@ func (q *Queries) ListImageAssets(ctx context.Context, arg ListImageAssetsParams
if err := rows.Scan(
&i.ID,
&i.TenantID,
&i.BrandID,
&i.FolderID,
&i.ObjectKey,
&i.Name,
@@ -245,84 +272,113 @@ const markImagesInFolderPendingDelete = `-- name: MarkImagesInFolderPendingDelet
UPDATE image_assets
SET status = 'pending_delete', folder_id = NULL, updated_at = NOW()
WHERE tenant_id = $1
AND folder_id = $2
AND brand_id = $2
AND folder_id = $3
AND status = 'active'
AND deleted_at IS NULL
`
type MarkImagesInFolderPendingDeleteParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
}
func (q *Queries) MarkImagesInFolderPendingDelete(ctx context.Context, arg MarkImagesInFolderPendingDeleteParams) error {
_, err := q.db.Exec(ctx, markImagesInFolderPendingDelete, arg.TenantID, arg.FolderID)
_, err := q.db.Exec(ctx, markImagesInFolderPendingDelete, arg.TenantID, arg.BrandID, arg.FolderID)
return err
}
const updateImageAssetDeleted = `-- name: UpdateImageAssetDeleted :exec
UPDATE image_assets
SET status = 'deleted', deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND tenant_id = $2
WHERE id = $1
AND tenant_id = $2
AND brand_id = $3
`
type UpdateImageAssetDeletedParams struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) UpdateImageAssetDeleted(ctx context.Context, arg UpdateImageAssetDeletedParams) error {
_, err := q.db.Exec(ctx, updateImageAssetDeleted, arg.ID, arg.TenantID)
_, err := q.db.Exec(ctx, updateImageAssetDeleted, arg.ID, arg.TenantID, arg.BrandID)
return err
}
const updateImageAssetFolder = `-- name: UpdateImageAssetFolder :exec
UPDATE image_assets
SET folder_id = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
WHERE id = $2
AND tenant_id = $3
AND brand_id = $4
`
type UpdateImageAssetFolderParams struct {
FolderID pgtype.Int8 `json:"folder_id"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) UpdateImageAssetFolder(ctx context.Context, arg UpdateImageAssetFolderParams) error {
_, err := q.db.Exec(ctx, updateImageAssetFolder, arg.FolderID, arg.ID, arg.TenantID)
_, err := q.db.Exec(ctx, updateImageAssetFolder,
arg.FolderID,
arg.ID,
arg.TenantID,
arg.BrandID,
)
return err
}
const updateImageAssetName = `-- name: UpdateImageAssetName :exec
UPDATE image_assets
SET name = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
WHERE id = $2
AND tenant_id = $3
AND brand_id = $4
`
type UpdateImageAssetNameParams struct {
Name string `json:"name"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) UpdateImageAssetName(ctx context.Context, arg UpdateImageAssetNameParams) error {
_, err := q.db.Exec(ctx, updateImageAssetName, arg.Name, arg.ID, arg.TenantID)
_, err := q.db.Exec(ctx, updateImageAssetName,
arg.Name,
arg.ID,
arg.TenantID,
arg.BrandID,
)
return err
}
const updateImageAssetStatus = `-- name: UpdateImageAssetStatus :exec
UPDATE image_assets
SET status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
WHERE id = $2
AND tenant_id = $3
AND brand_id = $4
`
type UpdateImageAssetStatusParams struct {
Status string `json:"status"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) UpdateImageAssetStatus(ctx context.Context, arg UpdateImageAssetStatusParams) error {
_, err := q.db.Exec(ctx, updateImageAssetStatus, arg.Status, arg.ID, arg.TenantID)
_, err := q.db.Exec(ctx, updateImageAssetStatus,
arg.Status,
arg.ID,
arg.TenantID,
arg.BrandID,
)
return err
}
@@ -15,36 +15,39 @@ const countActiveImagesInFolder = `-- name: CountActiveImagesInFolder :one
SELECT COUNT(*)::INT AS count
FROM image_assets
WHERE tenant_id = $1
AND folder_id = $2
AND brand_id = $2
AND folder_id = $3
AND status = 'active'
AND deleted_at IS NULL
`
type CountActiveImagesInFolderParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
FolderID pgtype.Int8 `json:"folder_id"`
}
func (q *Queries) CountActiveImagesInFolder(ctx context.Context, arg CountActiveImagesInFolderParams) (int32, error) {
row := q.db.QueryRow(ctx, countActiveImagesInFolder, arg.TenantID, arg.FolderID)
row := q.db.QueryRow(ctx, countActiveImagesInFolder, arg.TenantID, arg.BrandID, arg.FolderID)
var count int32
err := row.Scan(&count)
return count, err
}
const createImageFolder = `-- name: CreateImageFolder :one
INSERT INTO image_folders (tenant_id, name)
VALUES ($1, $2)
INSERT INTO image_folders (tenant_id, brand_id, name)
VALUES ($1, $2, $3)
RETURNING id
`
type CreateImageFolderParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
Name string `json:"name"`
}
func (q *Queries) CreateImageFolder(ctx context.Context, arg CreateImageFolderParams) (int64, error) {
row := q.db.QueryRow(ctx, createImageFolder, arg.TenantID, arg.Name)
row := q.db.QueryRow(ctx, createImageFolder, arg.TenantID, arg.BrandID, arg.Name)
var id int64
err := row.Scan(&id)
return id, err
@@ -52,44 +55,52 @@ func (q *Queries) CreateImageFolder(ctx context.Context, arg CreateImageFolderPa
const deleteImageFolder = `-- name: DeleteImageFolder :exec
DELETE FROM image_folders
WHERE id = $1 AND tenant_id = $2
WHERE id = $1
AND tenant_id = $2
AND brand_id = $3
`
type DeleteImageFolderParams struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) DeleteImageFolder(ctx context.Context, arg DeleteImageFolderParams) error {
_, err := q.db.Exec(ctx, deleteImageFolder, arg.ID, arg.TenantID)
_, err := q.db.Exec(ctx, deleteImageFolder, arg.ID, arg.TenantID, arg.BrandID)
return err
}
const getImageFolder = `-- name: GetImageFolder :one
SELECT id, tenant_id, name, created_at, updated_at
SELECT id, tenant_id, brand_id, name, created_at, updated_at
FROM image_folders
WHERE id = $1 AND tenant_id = $2
WHERE id = $1
AND tenant_id = $2
AND brand_id = $3
`
type GetImageFolderParams struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
type GetImageFolderRow struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetImageFolder(ctx context.Context, arg GetImageFolderParams) (GetImageFolderRow, error) {
row := q.db.QueryRow(ctx, getImageFolder, arg.ID, arg.TenantID)
row := q.db.QueryRow(ctx, getImageFolder, arg.ID, arg.TenantID, arg.BrandID)
var i GetImageFolderRow
err := row.Scan(
&i.ID,
&i.TenantID,
&i.BrandID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
@@ -98,26 +109,33 @@ func (q *Queries) GetImageFolder(ctx context.Context, arg GetImageFolderParams)
}
const listImageFolders = `-- name: ListImageFolders :many
SELECT f.id, f.tenant_id, f.name, f.created_at, f.updated_at,
SELECT f.id, f.tenant_id, f.brand_id, f.name, f.created_at, f.updated_at,
COUNT(ia.id) FILTER (WHERE ia.status = 'active' AND ia.deleted_at IS NULL)::INT AS image_count
FROM image_folders f
LEFT JOIN image_assets ia ON ia.tenant_id = f.tenant_id AND ia.folder_id = f.id
LEFT JOIN image_assets ia ON ia.tenant_id = f.tenant_id AND ia.brand_id = f.brand_id AND ia.folder_id = f.id
WHERE f.tenant_id = $1
AND f.brand_id = $2
GROUP BY f.id
ORDER BY f.name
`
type ListImageFoldersParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
type ListImageFoldersRow struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageCount int32 `json:"image_count"`
}
func (q *Queries) ListImageFolders(ctx context.Context, tenantID int64) ([]ListImageFoldersRow, error) {
rows, err := q.db.Query(ctx, listImageFolders, tenantID)
func (q *Queries) ListImageFolders(ctx context.Context, arg ListImageFoldersParams) ([]ListImageFoldersRow, error) {
rows, err := q.db.Query(ctx, listImageFolders, arg.TenantID, arg.BrandID)
if err != nil {
return nil, err
}
@@ -128,6 +146,7 @@ func (q *Queries) ListImageFolders(ctx context.Context, tenantID int64) ([]ListI
if err := rows.Scan(
&i.ID,
&i.TenantID,
&i.BrandID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
@@ -146,16 +165,24 @@ func (q *Queries) ListImageFolders(ctx context.Context, tenantID int64) ([]ListI
const updateImageFolder = `-- name: UpdateImageFolder :exec
UPDATE image_folders
SET name = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
WHERE id = $2
AND tenant_id = $3
AND brand_id = $4
`
type UpdateImageFolderParams struct {
Name string `json:"name"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
}
func (q *Queries) UpdateImageFolder(ctx context.Context, arg UpdateImageFolderParams) error {
_, err := q.db.Exec(ctx, updateImageFolder, arg.Name, arg.ID, arg.TenantID)
_, err := q.db.Exec(ctx, updateImageFolder,
arg.Name,
arg.ID,
arg.TenantID,
arg.BrandID,
)
return err
}
@@ -460,6 +460,7 @@ type ImageAsset struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
ContentHash pgtype.Text `json:"content_hash"`
BrandID int64 `json:"brand_id"`
}
type ImageAssetReference struct {
@@ -478,6 +479,7 @@ type ImageFolder struct {
Name string `json:"name"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
BrandID int64 `json:"brand_id"`
}
type KnowledgeChunksMetum struct {
@@ -517,6 +519,7 @@ type KnowledgeGroup struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
BrandID int64 `json:"brand_id"`
}
type KnowledgeItem struct {
@@ -536,6 +539,7 @@ type KnowledgeItem struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
MarkdownContent pgtype.Text `json:"markdown_content"`
BrandID int64 `json:"brand_id"`
}
type KnowledgeParseTask struct {
@@ -28,7 +28,7 @@ type Querier interface {
CountPromptRulesUngrouped(ctx context.Context, arg CountPromptRulesUngroupedParams) (int64, error)
CountPublishedArticlesByTenantAndBrand(ctx context.Context, arg CountPublishedArticlesByTenantAndBrandParams) (int64, error)
CountScheduleTasks(ctx context.Context, arg CountScheduleTasksParams) (int64, error)
CountUncategorizedImages(ctx context.Context, tenantID int64) (int32, error)
CountUncategorizedImages(ctx context.Context, arg CountUncategorizedImagesParams) (int32, error)
CreateArticle(ctx context.Context, arg CreateArticleParams) (int64, error)
CreateArticleVersion(ctx context.Context, arg CreateArticleVersionParams) (int64, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) error
@@ -121,7 +121,7 @@ type Querier interface {
ListDesktopClientsByUser(ctx context.Context, arg ListDesktopClientsByUserParams) ([]DesktopClient, error)
ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error)
ListImageFolders(ctx context.Context, tenantID int64) ([]ListImageFoldersRow, error)
ListImageFolders(ctx context.Context, arg ListImageFoldersParams) ([]ListImageFoldersRow, error)
ListImageReferenceArticles(ctx context.Context, arg ListImageReferenceArticlesParams) ([]ListImageReferenceArticlesRow, error)
ListImageReferences(ctx context.Context, arg ListImageReferencesParams) ([]ListImageReferencesRow, error)
ListKeywords(ctx context.Context, arg ListKeywordsParams) ([]ListKeywordsRow, error)
@@ -10,6 +10,7 @@ import (
type ImageFolder struct {
ID int64
TenantID int64
BrandID int64
Name string
ImageCount int
CreatedAt time.Time
@@ -17,12 +18,12 @@ type ImageFolder struct {
}
type ImageFolderRepository interface {
List(ctx context.Context, tenantID int64) ([]ImageFolder, error)
Get(ctx context.Context, tenantID, id int64) (*ImageFolder, error)
Create(ctx context.Context, tenantID int64, name string) (int64, error)
Update(ctx context.Context, tenantID, id int64, name string) error
Delete(ctx context.Context, tenantID, id int64) error
CountActiveImages(ctx context.Context, tenantID, folderID int64) (int, error)
List(ctx context.Context, tenantID, brandID int64) ([]ImageFolder, error)
Get(ctx context.Context, tenantID, brandID, id int64) (*ImageFolder, error)
Create(ctx context.Context, tenantID, brandID int64, name string) (int64, error)
Update(ctx context.Context, tenantID, brandID, id int64, name string) error
Delete(ctx context.Context, tenantID, brandID, id int64) error
CountActiveImages(ctx context.Context, tenantID, brandID, folderID int64) (int, error)
}
type imageFolderRepository struct {
@@ -33,8 +34,11 @@ func NewImageFolderRepository(db generated.DBTX) ImageFolderRepository {
return &imageFolderRepository{q: newQuerier(db)}
}
func (r *imageFolderRepository) List(ctx context.Context, tenantID int64) ([]ImageFolder, error) {
rows, err := r.q.ListImageFolders(ctx, tenantID)
func (r *imageFolderRepository) List(ctx context.Context, tenantID, brandID int64) ([]ImageFolder, error) {
rows, err := r.q.ListImageFolders(ctx, generated.ListImageFoldersParams{
TenantID: tenantID,
BrandID: brandID,
})
if err != nil {
return nil, err
}
@@ -44,6 +48,7 @@ func (r *imageFolderRepository) List(ctx context.Context, tenantID int64) ([]Ima
folders[i] = ImageFolder{
ID: row.ID,
TenantID: row.TenantID,
BrandID: row.BrandID,
Name: row.Name,
ImageCount: int(row.ImageCount),
CreatedAt: timeFromTimestamp(row.CreatedAt),
@@ -53,10 +58,11 @@ func (r *imageFolderRepository) List(ctx context.Context, tenantID int64) ([]Ima
return folders, nil
}
func (r *imageFolderRepository) Get(ctx context.Context, tenantID, id int64) (*ImageFolder, error) {
func (r *imageFolderRepository) Get(ctx context.Context, tenantID, brandID, id int64) (*ImageFolder, error) {
row, err := r.q.GetImageFolder(ctx, generated.GetImageFolderParams{
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
if err != nil {
return nil, err
@@ -65,37 +71,42 @@ func (r *imageFolderRepository) Get(ctx context.Context, tenantID, id int64) (*I
return &ImageFolder{
ID: row.ID,
TenantID: row.TenantID,
BrandID: row.BrandID,
Name: row.Name,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
func (r *imageFolderRepository) Create(ctx context.Context, tenantID int64, name string) (int64, error) {
func (r *imageFolderRepository) Create(ctx context.Context, tenantID, brandID int64, name string) (int64, error) {
return r.q.CreateImageFolder(ctx, generated.CreateImageFolderParams{
TenantID: tenantID,
BrandID: brandID,
Name: name,
})
}
func (r *imageFolderRepository) Update(ctx context.Context, tenantID, id int64, name string) error {
func (r *imageFolderRepository) Update(ctx context.Context, tenantID, brandID, id int64, name string) error {
return r.q.UpdateImageFolder(ctx, generated.UpdateImageFolderParams{
Name: name,
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
}
func (r *imageFolderRepository) Delete(ctx context.Context, tenantID, id int64) error {
func (r *imageFolderRepository) Delete(ctx context.Context, tenantID, brandID, id int64) error {
return r.q.DeleteImageFolder(ctx, generated.DeleteImageFolderParams{
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
}
func (r *imageFolderRepository) CountActiveImages(ctx context.Context, tenantID, folderID int64) (int, error) {
func (r *imageFolderRepository) CountActiveImages(ctx context.Context, tenantID, brandID, folderID int64) (int, error) {
count, err := r.q.CountActiveImagesInFolder(ctx, generated.CountActiveImagesInFolderParams{
TenantID: tenantID,
BrandID: brandID,
FolderID: pgInt8(&folderID),
})
if err != nil {
+35 -17
View File
@@ -10,6 +10,7 @@ import (
type ImageAsset struct {
ID int64
TenantID int64
BrandID int64
FolderID *int64
ObjectKey string
Name string
@@ -22,6 +23,7 @@ type ImageAsset struct {
type ImageAssetListParams struct {
TenantID int64
BrandID int64
FolderID *int64
SearchQuery *string
PageSize int
@@ -30,6 +32,7 @@ type ImageAssetListParams struct {
type InsertImageAssetInput struct {
TenantID int64
BrandID int64
FolderID *int64
ObjectKey string
Name string
@@ -46,15 +49,15 @@ type FolderImageSummary struct {
type ImageRepository interface {
List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error)
Get(ctx context.Context, tenantID, id int64) (*ImageAsset, error)
Get(ctx context.Context, tenantID, brandID, id int64) (*ImageAsset, error)
Insert(ctx context.Context, input InsertImageAssetInput) (int64, error)
UpdateStatus(ctx context.Context, tenantID, id int64, status string) error
MarkDeleted(ctx context.Context, tenantID, id int64) error
UpdateName(ctx context.Context, tenantID, id int64, name string) error
UpdateFolder(ctx context.Context, tenantID, id int64, folderID *int64) error
ListActiveByFolder(ctx context.Context, tenantID, folderID int64) ([]FolderImageSummary, error)
MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, folderID int64) error
CountUncategorized(ctx context.Context, tenantID int64) (int, error)
UpdateStatus(ctx context.Context, tenantID, brandID, id int64, status string) error
MarkDeleted(ctx context.Context, tenantID, brandID, id int64) error
UpdateName(ctx context.Context, tenantID, brandID, id int64, name string) error
UpdateFolder(ctx context.Context, tenantID, brandID, id int64, folderID *int64) error
ListActiveByFolder(ctx context.Context, tenantID, brandID, folderID int64) ([]FolderImageSummary, error)
MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, brandID, folderID int64) error
CountUncategorized(ctx context.Context, tenantID, brandID int64) (int, error)
}
type imageRepository struct {
@@ -68,6 +71,7 @@ func NewImageRepository(db generated.DBTX) ImageRepository {
func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error) {
listParams := generated.ListImageAssetsParams{
TenantID: params.TenantID,
BrandID: params.BrandID,
FolderID: pgInt8(params.FolderID),
PageOffset: int32(params.PageOffset),
PageSize: int32(params.PageSize),
@@ -81,6 +85,7 @@ func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams)
total, err := r.q.CountImageAssets(ctx, generated.CountImageAssetsParams{
TenantID: params.TenantID,
BrandID: params.BrandID,
FolderID: listParams.FolderID,
SearchQuery: listParams.SearchQuery,
})
@@ -93,6 +98,7 @@ func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams)
assets[i] = ImageAsset{
ID: row.ID,
TenantID: row.TenantID,
BrandID: row.BrandID,
FolderID: nullableInt64(row.FolderID),
ObjectKey: row.ObjectKey,
Name: row.Name,
@@ -107,10 +113,11 @@ func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams)
return assets, int(total), nil
}
func (r *imageRepository) Get(ctx context.Context, tenantID, id int64) (*ImageAsset, error) {
func (r *imageRepository) Get(ctx context.Context, tenantID, brandID, id int64) (*ImageAsset, error) {
row, err := r.q.GetImageAsset(ctx, generated.GetImageAssetParams{
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
if err != nil {
return nil, err
@@ -119,6 +126,7 @@ func (r *imageRepository) Get(ctx context.Context, tenantID, id int64) (*ImageAs
return &ImageAsset{
ID: row.ID,
TenantID: row.TenantID,
BrandID: row.BrandID,
FolderID: nullableInt64(row.FolderID),
ObjectKey: row.ObjectKey,
Name: row.Name,
@@ -133,6 +141,7 @@ func (r *imageRepository) Get(ctx context.Context, tenantID, id int64) (*ImageAs
func (r *imageRepository) Insert(ctx context.Context, input InsertImageAssetInput) (int64, error) {
return r.q.InsertImageAsset(ctx, generated.InsertImageAssetParams{
TenantID: input.TenantID,
BrandID: input.BrandID,
FolderID: pgInt8(input.FolderID),
ObjectKey: input.ObjectKey,
Name: input.Name,
@@ -142,40 +151,45 @@ func (r *imageRepository) Insert(ctx context.Context, input InsertImageAssetInpu
})
}
func (r *imageRepository) UpdateStatus(ctx context.Context, tenantID, id int64, status string) error {
func (r *imageRepository) UpdateStatus(ctx context.Context, tenantID, brandID, id int64, status string) error {
return r.q.UpdateImageAssetStatus(ctx, generated.UpdateImageAssetStatusParams{
Status: status,
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
}
func (r *imageRepository) MarkDeleted(ctx context.Context, tenantID, id int64) error {
func (r *imageRepository) MarkDeleted(ctx context.Context, tenantID, brandID, id int64) error {
return r.q.UpdateImageAssetDeleted(ctx, generated.UpdateImageAssetDeletedParams{
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
}
func (r *imageRepository) UpdateName(ctx context.Context, tenantID, id int64, name string) error {
func (r *imageRepository) UpdateName(ctx context.Context, tenantID, brandID, id int64, name string) error {
return r.q.UpdateImageAssetName(ctx, generated.UpdateImageAssetNameParams{
Name: name,
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
}
func (r *imageRepository) UpdateFolder(ctx context.Context, tenantID, id int64, folderID *int64) error {
func (r *imageRepository) UpdateFolder(ctx context.Context, tenantID, brandID, id int64, folderID *int64) error {
return r.q.UpdateImageAssetFolder(ctx, generated.UpdateImageAssetFolderParams{
FolderID: pgInt8(folderID),
ID: id,
TenantID: tenantID,
BrandID: brandID,
})
}
func (r *imageRepository) ListActiveByFolder(ctx context.Context, tenantID, folderID int64) ([]FolderImageSummary, error) {
func (r *imageRepository) ListActiveByFolder(ctx context.Context, tenantID, brandID, folderID int64) ([]FolderImageSummary, error) {
rows, err := r.q.ListActiveImagesByFolder(ctx, generated.ListActiveImagesByFolderParams{
TenantID: tenantID,
BrandID: brandID,
FolderID: pgInt8(&folderID),
})
if err != nil {
@@ -193,15 +207,19 @@ func (r *imageRepository) ListActiveByFolder(ctx context.Context, tenantID, fold
return result, nil
}
func (r *imageRepository) MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, folderID int64) error {
func (r *imageRepository) MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, brandID, folderID int64) error {
return r.q.MarkImagesInFolderPendingDelete(ctx, generated.MarkImagesInFolderPendingDeleteParams{
TenantID: tenantID,
BrandID: brandID,
FolderID: pgInt8(&folderID),
})
}
func (r *imageRepository) CountUncategorized(ctx context.Context, tenantID int64) (int, error) {
count, err := r.q.CountUncategorizedImages(ctx, tenantID)
func (r *imageRepository) CountUncategorized(ctx context.Context, tenantID, brandID int64) (int, error) {
count, err := r.q.CountUncategorizedImages(ctx, generated.CountUncategorizedImagesParams{
TenantID: tenantID,
BrandID: brandID,
})
if err != nil {
return 0, err
}
@@ -1,7 +1,8 @@
-- name: ListImageAssets :many
SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
SELECT id, tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id)
AND status = 'active'
AND deleted_at IS NULL
AND (
@@ -17,6 +18,7 @@ LIMIT sqlc.arg(page_size) OFFSET sqlc.arg(page_offset);
SELECT COUNT(*)::INT AS total
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id)
AND status = 'active'
AND deleted_at IS NULL
AND (
@@ -27,39 +29,50 @@ WHERE tenant_id = sqlc.arg(tenant_id)
AND (sqlc.narg(search_query)::TEXT IS NULL OR name ILIKE '%' || sqlc.narg(search_query)::TEXT || '%');
-- name: GetImageAsset :one
SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
SELECT id, tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
FROM image_assets
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: InsertImageAsset :one
INSERT INTO image_assets (tenant_id, folder_id, object_key, name, size_bytes, status, created_by)
VALUES (sqlc.arg(tenant_id), sqlc.narg(folder_id), sqlc.arg(object_key), sqlc.arg(name), sqlc.arg(size_bytes), sqlc.arg(status), sqlc.arg(created_by))
INSERT INTO image_assets (tenant_id, brand_id, folder_id, object_key, name, size_bytes, status, created_by)
VALUES (sqlc.arg(tenant_id), sqlc.arg(brand_id), sqlc.narg(folder_id), sqlc.arg(object_key), sqlc.arg(name), sqlc.arg(size_bytes), sqlc.arg(status), sqlc.arg(created_by))
RETURNING id;
-- name: UpdateImageAssetStatus :exec
UPDATE image_assets
SET status = sqlc.arg(status), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: UpdateImageAssetDeleted :exec
UPDATE image_assets
SET status = 'deleted', deleted_at = NOW(), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: UpdateImageAssetName :exec
UPDATE image_assets
SET name = sqlc.arg(name), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: UpdateImageAssetFolder :exec
UPDATE image_assets
SET folder_id = sqlc.narg(folder_id), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: ListActiveImagesByFolder :many
SELECT id, object_key, size_bytes
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id)
AND folder_id = sqlc.arg(folder_id)
AND status = 'active'
AND deleted_at IS NULL;
@@ -68,6 +81,7 @@ WHERE tenant_id = sqlc.arg(tenant_id)
UPDATE image_assets
SET status = 'pending_delete', folder_id = NULL, updated_at = NOW()
WHERE tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id)
AND folder_id = sqlc.arg(folder_id)
AND status = 'active'
AND deleted_at IS NULL;
@@ -76,6 +90,7 @@ WHERE tenant_id = sqlc.arg(tenant_id)
SELECT COUNT(*)::INT AS count
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id)
AND folder_id IS NULL
AND status = 'active'
AND deleted_at IS NULL;
@@ -1,35 +1,43 @@
-- name: ListImageFolders :many
SELECT f.id, f.tenant_id, f.name, f.created_at, f.updated_at,
SELECT f.id, f.tenant_id, f.brand_id, f.name, f.created_at, f.updated_at,
COUNT(ia.id) FILTER (WHERE ia.status = 'active' AND ia.deleted_at IS NULL)::INT AS image_count
FROM image_folders f
LEFT JOIN image_assets ia ON ia.tenant_id = f.tenant_id AND ia.folder_id = f.id
LEFT JOIN image_assets ia ON ia.tenant_id = f.tenant_id AND ia.brand_id = f.brand_id AND ia.folder_id = f.id
WHERE f.tenant_id = sqlc.arg(tenant_id)
AND f.brand_id = sqlc.arg(brand_id)
GROUP BY f.id
ORDER BY f.name;
-- name: CreateImageFolder :one
INSERT INTO image_folders (tenant_id, name)
VALUES (sqlc.arg(tenant_id), sqlc.arg(name))
INSERT INTO image_folders (tenant_id, brand_id, name)
VALUES (sqlc.arg(tenant_id), sqlc.arg(brand_id), sqlc.arg(name))
RETURNING id;
-- name: UpdateImageFolder :exec
UPDATE image_folders
SET name = sqlc.arg(name), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: GetImageFolder :one
SELECT id, tenant_id, name, created_at, updated_at
SELECT id, tenant_id, brand_id, name, created_at, updated_at
FROM image_folders
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: DeleteImageFolder :exec
DELETE FROM image_folders
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
WHERE id = sqlc.arg(id)
AND tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id);
-- name: CountActiveImagesInFolder :one
SELECT COUNT(*)::INT AS count
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND brand_id = sqlc.arg(brand_id)
AND folder_id = sqlc.arg(folder_id)
AND status = 'active'
AND deleted_at IS NULL;
+4 -1
View File
@@ -268,6 +268,7 @@ func RegisterRoutes(app *bootstrap.App) {
monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow)
knowledge := tenantProtected.Group("/knowledge")
knowledge.Use(middleware.RequireCurrentBrand())
knowledgeHandler := NewKnowledgeHandler(app)
knowledge.GET("/groups", knowledgeHandler.ListGroups)
knowledge.POST("/groups", knowledgeHandler.CreateGroup)
@@ -315,14 +316,16 @@ func RegisterRoutes(app *bootstrap.App) {
images := tenantProtected.Group("/images")
imgHandler := NewImageHandler(app)
images.GET("/storage-usage", imgHandler.StorageUsage)
images.Use(middleware.RequireCurrentBrand())
images.GET("", imgHandler.ListImages)
images.POST("", imgHandler.UploadImage)
images.PUT("/:id", imgHandler.UpdateImage)
images.GET("/:id/references", imgHandler.GetImageReferences)
images.DELETE("/:id", imgHandler.DeleteImage)
images.GET("/storage-usage", imgHandler.StorageUsage)
imageFolders := tenantProtected.Group("/images/folders")
imageFolders.Use(middleware.RequireCurrentBrand())
imageFolders.GET("", imgHandler.ListFolders)
imageFolders.POST("", imgHandler.CreateFolder)
imageFolders.PUT("/:id", imgHandler.UpdateFolder)
@@ -125,6 +125,7 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
knowledgeContext, err := w.knowledge.ResolveContext(
ctx,
task.TenantID,
int64(kolIntInput(task.InputParams, "brand_id")),
knowledgeGroupIDs,
buildKolKnowledgeQuery(task.InputParams),
)
@@ -0,0 +1,71 @@
ALTER TABLE image_assets
DROP CONSTRAINT IF EXISTS fk_image_assets_folder_brand;
ALTER TABLE image_folders
DROP CONSTRAINT IF EXISTS uq_image_folders_tenant_brand_name;
ALTER TABLE image_folders
DROP CONSTRAINT IF EXISTS uq_image_folders_tenant_brand_id;
DROP INDEX IF EXISTS idx_image_folders_tenant_brand;
DROP INDEX IF EXISTS idx_image_assets_tenant_brand_folder;
DROP INDEX IF EXISTS idx_image_assets_tenant_brand_created;
DROP INDEX IF EXISTS idx_image_assets_tenant_brand_status;
ALTER TABLE image_assets
DROP COLUMN IF EXISTS brand_id;
ALTER TABLE image_folders
DROP COLUMN IF EXISTS brand_id;
ALTER TABLE image_folders
ADD CONSTRAINT uq_image_folders_tenant_name UNIQUE (tenant_id, name);
ALTER TABLE image_folders
ADD CONSTRAINT uq_image_folders_tenant_id UNIQUE (tenant_id, id);
ALTER TABLE image_assets
ADD CONSTRAINT fk_image_assets_folder
FOREIGN KEY (tenant_id, folder_id)
REFERENCES image_folders(tenant_id, id);
CREATE INDEX IF NOT EXISTS idx_image_folders_tenant ON image_folders (tenant_id);
CREATE INDEX IF NOT EXISTS idx_image_assets_tenant_folder
ON image_assets (tenant_id, folder_id)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_image_assets_tenant_created
ON image_assets (tenant_id, created_at DESC)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_image_assets_tenant_status
ON image_assets (tenant_id, status, created_at DESC);
ALTER TABLE knowledge_groups
DROP CONSTRAINT IF EXISTS fk_knowledge_groups_parent_brand;
ALTER TABLE knowledge_items
DROP CONSTRAINT IF EXISTS fk_knowledge_items_group_brand;
ALTER TABLE knowledge_groups
DROP CONSTRAINT IF EXISTS uq_knowledge_groups_tenant_brand_id;
DROP INDEX IF EXISTS uk_knowledge_group_brand_name;
DROP INDEX IF EXISTS idx_knowledge_group_tenant_brand_parent;
DROP INDEX IF EXISTS idx_knowledge_item_tenant_brand_group_status;
ALTER TABLE knowledge_items
DROP COLUMN IF EXISTS brand_id;
ALTER TABLE knowledge_groups
DROP COLUMN IF EXISTS brand_id;
CREATE UNIQUE INDEX IF NOT EXISTS uk_knowledge_group_name
ON knowledge_groups(tenant_id, COALESCE(parent_id, 0), name)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_knowledge_group_tenant_parent
ON knowledge_groups(tenant_id, parent_id, sort_order)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_knowledge_item_group_status
ON knowledge_items(tenant_id, group_id, status)
WHERE deleted_at IS NULL;
@@ -0,0 +1,282 @@
ALTER TABLE knowledge_groups
ADD COLUMN IF NOT EXISTS brand_id BIGINT REFERENCES brands(id);
ALTER TABLE knowledge_items
ADD COLUMN IF NOT EXISTS brand_id BIGINT REFERENCES brands(id);
ALTER TABLE image_folders
ADD COLUMN IF NOT EXISTS brand_id BIGINT REFERENCES brands(id);
ALTER TABLE image_assets
ADD COLUMN IF NOT EXISTS brand_id BIGINT REFERENCES brands(id);
INSERT INTO brands (tenant_id, name, description, status)
SELECT tenant_id, '未归属', '系统迁移生成,用于存放无法自动推断品牌的历史知识库和图片库资料。', 'active'
FROM (
SELECT tenant_id FROM knowledge_groups WHERE brand_id IS NULL
UNION
SELECT tenant_id FROM knowledge_items WHERE brand_id IS NULL
UNION
SELECT tenant_id FROM image_folders WHERE brand_id IS NULL
UNION
SELECT tenant_id FROM image_assets WHERE brand_id IS NULL
) tenants_with_assets
WHERE NOT EXISTS (
SELECT 1
FROM brands b
WHERE b.tenant_id = tenants_with_assets.tenant_id
AND b.deleted_at IS NULL
);
WITH inferred_group_brand AS (
SELECT DISTINCT ON (prkg.knowledge_group_id)
prkg.knowledge_group_id,
pr.brand_id
FROM prompt_rule_knowledge_groups prkg
JOIN prompt_rules pr
ON pr.id = prkg.prompt_rule_id
AND pr.deleted_at IS NULL
JOIN knowledge_groups kg
ON kg.id = prkg.knowledge_group_id
AND kg.tenant_id = pr.tenant_id
AND kg.deleted_at IS NULL
WHERE pr.brand_id IS NOT NULL
ORDER BY prkg.knowledge_group_id, pr.updated_at DESC, pr.id DESC
)
UPDATE knowledge_groups kg
SET brand_id = igb.brand_id,
updated_at = NOW()
FROM inferred_group_brand igb
WHERE kg.id = igb.knowledge_group_id
AND kg.brand_id IS NULL;
UPDATE knowledge_groups child
SET brand_id = parent.brand_id,
updated_at = NOW()
FROM knowledge_groups parent
WHERE child.parent_id = parent.id
AND child.tenant_id = parent.tenant_id
AND parent.brand_id IS NOT NULL;
UPDATE knowledge_groups parent
SET brand_id = child.brand_id,
updated_at = NOW()
FROM knowledge_groups child
WHERE child.parent_id = parent.id
AND child.tenant_id = parent.tenant_id
AND parent.brand_id IS NULL
AND child.brand_id IS NOT NULL;
WITH fallback_brand AS (
SELECT DISTINCT ON (tenant_id) tenant_id, id AS brand_id
FROM brands
WHERE deleted_at IS NULL
ORDER BY tenant_id, created_at, id
)
UPDATE knowledge_groups kg
SET brand_id = fb.brand_id,
updated_at = NOW()
FROM fallback_brand fb
WHERE kg.tenant_id = fb.tenant_id
AND kg.brand_id IS NULL;
UPDATE knowledge_items ki
SET brand_id = kg.brand_id,
updated_at = NOW()
FROM knowledge_groups kg
WHERE ki.group_id = kg.id
AND ki.tenant_id = kg.tenant_id
AND kg.brand_id IS NOT NULL;
WITH inferred_asset_brand AS (
SELECT DISTINCT ON (r.image_asset_id)
r.image_asset_id,
a.brand_id
FROM image_asset_references r
JOIN articles a
ON a.id = r.article_id
AND a.tenant_id = r.tenant_id
AND a.deleted_at IS NULL
WHERE a.brand_id IS NOT NULL
ORDER BY r.image_asset_id, a.updated_at DESC, a.id DESC
)
UPDATE image_assets ia
SET brand_id = iab.brand_id,
updated_at = NOW()
FROM inferred_asset_brand iab
WHERE ia.id = iab.image_asset_id
AND ia.brand_id IS NULL;
WITH schedule_cover_asset_brand AS (
SELECT DISTINCT ON (cover_image_asset_id)
cover_image_asset_id AS image_asset_id,
brand_id
FROM schedule_tasks
WHERE cover_image_asset_id IS NOT NULL
AND brand_id IS NOT NULL
AND deleted_at IS NULL
ORDER BY cover_image_asset_id, updated_at DESC, id DESC
)
UPDATE image_assets ia
SET brand_id = scab.brand_id,
updated_at = NOW()
FROM schedule_cover_asset_brand scab
WHERE ia.id = scab.image_asset_id
AND ia.tenant_id = (SELECT tenant_id FROM brands WHERE id = scab.brand_id)
AND ia.brand_id IS NULL;
WITH schedule_folder_brand AS (
SELECT DISTINCT ON (cover_random_folder_id)
cover_random_folder_id AS folder_id,
brand_id
FROM schedule_tasks
WHERE cover_random_folder_id IS NOT NULL
AND brand_id IS NOT NULL
AND deleted_at IS NULL
ORDER BY cover_random_folder_id, updated_at DESC, id DESC
)
UPDATE image_folders f
SET brand_id = sfb.brand_id,
updated_at = NOW()
FROM schedule_folder_brand sfb
WHERE f.id = sfb.folder_id
AND f.tenant_id = (SELECT tenant_id FROM brands WHERE id = sfb.brand_id)
AND f.brand_id IS NULL;
WITH folder_brand_from_assets AS (
SELECT DISTINCT ON (folder_id)
folder_id,
brand_id
FROM image_assets
WHERE folder_id IS NOT NULL
AND brand_id IS NOT NULL
ORDER BY folder_id, updated_at DESC, id DESC
)
UPDATE image_folders f
SET brand_id = fbfa.brand_id,
updated_at = NOW()
FROM folder_brand_from_assets fbfa
WHERE f.id = fbfa.folder_id
AND f.brand_id IS NULL;
UPDATE image_assets ia
SET brand_id = f.brand_id,
updated_at = NOW()
FROM image_folders f
WHERE ia.folder_id = f.id
AND ia.tenant_id = f.tenant_id
AND f.brand_id IS NOT NULL;
WITH fallback_brand AS (
SELECT DISTINCT ON (tenant_id) tenant_id, id AS brand_id
FROM brands
WHERE deleted_at IS NULL
ORDER BY tenant_id, created_at, id
)
UPDATE image_folders f
SET brand_id = fb.brand_id,
updated_at = NOW()
FROM fallback_brand fb
WHERE f.tenant_id = fb.tenant_id
AND f.brand_id IS NULL;
UPDATE image_assets ia
SET brand_id = f.brand_id,
updated_at = NOW()
FROM image_folders f
WHERE ia.folder_id = f.id
AND ia.tenant_id = f.tenant_id
AND f.brand_id IS NOT NULL;
WITH fallback_brand AS (
SELECT DISTINCT ON (tenant_id) tenant_id, id AS brand_id
FROM brands
WHERE deleted_at IS NULL
ORDER BY tenant_id, created_at, id
)
UPDATE image_assets ia
SET brand_id = fb.brand_id,
updated_at = NOW()
FROM fallback_brand fb
WHERE ia.tenant_id = fb.tenant_id
AND ia.brand_id IS NULL;
ALTER TABLE knowledge_groups
ALTER COLUMN brand_id SET NOT NULL;
ALTER TABLE knowledge_items
ALTER COLUMN brand_id SET NOT NULL;
ALTER TABLE image_folders
ALTER COLUMN brand_id SET NOT NULL;
ALTER TABLE image_assets
ALTER COLUMN brand_id SET NOT NULL;
DROP INDEX IF EXISTS uk_knowledge_group_name;
DROP INDEX IF EXISTS idx_knowledge_group_tenant_parent;
DROP INDEX IF EXISTS idx_knowledge_item_group_status;
CREATE UNIQUE INDEX IF NOT EXISTS uk_knowledge_group_brand_name
ON knowledge_groups(tenant_id, brand_id, COALESCE(parent_id, 0), name)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_knowledge_group_tenant_brand_parent
ON knowledge_groups(tenant_id, brand_id, parent_id, sort_order)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_knowledge_item_tenant_brand_group_status
ON knowledge_items(tenant_id, brand_id, group_id, status)
WHERE deleted_at IS NULL;
ALTER TABLE knowledge_groups
ADD CONSTRAINT uq_knowledge_groups_tenant_brand_id UNIQUE (tenant_id, brand_id, id);
ALTER TABLE knowledge_items
ADD CONSTRAINT fk_knowledge_items_group_brand
FOREIGN KEY (tenant_id, brand_id, group_id)
REFERENCES knowledge_groups(tenant_id, brand_id, id);
ALTER TABLE knowledge_groups
ADD CONSTRAINT fk_knowledge_groups_parent_brand
FOREIGN KEY (tenant_id, brand_id, parent_id)
REFERENCES knowledge_groups(tenant_id, brand_id, id);
ALTER TABLE image_assets
DROP CONSTRAINT IF EXISTS fk_image_assets_folder;
ALTER TABLE image_folders
DROP CONSTRAINT IF EXISTS uq_image_folders_tenant_name;
ALTER TABLE image_folders
DROP CONSTRAINT IF EXISTS uq_image_folders_tenant_id;
ALTER TABLE image_folders
ADD CONSTRAINT uq_image_folders_tenant_brand_name UNIQUE (tenant_id, brand_id, name);
ALTER TABLE image_folders
ADD CONSTRAINT uq_image_folders_tenant_brand_id UNIQUE (tenant_id, brand_id, id);
ALTER TABLE image_assets
ADD CONSTRAINT fk_image_assets_folder_brand
FOREIGN KEY (tenant_id, brand_id, folder_id)
REFERENCES image_folders(tenant_id, brand_id, id);
DROP INDEX IF EXISTS idx_image_folders_tenant;
DROP INDEX IF EXISTS idx_image_assets_tenant_folder;
DROP INDEX IF EXISTS idx_image_assets_tenant_created;
DROP INDEX IF EXISTS idx_image_assets_tenant_status;
CREATE INDEX IF NOT EXISTS idx_image_folders_tenant_brand
ON image_folders (tenant_id, brand_id);
CREATE INDEX IF NOT EXISTS idx_image_assets_tenant_brand_folder
ON image_assets (tenant_id, brand_id, folder_id)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_image_assets_tenant_brand_created
ON image_assets (tenant_id, brand_id, created_at DESC)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_image_assets_tenant_brand_status
ON image_assets (tenant_id, brand_id, status, created_at DESC);