From 27389164b094eb7d4db414ce59aaa9d48ae05c7a Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 16 Apr 2026 20:40:41 +0800 Subject: [PATCH] feat: add image management functionality - Implemented image folder repository with CRUD operations. - Added image reference repository for managing image references to articles. - Created image repository for handling image assets, including listing, inserting, updating, and deleting images. - Introduced image usage repository to track storage usage and quotas for tenants. - Added SQL queries for image assets, folders, references, and usage. - Developed image handler for HTTP endpoints to manage images and folders. - Created database migration scripts for image-related tables and structures. --- apps/admin-web/package.json | 1 + apps/admin-web/src/App.vue | 3 + .../src/components/ArticleEditorCanvas.vue | 155 +- .../src/components/CoverPickerModal.vue | 136 +- .../src/components/GenerateTaskDrawer.vue | 23 +- .../src/components/ImageLibraryPicker.vue | 250 ++ .../components/ImageUploadProgressOverlay.vue | 100 + apps/admin-web/src/i18n/messages/en-US.ts | 65 +- apps/admin-web/src/i18n/messages/zh-CN.ts | 67 +- apps/admin-web/src/layouts/AppShell.vue | 2 + apps/admin-web/src/lib/api.ts | 102 +- apps/admin-web/src/lib/display.ts | 20 + apps/admin-web/src/lib/errors.ts | 4 + apps/admin-web/src/lib/image-webp.ts | 229 ++ .../src/lib/milkdown/richImageBlock.ts | 23 +- apps/admin-web/src/main.ts | 2 + apps/admin-web/src/router/index.ts | 10 + .../admin-web/src/views/ArticleEditorView.vue | 31 +- apps/admin-web/src/views/ImagesView.vue | 840 ++++++ .../plans/2026-04-15-image-management.md | 2541 +++++++++++++++++ packages/shared-types/src/index.ts | 61 + pnpm-lock.yaml | 15 + server/cmd/tenant-api/main.go | 2 + server/go.mod | 8 +- server/go.sum | 8 + server/internal/shared/config/config.go | 24 + .../shared/messaging/rabbitmq/client.go | 20 + .../internal/shared/objectstorage/aliyun.go | 30 + .../internal/shared/objectstorage/client.go | 5 + server/internal/shared/objectstorage/minio.go | 26 + server/internal/tenant/app/article_service.go | 202 +- .../internal/tenant/app/image_asset_task.go | 70 + .../internal/tenant/app/image_asset_worker.go | 167 ++ .../tenant/app/image_folder_service.go | 215 ++ server/internal/tenant/app/image_service.go | 854 ++++++ .../tenant/app/prompt_generate_service.go | 2 +- .../internal/tenant/app/publish_platforms.go | 53 +- .../tenant/repository/generated/image.sql.go | 328 +++ .../repository/generated/image_folder.sql.go | 161 ++ .../generated/image_reference.sql.go | 167 ++ .../repository/generated/image_usage.sql.go | 58 + .../tenant/repository/generated/models.go | 83 +- .../tenant/repository/generated/querier.go | 26 + .../tenant/repository/image_folder_repo.go | 105 + .../tenant/repository/image_reference_repo.go | 116 + .../internal/tenant/repository/image_repo.go | 209 ++ .../tenant/repository/image_usage_repo.go | 84 + .../tenant/repository/queries/image.sql | 81 + .../repository/queries/image_folder.sql | 35 + .../repository/queries/image_reference.sql | 27 + .../tenant/repository/queries/image_usage.sql | 15 + .../tenant/transport/image_handler.go | 249 ++ server/internal/tenant/transport/router.go | 16 + ...0260415100000_create_image_tables.down.sql | 8 + .../20260415100000_create_image_tables.up.sql | 70 + ...3000_add_image_asset_content_hash.down.sql | 4 + ...113000_add_image_asset_content_hash.up.sql | 8 + 57 files changed, 8063 insertions(+), 153 deletions(-) create mode 100644 apps/admin-web/src/components/ImageLibraryPicker.vue create mode 100644 apps/admin-web/src/components/ImageUploadProgressOverlay.vue create mode 100644 apps/admin-web/src/lib/image-webp.ts create mode 100644 apps/admin-web/src/views/ImagesView.vue create mode 100644 docs/superpowers/plans/2026-04-15-image-management.md create mode 100644 server/internal/tenant/app/image_asset_task.go create mode 100644 server/internal/tenant/app/image_asset_worker.go create mode 100644 server/internal/tenant/app/image_folder_service.go create mode 100644 server/internal/tenant/app/image_service.go create mode 100644 server/internal/tenant/repository/generated/image.sql.go create mode 100644 server/internal/tenant/repository/generated/image_folder.sql.go create mode 100644 server/internal/tenant/repository/generated/image_reference.sql.go create mode 100644 server/internal/tenant/repository/generated/image_usage.sql.go create mode 100644 server/internal/tenant/repository/image_folder_repo.go create mode 100644 server/internal/tenant/repository/image_reference_repo.go create mode 100644 server/internal/tenant/repository/image_repo.go create mode 100644 server/internal/tenant/repository/image_usage_repo.go create mode 100644 server/internal/tenant/repository/queries/image.sql create mode 100644 server/internal/tenant/repository/queries/image_folder.sql create mode 100644 server/internal/tenant/repository/queries/image_reference.sql create mode 100644 server/internal/tenant/repository/queries/image_usage.sql create mode 100644 server/internal/tenant/transport/image_handler.go create mode 100644 server/migrations/20260415100000_create_image_tables.down.sql create mode 100644 server/migrations/20260415100000_create_image_tables.up.sql create mode 100644 server/migrations/20260416113000_add_image_asset_content_hash.down.sql create mode 100644 server/migrations/20260416113000_add_image_asset_content_hash.up.sql diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index b82fea9..07dfbdf 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -13,6 +13,7 @@ "@ant-design/icons-vue": "^7.0.1", "@geo/http-client": "workspace:*", "@geo/shared-types": "workspace:*", + "@jsquash/webp": "^1.5.0", "@milkdown/crepe": "^7.20.0", "@milkdown/kit": "^7.20.0", "@milkdown/theme-nord": "^7.20.0", diff --git a/apps/admin-web/src/App.vue b/apps/admin-web/src/App.vue index dcc99e2..072c2ae 100644 --- a/apps/admin-web/src/App.vue +++ b/apps/admin-web/src/App.vue @@ -4,6 +4,8 @@ import zhCN from "ant-design-vue/es/locale/zh_CN"; import { computed } from "vue"; import { useI18n } from "vue-i18n"; +import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue"; + const { locale } = useI18n(); const antLocale = computed(() => (locale.value === "en-US" ? enUS : zhCN)); @@ -21,6 +23,7 @@ const antLocale = computed(() => (locale.value === "en-US" ? enUS : zhCN)); > + diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index 07e8a03..57d413e 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -60,8 +60,10 @@ import EditorActionMenu from "@/components/editor-common/EditorActionMenu.vue"; import EditorAiAssistPanel from "@/components/editor-common/EditorAiAssistPanel.vue"; import EditorGridSizePicker from "@/components/editor-common/EditorGridSizePicker.vue"; import EditorSelectionFrame from "@/components/editor-common/EditorSelectionFrame.vue"; +import CoverPickerModal from "@/components/CoverPickerModal.vue"; import MarkdownPreview from "@/components/MarkdownPreview.vue"; import { + imagesApi, streamArticleSelectionOptimize, type ArticleSelectionOptimizeStreamEvent, } from "@/lib/api"; @@ -90,7 +92,6 @@ const crepeRef = ref(null); const syncingExternalMarkdown = ref(false); const surfaceRef = ref(null); const tablePickerRef = ref(null); -const imageInputRef = ref(null); const AI_OPTIMIZE_PANEL_GAP = 18; const AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME = "article-editor-ai-optimize-selection"; @@ -130,6 +131,8 @@ type ImageContextMenuAction = | "align-right" | "delete-image"; +type ImagePickerAction = "insert" | "replace"; + type AiOptimizeStatus = "idle" | "generating" | "completed" | "error"; type TableContextMenuState = { @@ -152,6 +155,7 @@ type SelectedImageState = { nodePos: number; nodeSize: number; src: string; + assetId: number | null; align: RichImageAlign; }; @@ -189,6 +193,7 @@ type SelectedImageSnapshot = { nodePos: number; nodeSize: number; src: string; + assetId: number | null; align: RichImageAlign; }; @@ -215,6 +220,7 @@ function createDefaultSelectedImageState(): SelectedImageState { nodePos: 0, nodeSize: 0, src: "", + assetId: null, align: "center", }; } @@ -297,8 +303,10 @@ const { loading } = useEditor((root) => { const [instanceLoading, getEditor] = useInstance(); const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value); const tablePickerOpen = ref(false); -const imageUploading = ref(false); -const imageInputAction = ref<"insert" | "replace">("insert"); +const imagePickerOpen = ref(false); +const imagePickerAction = ref("insert"); +const imagePickerCurrentUrl = ref(""); +const imagePickerCurrentAssetId = ref(null); const tableContextMenu = ref(createDefaultTableContextMenuState()); const selectedImage = ref(createDefaultSelectedImageState()); const imageContextMenu = ref(createDefaultImageContextMenuState()); @@ -512,6 +520,7 @@ watch( closeTableContextMenu(); clearSelectedImageState(); closeImageContextMenu(); + imagePickerOpen.value = false; closeAiOptimizePanel(); } }, @@ -575,8 +584,16 @@ function runEditorAction(action: (ctx: Ctx) => void): void { editor.action(action); } -function insertImageBlockAtCursor(src: string): void { - const resolvedSrc = src.trim(); +function resolveImageAssetId(value: unknown): number | null { + const resolved = Number.parseInt(String(value ?? "").trim(), 10); + if (!Number.isInteger(resolved) || resolved <= 0) { + return null; + } + return resolved; +} + +function insertImageBlockAtCursor(payload: { src: string; assetId?: number | null }): void { + const resolvedSrc = payload.src.trim(); if (!resolvedSrc) { return; } @@ -588,6 +605,7 @@ function insertImageBlockAtCursor(src: string): void { attrs: { src: resolvedSrc, caption: "", + assetId: payload.assetId ?? 0, ratio: 1, width: 0, height: 0, @@ -606,54 +624,44 @@ function insertImage(): void { return; } - if (props.uploadImage) { - imageInputAction.value = "insert"; - imageInputRef.value?.click(); - return; - } - - const src = window.prompt(t("article.editor.imagePrompt")); - if (!src?.trim()) { - return; - } - - insertImageBlockAtCursor(src); + closeTablePicker(); + closeTableContextMenu(); + closeImageContextMenu(); + imagePickerAction.value = "insert"; + imagePickerCurrentUrl.value = ""; + imagePickerCurrentAssetId.value = null; + imagePickerOpen.value = true; } -async function handleImageFileChange(event: Event): Promise { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - input.value = ""; - const action = imageInputAction.value; - imageInputAction.value = "insert"; +async function uploadBodyImageToLibrary(file: File): Promise<{ url: string; fileName: string; assetId: number }> { + const uploaded = await imagesApi.upload(file); + return { + url: uploaded.url, + fileName: uploaded.name, + assetId: uploaded.id, + }; +} - if (!file || !props.uploadImage || editorDisabled.value || imageUploading.value) { - return; +function handleImagePicked(payload: { url: string; fileName: string; assetId?: number | null }): void { + const assetId = payload.assetId ?? null; + if (imagePickerAction.value === "replace" && selectedImage.value.open) { + updateSelectedImageAttributes({ + src: payload.url, + assetId: assetId ?? 0, + ratio: 1, + width: 0, + height: 0, + }); + } else { + insertImageBlockAtCursor({ + src: payload.url, + assetId, + }); } - imageUploading.value = true; - try { - const src = (await props.uploadImage(file)).trim(); - if (!src) { - throw new Error("article_image_url_unavailable"); - } - - if (action === "replace" && selectedImage.value.open) { - updateSelectedImageAttributes({ - src, - ratio: 1, - width: 0, - height: 0, - }); - return; - } - - insertImageBlockAtCursor(src); - } catch (error) { - message.error(formatError(error)); - } finally { - imageUploading.value = false; - } + imagePickerAction.value = "insert"; + imagePickerCurrentUrl.value = ""; + imagePickerCurrentAssetId.value = null; } function clamp(value: number, min: number, max: number): number { @@ -1119,6 +1127,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec nodePos: selection.from, nodeSize: selection.node.nodeSize, src: String(selection.node.attrs.src ?? ""), + assetId: resolveImageAssetId(selection.node.attrs.assetId), align: resolveImageAlign(selection.node.attrs.align), }; }); @@ -1152,6 +1161,7 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi nodePos: snapshot.nodePos, nodeSize: snapshot.nodeSize, src: snapshot.src, + assetId: snapshot.assetId, align: snapshot.align, }; attachSelectedImageDomSync(); @@ -1396,6 +1406,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag nodePos, nodeSize: node.nodeSize, src: String(node.attrs.src ?? ""), + assetId: resolveImageAssetId(node.attrs.assetId), align: resolveImageAlign(node.attrs.align), }; }); @@ -1456,7 +1467,7 @@ function handleEditorDrop(): void { } function updateSelectedImageAttributes( - attrs: Partial>, + attrs: Partial>, ): void { if (!selectedImage.value.open) { return; @@ -1478,7 +1489,7 @@ function updateSelectedImageAttributes( } function updateSelectedImageAttribute( - attr: "src" | "ratio" | "width" | "height" | "align", + attr: "src" | "assetId" | "ratio" | "width" | "height" | "align", value: string | number, ): void { updateSelectedImageAttributes({ [attr]: value }); @@ -1519,24 +1530,10 @@ function replaceSelectedImage(): void { } closeImageContextMenu(); - - if (props.uploadImage) { - imageInputAction.value = "replace"; - imageInputRef.value?.click(); - return; - } - - const src = window.prompt(t("article.editor.imagePrompt"), selectedImage.value.src); - if (!src?.trim()) { - return; - } - - updateSelectedImageAttributes({ - src: src.trim(), - ratio: 1, - width: 0, - height: 0, - }); + imagePickerAction.value = "replace"; + imagePickerCurrentUrl.value = selectedImage.value.src; + imagePickerCurrentAssetId.value = selectedImage.value.assetId; + imagePickerOpen.value = true; } function runImageContextAction(action: ImageContextMenuAction): void { @@ -1853,18 +1850,9 @@ function runTableContextAction(action: TableContextMenuAction): void { - @@ -1958,6 +1946,19 @@ function runTableContextAction(action: TableContextMenuAction): void { + + diff --git a/apps/admin-web/src/components/CoverPickerModal.vue b/apps/admin-web/src/components/CoverPickerModal.vue index f5f2b67..0536bd2 100644 --- a/apps/admin-web/src/components/CoverPickerModal.vue +++ b/apps/admin-web/src/components/CoverPickerModal.vue @@ -4,6 +4,7 @@ import { message } from "ant-design-vue"; import { computed, onBeforeUnmount, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; +import ImageLibraryPicker from "@/components/ImageLibraryPicker.vue"; import { articlesApi } from "@/lib/api"; import { deriveCoverFileName, @@ -11,6 +12,14 @@ import { type PlatformCoverRequirement, } from "@/lib/cover-requirements"; import { formatError } from "@/lib/errors"; +import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress, validateImageUploadFile } from "@/lib/image-webp"; +import type { ImageAssetItem } from "@geo/shared-types"; + +type PickerUploadResult = { + url: string; + fileName?: string | null; + assetId?: number | null; +}; const props = defineProps<{ open: boolean; @@ -18,14 +27,21 @@ const props = defineProps<{ platformIds: string[]; currentUrl?: string | null; currentFileName?: string | null; + currentAssetId?: number | null; + title?: string; + localTabLabel?: string; + emptyUploadTitle?: string; + emptyUploadHint?: string; + uploadHandler?: (file: File) => Promise; }>(); const emit = defineEmits<{ "update:open": [value: boolean]; - confirmed: [payload: { url: string; fileName: string }]; + confirmed: [payload: { url: string; fileName: string; assetId?: number | null }]; }>(); const { t } = useI18n(); +const imageUploadProgress = useImageUploadProgress(); const fileInput = ref(null); const activeTab = ref("local"); @@ -41,6 +57,7 @@ const confirmLoading = ref(false); const transformDirty = ref(false); const localObjectUrl = ref(""); const localFile = ref(null); +const selectedLibraryImageId = ref(null); const dragging = ref(false); const dragStartX = ref(0); @@ -51,13 +68,18 @@ const imageLoadToken = ref(0); const primaryRequirement = computed(() => getPrimaryCoverRequirement(props.platformIds)); const aspectRatioLocked = computed(() => Boolean(primaryRequirement.value.aspectRatio)); +const modalTitle = computed(() => props.title?.trim() || t("coverPicker.title")); +const resolvedLocalTabLabel = computed(() => props.localTabLabel?.trim() || t("coverPicker.tabs.local")); +const resolvedEmptyUploadTitle = computed(() => props.emptyUploadTitle?.trim() || t("coverPicker.dropzoneTitle")); +const resolvedEmptyUploadHint = computed(() => props.emptyUploadHint?.trim() || t("coverPicker.dropzoneHint")); const localTabLabel = computed(() => - workingUrl.value ? `${t("coverPicker.tabs.local")} (1)` : t("coverPicker.tabs.local"), + workingUrl.value ? `${resolvedLocalTabLabel.value} (1)` : resolvedLocalTabLabel.value, ); const hasImage = computed(() => Boolean(workingUrl.value)); const previewHint = computed(() => aspectRatioLocked.value ? t("coverPicker.previewHintLocked") : t("coverPicker.previewHintFree"), ); +const imageUploadBusy = computed(() => imageUploadProgress.visible); const stageSize = computed(() => { const ratio = primaryRequirement.value.aspectRatio || (naturalWidth.value > 0 && naturalHeight.value > 0 ? naturalWidth.value / naturalHeight.value : 4 / 3); @@ -164,6 +186,7 @@ function hydrateFromProps(): void { workingFileName.value = nextFileName; sourceKind.value = nextUrl ? "remote" : null; localFile.value = null; + selectedLibraryImageId.value = props.currentAssetId ?? null; if (!nextUrl) { resetStage(); @@ -226,10 +249,18 @@ async function loadImageMeta(url: string): Promise { } function openFileDialog(): void { + if (imageUploadBusy.value) { + return; + } fileInput.value?.click(); } function handleFileChange(event: Event): void { + if (imageUploadBusy.value) { + (event.target as HTMLInputElement).value = ""; + return; + } + const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ""; @@ -237,12 +268,11 @@ function handleFileChange(event: Event): void { if (!file) { return; } - if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) { - message.warning(t("coverPicker.messages.invalidType")); - return; - } - if (file.size > 10 * 1024 * 1024) { - message.warning(t("coverPicker.messages.tooLarge")); + + try { + validateImageUploadFile(file); + } catch (error) { + message.warning(formatError(error)); return; } @@ -253,6 +283,7 @@ function handleFileChange(event: Event): void { workingFileName.value = file.name; sourceKind.value = "local"; localFile.value = file; + selectedLibraryImageId.value = null; transformDirty.value = false; void loadImageMeta(objectUrl); } @@ -320,18 +351,30 @@ function handleClear(): void { workingFileName.value = ""; sourceKind.value = null; localFile.value = null; + selectedLibraryImageId.value = null; transformDirty.value = false; resetStage(); revokeLocalObjectUrl(); } +function handleLibrarySelect(image: ImageAssetItem): void { + activeTab.value = "local"; + workingUrl.value = image.url; + workingFileName.value = image.name; + sourceKind.value = "remote"; + selectedLibraryImageId.value = image.id; + localFile.value = null; + transformDirty.value = false; + void loadImageMeta(image.url); +} + async function handleConfirm(): Promise { if (!workingUrl.value) { message.warning(t("coverPicker.messages.missingImage")); return; } - if (!props.articleId) { + if (!props.uploadHandler && !props.articleId) { message.error(t("coverPicker.messages.missingArticle")); return; } @@ -340,6 +383,7 @@ async function handleConfirm(): Promise { emit("confirmed", { url: workingUrl.value, fileName: workingFileName.value || deriveCoverFileName(workingUrl.value) || "cover.png", + assetId: selectedLibraryImageId.value, }); emit("update:open", false); return; @@ -348,10 +392,12 @@ async function handleConfirm(): Promise { confirmLoading.value = true; try { if (sourceKind.value === "local" && !transformDirty.value && !aspectRatioLocked.value && localFile.value) { - const uploaded = await articlesApi.uploadImage(props.articleId, localFile.value); + const fallbackFileName = localFile.value.name || deriveCoverFileName(workingUrl.value) || "cover.webp"; + const uploaded = await uploadPickedFile(localFile.value, fallbackFileName); emit("confirmed", { url: uploaded.url, - fileName: localFile.value.name, + fileName: uploaded.fileName, + assetId: uploaded.assetId, }); emit("update:open", false); return; @@ -363,11 +409,12 @@ async function handleConfirm(): Promise { blob.type, ); const file = new File([blob], fileName, { type: blob.type }); - const uploaded = await articlesApi.uploadImage(props.articleId, file); + const uploaded = await uploadPickedFile(file, fileName); emit("confirmed", { url: uploaded.url, - fileName, + fileName: uploaded.fileName, + assetId: uploaded.assetId, }); emit("update:open", false); } catch (error) { @@ -377,6 +424,28 @@ async function handleConfirm(): Promise { } } +async function uploadPickedFile(file: File, fallbackFileName: string): Promise<{ url: string; fileName: string; assetId?: number | null }> { + if (props.uploadHandler) { + const uploaded = await props.uploadHandler(file); + return { + url: uploaded.url, + fileName: String(uploaded.fileName ?? "").trim() || fallbackFileName, + assetId: uploaded.assetId ?? null, + }; + } + + if (!props.articleId) { + throw new Error(t("coverPicker.messages.missingArticle")); + } + + const uploaded = await articlesApi.uploadImage(props.articleId, file); + return { + url: uploaded.url, + fileName: fallbackFileName, + assetId: null, + }; +} + async function renderCroppedBlob(requirement: PlatformCoverRequirement): Promise { const image = await loadImageElement(workingUrl.value); const canvas = document.createElement("canvas"); @@ -456,7 +525,7 @@ function loadImageElement(url: string): Promise { :confirm-loading="confirmLoading" :ok-text="t('common.confirm')" :cancel-text="t('common.cancel')" - :title="t('coverPicker.title')" + :title="modalTitle" @ok="handleConfirm" @cancel="closeModal" > @@ -464,7 +533,8 @@ function loadImageElement(url: string): Promise { @@ -495,13 +565,14 @@ function loadImageElement(url: string): Promise { v-else class="cover-picker__stage-empty" type="button" + :disabled="imageUploadBusy" @click="openFileDialog" > - 点击本地上传 - 支持jpg、png等格式,单张图片最大支持5M,清晰的图片更有利于推荐 + {{ resolvedEmptyUploadTitle }} + {{ resolvedEmptyUploadHint }} @@ -524,7 +595,12 @@ function loadImageElement(url: string): Promise {
-
- -
- {{ t("coverPicker.aiTitle") }} -

{{ t("coverPicker.aiHint") }}

+ +
+
@@ -572,7 +647,7 @@ function loadImageElement(url: string): Promise { } .cover-picker__workspace, -.cover-picker__ai-placeholder { +.cover-picker__library { border: none; border-radius: 0; background: transparent; @@ -583,6 +658,11 @@ function loadImageElement(url: string): Promise { padding: 0; } +.cover-picker__library { + padding: 0; + height: 520px; +} + .cover-picker__stage-panel { display: flex; justify-content: center; @@ -641,6 +721,12 @@ function loadImageElement(url: string): Promise { cursor: pointer; } +.cover-picker__stage-empty:disabled, +.cover-picker__upload-tile:disabled { + opacity: 0.56; + cursor: not-allowed; +} + .cover-picker__ai-placeholder strong, .cover-picker__stage-empty strong { margin: 0; @@ -766,10 +852,6 @@ function loadImageElement(url: string): Promise { transform: scale(1.05); } -.cover-picker__ai-placeholder { - padding: 28px 22px; -} - @media (max-width: 960px) { .cover-picker__thumbs { flex-wrap: wrap; diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue index 9bb0c07..8eb8c77 100644 --- a/apps/admin-web/src/components/GenerateTaskDrawer.vue +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -10,6 +10,7 @@ import PromptRuleModal from "@/components/PromptRuleModal.vue"; import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue"; import { generateApi, mediaApi, promptRulesApi, schedulesApi } from "@/lib/api"; import { formatError } from "@/lib/errors"; +import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress, validateImageUploadFile } from "@/lib/image-webp"; import { buildPublishPlatformOptions, normalizePublishPlatformIds, @@ -29,6 +30,7 @@ const emit = defineEmits<{ const queryClient = useQueryClient(); const { t } = useI18n(); +const imageUploadProgress = useImageUploadProgress(); const promptDrawerOpen = ref(false); const coverPreviewUrl = ref(""); @@ -149,6 +151,7 @@ const submitLoading = computed( updateScheduleMutation.isPending.value || instantGenerateMutation.isPending.value, ); +const imageUploadBusy = computed(() => imageUploadProgress.visible); function hydrateForm(): void { form.name = props.task?.name ?? ""; @@ -202,12 +205,26 @@ function resetCoverState(): void { } function handleCoverChange(event: Event): void { + if (imageUploadBusy.value) { + (event.target as HTMLInputElement).value = ""; + return; + } + const input = event.target as HTMLInputElement; const file = input.files?.[0]; + input.value = ""; + if (!file) { return; } + try { + validateImageUploadFile(file); + } catch (error) { + message.warning(formatError(error)); + return; + } + if (coverPreviewUrl.value) { URL.revokeObjectURL(coverPreviewUrl.value); } @@ -373,13 +390,13 @@ function padTime(value: string): string {
diff --git a/apps/admin-web/src/views/ImagesView.vue b/apps/admin-web/src/views/ImagesView.vue new file mode 100644 index 0000000..7364cec --- /dev/null +++ b/apps/admin-web/src/views/ImagesView.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/docs/superpowers/plans/2026-04-15-image-management.md b/docs/superpowers/plans/2026-04-15-image-management.md new file mode 100644 index 0000000..53751ca --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-image-management.md @@ -0,0 +1,2541 @@ +# Image Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone image asset management system with folder organization, WebP conversion, quota enforcement, and article editor integration. + +**Architecture:** Layered Go backend (repository/app/transport) with sqlc-generated queries, atomic quota counter, and RabbitMQ-based compensation for upload/delete finalization. Vue 3 + Ant Design frontend with a new `/images` page and library picker integrated into the article editor and cover modal. + +**Tech Stack:** Go (gin, pgx, chai2010/webp, x/image/webp), PostgreSQL (pg_trgm), RabbitMQ, Vue 3, TypeScript, Ant Design Vue, TanStack Query. + +--- + +## File Structure + +### Backend — New Files + +| File | Responsibility | +|------|---------------| +| `server/migrations/20260415100000_create_image_tables.up.sql` | DDL for 4 tables + indexes + plan seed update | +| `server/migrations/20260415100000_create_image_tables.down.sql` | Rollback DDL | +| `server/internal/tenant/repository/queries/image.sql` | sqlc queries for image_assets | +| `server/internal/tenant/repository/queries/image_folder.sql` | sqlc queries for image_folders | +| `server/internal/tenant/repository/queries/image_reference.sql` | sqlc queries for image_asset_references | +| `server/internal/tenant/repository/queries/image_usage.sql` | sqlc queries for tenant_image_storage_usage | +| `server/internal/tenant/repository/image_repo.go` | ImageRepository interface + impl | +| `server/internal/tenant/repository/image_folder_repo.go` | ImageFolderRepository interface + impl | +| `server/internal/tenant/repository/image_reference_repo.go` | ImageReferenceRepository interface + impl | +| `server/internal/tenant/repository/image_usage_repo.go` | ImageUsageRepository interface + impl | +| `server/internal/tenant/app/image_service.go` | ImageService (upload pipeline, list, update, delete, quota) | +| `server/internal/tenant/app/image_folder_service.go` | ImageFolderService (CRUD) | +| `server/internal/tenant/transport/image_handler.go` | HTTP handlers for all /api/tenant/images endpoints | + +### Backend — Modified Files + +| File | Change | +|------|--------| +| `server/internal/shared/config/config.go` | Add ImageAsset queue config fields to RabbitMQConfig | +| `server/internal/shared/messaging/rabbitmq/client.go` | Add Publish/Consume for image asset queue + topology | +| `server/internal/tenant/transport/router.go` | Register image routes | +| `server/internal/tenant/repository/sqlc.yaml` | No change needed — queries auto-discovered from queries/ dir | + +### Frontend — New Files + +| File | Responsibility | +|------|---------------| +| `apps/admin-web/src/views/ImagesView.vue` | Main `/images` page with folder sidebar + image grid | +| `apps/admin-web/src/components/ImageLibraryPicker.vue` | Reusable image library picker (used in cover modal + editor) | + +### Frontend — Modified Files + +| File | Change | +|------|--------| +| `packages/shared-types/src/index.ts` | Add image-related TypeScript interfaces | +| `apps/admin-web/src/lib/api.ts` | Add `imagesApi` client | +| `apps/admin-web/src/router/index.ts` | Add `/images` route | +| `apps/admin-web/src/layouts/AppShell.vue` | Add nav item under contentManagement | +| `apps/admin-web/src/i18n/messages/zh-CN.ts` | Add Chinese translations for images feature | +| `apps/admin-web/src/i18n/messages/en-US.ts` | Add English translations for images feature | +| `apps/admin-web/src/components/CoverPickerModal.vue` | Replace "AI封面" tab with "本地素材" library picker tab | + +--- + +## Task 1: Database Migration + +**Files:** +- Create: `server/migrations/20260415100000_create_image_tables.up.sql` +- Create: `server/migrations/20260415100000_create_image_tables.down.sql` + +- [ ] **Step 1: Create up migration** + +Create `server/migrations/20260415100000_create_image_tables.up.sql`: + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE image_folders ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_image_folders_tenant_name UNIQUE (tenant_id, name), + CONSTRAINT uq_image_folders_tenant_id UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_image_folders_tenant ON image_folders (tenant_id); + +CREATE TABLE image_assets ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + folder_id BIGINT, + object_key TEXT NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + size_bytes BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_by BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT fk_image_assets_folder + FOREIGN KEY (tenant_id, folder_id) + REFERENCES image_folders(tenant_id, id), + CONSTRAINT uq_image_assets_tenant_id UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_image_assets_tenant_folder ON image_assets (tenant_id, folder_id) WHERE status = 'active' AND deleted_at IS NULL; +CREATE INDEX idx_image_assets_tenant_created ON image_assets (tenant_id, created_at DESC) WHERE status = 'active' AND deleted_at IS NULL; +CREATE INDEX idx_image_assets_tenant_status ON image_assets (tenant_id, status, created_at DESC); +CREATE INDEX idx_image_assets_name_trgm ON image_assets USING GIN (name gin_trgm_ops) WHERE status = 'active' AND deleted_at IS NULL; + +CREATE TABLE image_asset_references ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + image_asset_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + ref_scope VARCHAR(20) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_image_asset_references_asset + FOREIGN KEY (tenant_id, image_asset_id) + REFERENCES image_assets(tenant_id, id) + ON DELETE CASCADE, + CONSTRAINT uq_image_asset_reference UNIQUE (image_asset_id, article_id, ref_scope) +); + +CREATE INDEX idx_image_asset_references_asset ON image_asset_references (tenant_id, image_asset_id); +CREATE INDEX idx_image_asset_references_article ON image_asset_references (tenant_id, article_id); + +CREATE TABLE tenant_image_storage_usage ( + tenant_id BIGINT PRIMARY KEY, + used_bytes BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +UPDATE plans +SET quota_policy_json = quota_policy_json || + CASE plan_code + WHEN 'free' THEN '{"image_storage_bytes":104857600}'::jsonb + WHEN 'regular' THEN '{"image_storage_bytes":1073741824}'::jsonb + WHEN 'premium' THEN '{"image_storage_bytes":2147483648}'::jsonb + ELSE '{}'::jsonb + END +WHERE deleted_at IS NULL; +``` + +- [ ] **Step 2: Create down migration** + +Create `server/migrations/20260415100000_create_image_tables.down.sql`: + +```sql +DROP TABLE IF EXISTS image_asset_references; +DROP TABLE IF EXISTS image_assets; +DROP TABLE IF EXISTS image_folders; +DROP TABLE IF EXISTS tenant_image_storage_usage; + +UPDATE plans +SET quota_policy_json = quota_policy_json - 'image_storage_bytes' +WHERE deleted_at IS NULL; +``` + +- [ ] **Step 3: Run migration** + +```bash +cd server && go run cmd/migrate/main.go up +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/migrations/20260415100000_create_image_tables.up.sql server/migrations/20260415100000_create_image_tables.down.sql +git commit -m "feat(images): add database migration for image management tables" +``` + +--- + +## Task 2: sqlc Queries + +**Files:** +- Create: `server/internal/tenant/repository/queries/image.sql` +- Create: `server/internal/tenant/repository/queries/image_folder.sql` +- Create: `server/internal/tenant/repository/queries/image_reference.sql` +- Create: `server/internal/tenant/repository/queries/image_usage.sql` + +- [ ] **Step 1: Create image folder queries** + +Create `server/internal/tenant/repository/queries/image_folder.sql`: + +```sql +-- name: ListImageFolders :many +SELECT f.id, f.tenant_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 +WHERE f.tenant_id = sqlc.arg(tenant_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)) +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); + +-- name: GetImageFolder :one +SELECT id, tenant_id, name, created_at, updated_at +FROM image_folders +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); + +-- name: DeleteImageFolder :exec +DELETE FROM image_folders WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); + +-- name: CountActiveImagesInFolder :one +SELECT COUNT(*)::INT AS count +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id) + AND status = 'active' AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Create image asset queries** + +Create `server/internal/tenant/repository/queries/image.sql`: + +```sql +-- name: ListImageAssets :many +SELECT id, tenant_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 status = 'active' AND deleted_at IS NULL + AND (sqlc.narg(folder_id)::BIGINT IS NULL OR folder_id IS NOT DISTINCT FROM sqlc.narg(folder_id)::BIGINT) + AND (sqlc.narg(search_query)::TEXT IS NULL OR name ILIKE '%' || sqlc.narg(search_query)::TEXT || '%') +ORDER BY created_at DESC +LIMIT sqlc.arg(page_size) OFFSET sqlc.arg(page_offset); + +-- name: CountImageAssets :one +SELECT COUNT(*)::INT AS total +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) + AND status = 'active' AND deleted_at IS NULL + AND (sqlc.narg(folder_id)::BIGINT IS NULL OR folder_id IS NOT DISTINCT FROM sqlc.narg(folder_id)::BIGINT) + 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 +FROM image_assets +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_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)) +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); + +-- 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); + +-- 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); + +-- 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); + +-- name: ListActiveImagesByFolder :many +SELECT id, object_key, size_bytes +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id) + AND status = 'active' AND deleted_at IS NULL; + +-- name: NullifyFolderForImages :exec +UPDATE image_assets SET folder_id = NULL, updated_at = NOW() +WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id) + AND status = 'active' AND deleted_at IS NULL; + +-- name: MarkImagesInFolderPendingDelete :exec +UPDATE image_assets SET status = 'pending_delete', folder_id = NULL, updated_at = NOW() +WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id) + AND status = 'active' AND deleted_at IS NULL; + +-- name: CountUncategorizedImages :one +SELECT COUNT(*)::INT AS count +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id IS NULL + AND status = 'active' AND deleted_at IS NULL; +``` + +- [ ] **Step 3: Create image reference queries** + +Create `server/internal/tenant/repository/queries/image_reference.sql`: + +```sql +-- name: ListImageReferences :many +SELECT r.id, r.tenant_id, r.image_asset_id, r.article_id, r.ref_scope, r.created_at +FROM image_asset_references r +WHERE r.tenant_id = sqlc.arg(tenant_id) AND r.image_asset_id = sqlc.arg(image_asset_id); + +-- name: CountImageReferences :one +SELECT COUNT(*)::INT AS total +FROM image_asset_references +WHERE tenant_id = sqlc.arg(tenant_id) AND image_asset_id = sqlc.arg(image_asset_id); + +-- name: UpsertImageReference :exec +INSERT INTO image_asset_references (tenant_id, image_asset_id, article_id, ref_scope) +VALUES (sqlc.arg(tenant_id), sqlc.arg(image_asset_id), sqlc.arg(article_id), sqlc.arg(ref_scope)) +ON CONFLICT (image_asset_id, article_id, ref_scope) DO UPDATE SET updated_at = NOW(); + +-- name: DeleteImageReferencesByArticle :exec +DELETE FROM image_asset_references +WHERE tenant_id = sqlc.arg(tenant_id) AND article_id = sqlc.arg(article_id); + +-- name: DeleteImageReferencesByArticleScope :exec +DELETE FROM image_asset_references +WHERE tenant_id = sqlc.arg(tenant_id) AND article_id = sqlc.arg(article_id) AND ref_scope = sqlc.arg(ref_scope); + +-- name: ListImageReferenceArticles :many +SELECT DISTINCT r.article_id, r.ref_scope +FROM image_asset_references r +WHERE r.tenant_id = sqlc.arg(tenant_id) AND r.image_asset_id = sqlc.arg(image_asset_id); +``` + +- [ ] **Step 4: Create image usage queries** + +Create `server/internal/tenant/repository/queries/image_usage.sql`: + +```sql +-- name: EnsureImageStorageUsageRow :exec +INSERT INTO tenant_image_storage_usage (tenant_id, used_bytes) +VALUES (sqlc.arg(tenant_id), 0) +ON CONFLICT (tenant_id) DO NOTHING; + +-- name: GetImageStorageUsage :one +SELECT used_bytes, updated_at +FROM tenant_image_storage_usage +WHERE tenant_id = sqlc.arg(tenant_id); + +-- name: ReleaseImageStorageQuota :exec +UPDATE tenant_image_storage_usage +SET used_bytes = GREATEST(0, used_bytes - sqlc.arg(release_bytes)), + updated_at = NOW() +WHERE tenant_id = sqlc.arg(tenant_id); +``` + +Note: The atomic quota reserve query is too complex for sqlc (CTE + conditional UPDATE ... FROM ... RETURNING). It will be executed as raw SQL in the repository layer. + +- [ ] **Step 5: Run sqlc generate** + +```bash +cd server/internal/tenant/repository && sqlc generate +``` + +Verify generated files appear in `generated/` directory including `image.sql.go`, `image_folder.sql.go`, `image_reference.sql.go`, `image_usage.sql.go`, updated `querier.go` and `models.go`. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/tenant/repository/queries/ server/internal/tenant/repository/generated/ +git commit -m "feat(images): add sqlc queries and generated code for image management" +``` + +--- + +## Task 3: Repository Layer + +**Files:** +- Create: `server/internal/tenant/repository/image_folder_repo.go` +- Create: `server/internal/tenant/repository/image_repo.go` +- Create: `server/internal/tenant/repository/image_reference_repo.go` +- Create: `server/internal/tenant/repository/image_usage_repo.go` + +- [ ] **Step 1: Create image folder repository** + +Create `server/internal/tenant/repository/image_folder_repo.go`: + +```go +package repository + +import ( + "context" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageFolder struct { + ID int64 + TenantID int64 + Name string + ImageCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +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) +} + +type imageFolderRepository struct { + q generated.Querier +} + +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) + if err != nil { + return nil, err + } + folders := make([]ImageFolder, len(rows)) + for i, row := range rows { + folders[i] = ImageFolder{ + ID: row.ID, + TenantID: row.TenantID, + Name: row.Name, + ImageCount: int(row.ImageCount), + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + } + } + return folders, nil +} + +func (r *imageFolderRepository) Get(ctx context.Context, tenantID, id int64) (*ImageFolder, error) { + row, err := r.q.GetImageFolder(ctx, generated.GetImageFolderParams{ + ID: id, + TenantID: tenantID, + }) + if err != nil { + return nil, err + } + return &ImageFolder{ + ID: row.ID, + TenantID: row.TenantID, + 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) { + return r.q.CreateImageFolder(ctx, generated.CreateImageFolderParams{ + TenantID: tenantID, + Name: name, + }) +} + +func (r *imageFolderRepository) Update(ctx context.Context, tenantID, id int64, name string) error { + return r.q.UpdateImageFolder(ctx, generated.UpdateImageFolderParams{ + Name: name, + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageFolderRepository) Delete(ctx context.Context, tenantID, id int64) error { + return r.q.DeleteImageFolder(ctx, generated.DeleteImageFolderParams{ + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageFolderRepository) CountActiveImages(ctx context.Context, tenantID, folderID int64) (int, error) { + count, err := r.q.CountActiveImagesInFolder(ctx, generated.CountActiveImagesInFolderParams{ + TenantID: tenantID, + FolderID: folderID, + }) + if err != nil { + return 0, err + } + return int(count), nil +} +``` + +- [ ] **Step 2: Create image asset repository** + +Create `server/internal/tenant/repository/image_repo.go`: + +```go +package repository + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageAsset struct { + ID int64 + TenantID int64 + FolderID *int64 + ObjectKey string + Name string + SizeBytes int64 + Status string + CreatedBy int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type ImageAssetListParams struct { + TenantID int64 + FolderID *int64 + SearchQuery *string + PageSize int + PageOffset int +} + +type InsertImageAssetInput struct { + TenantID int64 + FolderID *int64 + ObjectKey string + Name string + SizeBytes int64 + Status string + CreatedBy int64 +} + +type FolderImageSummary struct { + ID int64 + ObjectKey string + SizeBytes int64 +} + +type ImageRepository interface { + List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error) + Get(ctx context.Context, tenantID, 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) +} + +type imageRepository struct { + q generated.Querier +} + +func NewImageRepository(db generated.DBTX) ImageRepository { + return &imageRepository{q: newQuerier(db)} +} + +func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error) { + listParams := generated.ListImageAssetsParams{ + TenantID: params.TenantID, + FolderID: pgInt8(params.FolderID), + PageSize: int32(params.PageSize), + PageOffset: int32(params.PageOffset), + } + if params.SearchQuery != nil { + listParams.SearchQuery = pgText(params.SearchQuery) + } + + rows, err := r.q.ListImageAssets(ctx, listParams) + if err != nil { + return nil, 0, err + } + + countParams := generated.CountImageAssetsParams{ + TenantID: params.TenantID, + FolderID: pgInt8(params.FolderID), + SearchQuery: listParams.SearchQuery, + } + total, err := r.q.CountImageAssets(ctx, countParams) + if err != nil { + return nil, 0, err + } + + assets := make([]ImageAsset, len(rows)) + for i, row := range rows { + assets[i] = ImageAsset{ + ID: row.ID, + TenantID: row.TenantID, + FolderID: nullableInt64(row.FolderID), + ObjectKey: row.ObjectKey, + Name: row.Name, + SizeBytes: row.SizeBytes, + Status: row.Status, + CreatedBy: row.CreatedBy, + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + } + } + + return assets, int(total), nil +} + +func (r *imageRepository) Get(ctx context.Context, tenantID, id int64) (*ImageAsset, error) { + row, err := r.q.GetImageAsset(ctx, generated.GetImageAssetParams{ + ID: id, + TenantID: tenantID, + }) + if err != nil { + return nil, err + } + return &ImageAsset{ + ID: row.ID, + TenantID: row.TenantID, + FolderID: nullableInt64(row.FolderID), + ObjectKey: row.ObjectKey, + Name: row.Name, + SizeBytes: row.SizeBytes, + Status: row.Status, + CreatedBy: row.CreatedBy, + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + }, nil +} + +func (r *imageRepository) Insert(ctx context.Context, input InsertImageAssetInput) (int64, error) { + return r.q.InsertImageAsset(ctx, generated.InsertImageAssetParams{ + TenantID: input.TenantID, + FolderID: pgInt8(input.FolderID), + ObjectKey: input.ObjectKey, + Name: input.Name, + SizeBytes: input.SizeBytes, + Status: input.Status, + CreatedBy: input.CreatedBy, + }) +} + +func (r *imageRepository) UpdateStatus(ctx context.Context, tenantID, id int64, status string) error { + return r.q.UpdateImageAssetStatus(ctx, generated.UpdateImageAssetStatusParams{ + Status: status, + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) MarkDeleted(ctx context.Context, tenantID, id int64) error { + return r.q.UpdateImageAssetDeleted(ctx, generated.UpdateImageAssetDeletedParams{ + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) UpdateName(ctx context.Context, tenantID, id int64, name string) error { + return r.q.UpdateImageAssetName(ctx, generated.UpdateImageAssetNameParams{ + Name: name, + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) UpdateFolder(ctx context.Context, tenantID, id int64, folderID *int64) error { + return r.q.UpdateImageAssetFolder(ctx, generated.UpdateImageAssetFolderParams{ + FolderID: pgInt8(folderID), + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) ListActiveByFolder(ctx context.Context, tenantID, folderID int64) ([]FolderImageSummary, error) { + rows, err := r.q.ListActiveImagesByFolder(ctx, generated.ListActiveImagesByFolderParams{ + TenantID: tenantID, + FolderID: folderID, + }) + if err != nil { + return nil, err + } + result := make([]FolderImageSummary, len(rows)) + for i, row := range rows { + result[i] = FolderImageSummary{ + ID: row.ID, + ObjectKey: row.ObjectKey, + SizeBytes: row.SizeBytes, + } + } + return result, nil +} + +func (r *imageRepository) MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, folderID int64) error { + return r.q.MarkImagesInFolderPendingDelete(ctx, generated.MarkImagesInFolderPendingDeleteParams{ + TenantID: tenantID, + FolderID: folderID, + }) +} + +func (r *imageRepository) CountUncategorized(ctx context.Context, tenantID int64) (int, error) { + count, err := r.q.CountUncategorizedImages(ctx, tenantID) + if err != nil { + return 0, err + } + return int(count), nil +} +``` + +Note: The `pgInt8`, `pgText`, `nullableInt64`, `timeFromTimestamp` helpers already exist in `common.go`. + +- [ ] **Step 3: Create image reference repository** + +Create `server/internal/tenant/repository/image_reference_repo.go`: + +```go +package repository + +import ( + "context" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageReference struct { + ID int64 + TenantID int64 + ImageAssetID int64 + ArticleID int64 + RefScope string + CreatedAt time.Time +} + +type ImageReferenceArticle struct { + ArticleID int64 + RefScope string +} + +type ImageReferenceRepository interface { + List(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReference, error) + Count(ctx context.Context, tenantID, imageAssetID int64) (int, error) + Upsert(ctx context.Context, tenantID, imageAssetID, articleID int64, refScope string) error + DeleteByArticle(ctx context.Context, tenantID, articleID int64) error + DeleteByArticleScope(ctx context.Context, tenantID, articleID int64, refScope string) error + ListArticles(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReferenceArticle, error) +} + +type imageReferenceRepository struct { + q generated.Querier +} + +func NewImageReferenceRepository(db generated.DBTX) ImageReferenceRepository { + return &imageReferenceRepository{q: newQuerier(db)} +} + +func (r *imageReferenceRepository) List(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReference, error) { + rows, err := r.q.ListImageReferences(ctx, generated.ListImageReferencesParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + }) + if err != nil { + return nil, err + } + refs := make([]ImageReference, len(rows)) + for i, row := range rows { + refs[i] = ImageReference{ + ID: row.ID, + TenantID: row.TenantID, + ImageAssetID: row.ImageAssetID, + ArticleID: row.ArticleID, + RefScope: row.RefScope, + CreatedAt: timeFromTimestamp(row.CreatedAt), + } + } + return refs, nil +} + +func (r *imageReferenceRepository) Count(ctx context.Context, tenantID, imageAssetID int64) (int, error) { + count, err := r.q.CountImageReferences(ctx, generated.CountImageReferencesParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + }) + if err != nil { + return 0, err + } + return int(count), nil +} + +func (r *imageReferenceRepository) Upsert(ctx context.Context, tenantID, imageAssetID, articleID int64, refScope string) error { + return r.q.UpsertImageReference(ctx, generated.UpsertImageReferenceParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + ArticleID: articleID, + RefScope: refScope, + }) +} + +func (r *imageReferenceRepository) DeleteByArticle(ctx context.Context, tenantID, articleID int64) error { + return r.q.DeleteImageReferencesByArticle(ctx, generated.DeleteImageReferencesByArticleParams{ + TenantID: tenantID, + ArticleID: articleID, + }) +} + +func (r *imageReferenceRepository) DeleteByArticleScope(ctx context.Context, tenantID, articleID int64, refScope string) error { + return r.q.DeleteImageReferencesByArticleScope(ctx, generated.DeleteImageReferencesByArticleScopeParams{ + TenantID: tenantID, + ArticleID: articleID, + RefScope: refScope, + }) +} + +func (r *imageReferenceRepository) ListArticles(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReferenceArticle, error) { + rows, err := r.q.ListImageReferenceArticles(ctx, generated.ListImageReferenceArticlesParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + }) + if err != nil { + return nil, err + } + articles := make([]ImageReferenceArticle, len(rows)) + for i, row := range rows { + articles[i] = ImageReferenceArticle{ + ArticleID: row.ArticleID, + RefScope: row.RefScope, + } + } + return articles, nil +} +``` + +- [ ] **Step 4: Create image usage repository** + +Create `server/internal/tenant/repository/image_usage_repo.go`: + +```go +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageStorageUsage struct { + UsedBytes int64 + UpdatedAt time.Time +} + +type QuotaReserveResult struct { + UsedBytes int64 + QuotaBytes int64 +} + +type ImageUsageRepository interface { + EnsureRow(ctx context.Context, tenantID int64) error + Get(ctx context.Context, tenantID int64) (*ImageStorageUsage, error) + Release(ctx context.Context, tenantID, releaseBytes int64) error + AtomicReserve(ctx context.Context, db generated.DBTX, tenantID, sizeBytes int64) (*QuotaReserveResult, error) +} + +type imageUsageRepository struct { + q generated.Querier + db generated.DBTX +} + +func NewImageUsageRepository(db generated.DBTX) ImageUsageRepository { + return &imageUsageRepository{q: newQuerier(db), db: db} +} + +func (r *imageUsageRepository) EnsureRow(ctx context.Context, tenantID int64) error { + return r.q.EnsureImageStorageUsageRow(ctx, tenantID) +} + +func (r *imageUsageRepository) Get(ctx context.Context, tenantID int64) (*ImageStorageUsage, error) { + row, err := r.q.GetImageStorageUsage(ctx, tenantID) + if err != nil { + return nil, err + } + return &ImageStorageUsage{ + UsedBytes: row.UsedBytes, + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + }, nil +} + +func (r *imageUsageRepository) Release(ctx context.Context, tenantID, releaseBytes int64) error { + return r.q.ReleaseImageStorageQuota(ctx, generated.ReleaseImageStorageQuotaParams{ + ReleaseBytes: releaseBytes, + TenantID: tenantID, + }) +} + +func (r *imageUsageRepository) AtomicReserve(ctx context.Context, db generated.DBTX, tenantID, sizeBytes int64) (*QuotaReserveResult, error) { + row := db.QueryRow(ctx, ` + WITH plan AS ( + SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, 104857600) AS quota_bytes + FROM tenant_plan_subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 + AND s.status = 'active' + AND s.deleted_at IS NULL + LIMIT 1 + ) + UPDATE tenant_image_storage_usage u + SET used_bytes = u.used_bytes + $2, + updated_at = NOW() + FROM plan + WHERE u.tenant_id = $1 + AND u.used_bytes + $2 <= plan.quota_bytes + RETURNING u.used_bytes, plan.quota_bytes + `, tenantID, sizeBytes) + + var result QuotaReserveResult + err := row.Scan(&result.UsedBytes, &result.QuotaBytes) + if err != nil { + return nil, fmt.Errorf("reserve image storage quota: %w", err) + } + return &result, nil +} +``` + +- [ ] **Step 5: Verify compilation** + +```bash +cd server && go build ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/tenant/repository/image_folder_repo.go server/internal/tenant/repository/image_repo.go server/internal/tenant/repository/image_reference_repo.go server/internal/tenant/repository/image_usage_repo.go +git commit -m "feat(images): add repository layer for image management" +``` + +--- + +## Task 4: RabbitMQ Image Asset Queue + +**Files:** +- Modify: `server/internal/shared/config/config.go` +- Modify: `server/internal/shared/messaging/rabbitmq/client.go` + +- [ ] **Step 1: Add config fields** + +In `server/internal/shared/config/config.go`, add the following fields to `RabbitMQConfig` (after `TemplateAssistDLQRouteKey`): + +```go +ImageAssetExchange string `mapstructure:"image_asset_exchange"` +ImageAssetRoutingKey string `mapstructure:"image_asset_routing_key"` +ImageAssetQueue string `mapstructure:"image_asset_queue"` +ImageAssetDLX string `mapstructure:"image_asset_dlx"` +ImageAssetDLQ string `mapstructure:"image_asset_dlq"` +ImageAssetDLQRouteKey string `mapstructure:"image_asset_dlq_routing_key"` +``` + +In `normalizeRabbitMQConfig`, add defaults (after the TemplateAssist block): + +```go +if strings.TrimSpace(cfg.ImageAssetExchange) == "" { + cfg.ImageAssetExchange = "image.asset" +} +if strings.TrimSpace(cfg.ImageAssetRoutingKey) == "" { + cfg.ImageAssetRoutingKey = "image.asset.finalize" +} +if strings.TrimSpace(cfg.ImageAssetQueue) == "" { + cfg.ImageAssetQueue = "image.asset.finalize" +} +if strings.TrimSpace(cfg.ImageAssetDLX) == "" { + cfg.ImageAssetDLX = "image.asset.dlx" +} +if strings.TrimSpace(cfg.ImageAssetDLQ) == "" { + cfg.ImageAssetDLQ = "image.asset.finalize.dlq" +} +if strings.TrimSpace(cfg.ImageAssetDLQRouteKey) == "" { + cfg.ImageAssetDLQRouteKey = "image.asset.finalize.dlq" +} +``` + +- [ ] **Step 2: Add publish/consume methods and topology** + +In `server/internal/shared/messaging/rabbitmq/client.go`, add: + +```go +func (c *Client) PublishImageAssetTask(ctx context.Context, body []byte) error { + return c.publish(ctx, c.cfg.ImageAssetExchange, c.cfg.ImageAssetRoutingKey, body) +} + +func (c *Client) ConsumeImageAssetTask(consumerName string) (<-chan amqp.Delivery, *amqp.Channel, error) { + return c.consume(consumerName, c.cfg.ImageAssetQueue) +} +``` + +In `ensureTopology`, add before `return nil`: + +```go +if err := c.ensureWorkQueue( + ch, + c.cfg.ImageAssetExchange, + c.cfg.ImageAssetRoutingKey, + c.cfg.ImageAssetQueue, + c.cfg.ImageAssetDLX, + c.cfg.ImageAssetDLQ, + c.cfg.ImageAssetDLQRouteKey, +); err != nil { + return err +} +``` + +- [ ] **Step 3: Verify compilation** + +```bash +cd server && go build ./... +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/shared/config/config.go server/internal/shared/messaging/rabbitmq/client.go +git commit -m "feat(images): add RabbitMQ queue for image asset finalization" +``` + +--- + +## Task 5: Image Folder Service + +**Files:** +- Create: `server/internal/tenant/app/image_folder_service.go` + +- [ ] **Step 1: Create ImageFolderService** + +Create `server/internal/tenant/app/image_folder_service.go`: + +```go +package app + +import ( + "context" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type ImageFolderService struct { + pool *pgxpool.Pool +} + +func NewImageFolderService(pool *pgxpool.Pool) *ImageFolderService { + return &ImageFolderService{pool: pool} +} + +type ImageFolderItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + ImageCount int `json:"image_count"` + CreatedAt string `json:"created_at"` +} + +func (s *ImageFolderService) List(ctx context.Context) ([]ImageFolderItem, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageFolderRepository(s.pool) + folders, err := repo.List(ctx, tenantID) + if err != nil { + return nil, err + } + + items := make([]ImageFolderItem, len(folders)) + for i, f := range folders { + items[i] = ImageFolderItem{ + ID: f.ID, + Name: f.Name, + ImageCount: f.ImageCount, + CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + return items, nil +} + +func (s *ImageFolderService) Create(ctx context.Context, name string) (int64, error) { + name = strings.TrimSpace(name) + if name == "" { + return 0, response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required") + } + if len(name) > 100 { + return 0, response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters") + } + + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageFolderRepository(s.pool) + id, err := repo.Create(ctx, tenantID, name) + if err != nil { + if strings.Contains(err.Error(), "uq_image_folders_tenant_name") { + return 0, response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists") + } + return 0, err + } + return id, nil +} + +func (s *ImageFolderService) Update(ctx context.Context, id int64, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required") + } + if len(name) > 100 { + return response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters") + } + + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageFolderRepository(s.pool) + if err := repo.Update(ctx, tenantID, id, name); err != nil { + if strings.Contains(err.Error(), "uq_image_folders_tenant_name") { + return response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists") + } + return err + } + return nil +} + +type FolderDeletePreview struct { + ImageCount int `json:"image_count"` + ReferencedList []ImageReferenceArticleBrief `json:"referenced_articles"` +} + +type ImageReferenceArticleBrief struct { + ArticleID int64 `json:"article_id"` + Scopes []string `json:"scopes"` +} + +func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64) (*FolderDeletePreview, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + imageRepo := repository.NewImageRepository(s.pool) + refRepo := repository.NewImageReferenceRepository(s.pool) + + images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID) + if err != nil { + return nil, err + } + + articleMap := make(map[int64]map[string]bool) + for _, img := range images { + articles, err := refRepo.ListArticles(ctx, tenantID, img.ID) + if err != nil { + return nil, err + } + for _, a := range articles { + if articleMap[a.ArticleID] == nil { + articleMap[a.ArticleID] = make(map[string]bool) + } + articleMap[a.ArticleID][a.RefScope] = true + } + } + + referenced := make([]ImageReferenceArticleBrief, 0, len(articleMap)) + for aid, scopes := range articleMap { + scopeList := make([]string, 0, len(scopes)) + for s := range scopes { + scopeList = append(scopeList, s) + } + referenced = append(referenced, ImageReferenceArticleBrief{ + ArticleID: aid, + Scopes: scopeList, + }) + } + + return &FolderDeletePreview{ + ImageCount: len(images), + ReferencedList: referenced, + }, nil +} + +func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force bool) error { + tenantID := middleware.TenantIDFromCtx(ctx) + imageRepo := repository.NewImageRepository(s.pool) + refRepo := repository.NewImageReferenceRepository(s.pool) + folderRepo := repository.NewImageFolderRepository(s.pool) + + images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID) + if err != nil { + return err + } + + if !force && len(images) > 0 { + for _, img := range images { + count, err := refRepo.Count(ctx, tenantID, img.ID) + if err != nil { + return err + } + if count > 0 { + return response.ErrConflict(40902, "folder_has_referenced_images", "folder contains images that are referenced by articles") + } + } + } + + if len(images) > 0 { + if err := imageRepo.MarkImagesInFolderPendingDelete(ctx, tenantID, folderID); err != nil { + return err + } + } + + if err := folderRepo.Delete(ctx, tenantID, folderID); err != nil { + return err + } + + return nil +} +``` + +- [ ] **Step 2: Verify compilation** + +```bash +cd server && go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/tenant/app/image_folder_service.go +git commit -m "feat(images): add ImageFolderService with CRUD and delete preview" +``` + +--- + +## Task 6: Image Service + +**Files:** +- Create: `server/internal/tenant/app/image_service.go` + +This is the main service with upload pipeline (WebP conversion), list, update, delete, storage usage, and reference lookup. + +- [ ] **Step 1: Create ImageService** + +Create `server/internal/tenant/app/image_service.go` (large file — all upload/delete/list logic): + +```go +package app + +import ( + "bytes" + "context" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "net/http" + "path/filepath" + "strings" + + "github.com/chai2010/webp" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + _ "golang.org/x/image/webp" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +const ( + maxImageUploadSize = 10 * 1024 * 1024 // 10 MB + webpQuality = 82 + maxImageDimension = 16384 +) + +var allowedImageMIMEs = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, +} + +type ImageService struct { + pool *pgxpool.Pool + objectStorage objectstorage.Client + logger *zap.Logger +} + +func NewImageService(pool *pgxpool.Pool, objectStorage objectstorage.Client, logger *zap.Logger) *ImageService { + return &ImageService{pool: pool, objectStorage: objectStorage, logger: logger} +} + +type ImageUploadResult struct { + ID int64 `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` +} + +func (s *ImageService) Upload(ctx context.Context, fileName string, content []byte, folderID *int64, buildURL func(string) string) (*ImageUploadResult, error) { + if len(content) > maxImageUploadSize { + return nil, response.ErrBadRequest(40010, "image_too_large", "image must be at most 10 MB") + } + + contentType := http.DetectContentType(content) + if !allowedImageMIMEs[contentType] { + return nil, response.ErrBadRequest(40011, "invalid_image_type", "only PNG, JPEG, GIF, and WebP images are supported") + } + + imgConfig, _, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return nil, response.ErrBadRequest(40012, "invalid_image", "unable to decode image") + } + if imgConfig.Width > maxImageDimension || imgConfig.Height > maxImageDimension { + return nil, response.ErrBadRequest(40013, "image_too_large_dimensions", "image dimensions exceed maximum allowed") + } + + img, _, err := image.Decode(bytes.NewReader(content)) + if err != nil { + return nil, response.ErrBadRequest(40012, "invalid_image", "unable to decode image") + } + + var webpBuf bytes.Buffer + if err := webp.Encode(&webpBuf, img, &webp.Options{Quality: webpQuality}); err != nil { + return nil, fmt.Errorf("encode webp: %w", err) + } + webpBytes := webpBuf.Bytes() + sizeBytes := int64(len(webpBytes)) + + tenantID := middleware.TenantIDFromCtx(ctx) + actor := auth.MustActor(ctx) + + usageRepo := repository.NewImageUsageRepository(s.pool) + if err := usageRepo.EnsureRow(ctx, tenantID); err != nil { + return nil, fmt.Errorf("ensure usage row: %w", err) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + reserveResult, err := usageRepo.AtomicReserve(ctx, tx, tenantID, sizeBytes) + if err != nil { + if strings.Contains(err.Error(), "no rows") || reserveResult == nil { + return nil, response.ErrConflict(40903, "image_storage_quota_exceeded", "image storage quota exceeded") + } + return nil, fmt.Errorf("reserve quota: %w", err) + } + + displayName := deriveImageName(fileName) + objectKey := fmt.Sprintf("tenants/%d/images/%s.webp", tenantID, uuid.New().String()) + + imageRepo := repository.NewImageRepository(tx) + assetID, err := imageRepo.Insert(ctx, repository.InsertImageAssetInput{ + TenantID: tenantID, + FolderID: folderID, + ObjectKey: objectKey, + Name: displayName, + SizeBytes: sizeBytes, + Status: "pending_upload", + CreatedBy: actor.UserID, + }) + if err != nil { + return nil, fmt.Errorf("insert image asset: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit tx: %w", err) + } + + if err := s.objectStorage.PutBytes(ctx, objectKey, webpBytes, "image/webp"); err != nil { + s.logger.Error("image upload to object storage failed, rolling back", + zap.Int64("asset_id", assetID), + zap.Error(err), + ) + s.rollbackUpload(ctx, tenantID, assetID, sizeBytes) + return nil, response.ErrInternal(50010, "image_upload_failed", "failed to upload image to storage") + } + + imgRepo := repository.NewImageRepository(s.pool) + if err := imgRepo.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil { + s.logger.Error("failed to mark image active after upload", + zap.Int64("asset_id", assetID), + zap.Error(err), + ) + } + + url := buildURL(objectKey) + + return &ImageUploadResult{ + ID: assetID, + URL: url, + Name: displayName, + SizeBytes: sizeBytes, + }, nil +} + +func (s *ImageService) rollbackUpload(ctx context.Context, tenantID, assetID, sizeBytes int64) { + imgRepo := repository.NewImageRepository(s.pool) + _ = imgRepo.UpdateStatus(ctx, tenantID, assetID, "upload_failed") + usageRepo := repository.NewImageUsageRepository(s.pool) + _ = usageRepo.Release(ctx, tenantID, sizeBytes) +} + +type ImageListResult struct { + Items []ImageListItem `json:"items"` + Total int `json:"total"` +} + +type ImageListItem struct { + ID int64 `json:"id"` + FolderID *int64 `json:"folder_id"` + Name string `json:"name"` + URL string `json:"url"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +func (s *ImageService) List(ctx context.Context, folderID *int64, query *string, page, pageSize int, buildURL func(string) string) (*ImageListResult, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + if query != nil && strings.TrimSpace(*query) != "" { + folderID = nil + } + + repo := repository.NewImageRepository(s.pool) + assets, total, err := repo.List(ctx, repository.ImageAssetListParams{ + TenantID: tenantID, + FolderID: folderID, + SearchQuery: query, + PageSize: pageSize, + PageOffset: (page - 1) * pageSize, + }) + if err != nil { + return nil, err + } + + items := make([]ImageListItem, len(assets)) + for i, a := range assets { + items[i] = ImageListItem{ + ID: a.ID, + FolderID: a.FolderID, + Name: a.Name, + URL: buildURL(a.ObjectKey), + SizeBytes: a.SizeBytes, + CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return &ImageListResult{Items: items, Total: total}, nil +} + +type ImageUpdateRequest struct { + Name *string `json:"name"` + FolderID *int64 `json:"folder_id"` +} + +func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequest) error { + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageRepository(s.pool) + + asset, err := repo.Get(ctx, tenantID, id) + if err != nil { + if err == pgx.ErrNoRows { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + return err + } + if asset.Status != "active" { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return response.ErrBadRequest(40001, "invalid_name", "name is required") + } + 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 { + return err + } + } + + if req.FolderID != nil { + if err := repo.UpdateFolder(ctx, tenantID, id, req.FolderID); err != nil { + return err + } + } + + return nil +} + +type ImageReferenceDetail struct { + Total int `json:"total"` + ArticleBody int `json:"article_body"` + ArticleCover int `json:"article_cover"` + Articles []ImageReferenceArticleBrief `json:"articles"` +} + +func (s *ImageService) GetReferences(ctx context.Context, imageID int64) (*ImageReferenceDetail, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + refRepo := repository.NewImageReferenceRepository(s.pool) + + articles, err := refRepo.ListArticles(ctx, tenantID, imageID) + if err != nil { + return nil, err + } + + articleMap := make(map[int64][]string) + bodyCount, coverCount := 0, 0 + for _, a := range articles { + articleMap[a.ArticleID] = append(articleMap[a.ArticleID], a.RefScope) + switch a.RefScope { + case "article_body": + bodyCount++ + case "article_cover": + coverCount++ + } + } + + briefs := make([]ImageReferenceArticleBrief, 0, len(articleMap)) + for aid, scopes := range articleMap { + briefs = append(briefs, ImageReferenceArticleBrief{ + ArticleID: aid, + Scopes: scopes, + }) + } + + return &ImageReferenceDetail{ + Total: len(articles), + ArticleBody: bodyCount, + ArticleCover: coverCount, + Articles: briefs, + }, nil +} + +func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error { + tenantID := middleware.TenantIDFromCtx(ctx) + imageRepo := repository.NewImageRepository(s.pool) + refRepo := repository.NewImageReferenceRepository(s.pool) + usageRepo := repository.NewImageUsageRepository(s.pool) + + asset, err := imageRepo.Get(ctx, tenantID, id) + if err != nil { + if err == pgx.ErrNoRows { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + return err + } + if asset.Status != "active" { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + + if !force { + count, err := refRepo.Count(ctx, tenantID, id) + if err != nil { + return err + } + if count > 0 { + return response.ErrConflict(40904, "image_asset_in_use", "image is referenced by articles") + } + } + + if err := imageRepo.UpdateStatus(ctx, tenantID, id, "pending_delete"); err != nil { + return err + } + + if err := s.objectStorage.Delete(ctx, asset.ObjectKey); err != nil { + s.logger.Error("failed to delete image from object storage", + zap.Int64("asset_id", id), + zap.String("object_key", asset.ObjectKey), + zap.Error(err), + ) + } + + if err := imageRepo.MarkDeleted(ctx, tenantID, id); err != nil { + return err + } + if err := usageRepo.Release(ctx, tenantID, asset.SizeBytes); err != nil { + s.logger.Error("failed to release image storage quota", + zap.Int64("asset_id", id), + zap.Error(err), + ) + } + + return nil +} + +type StorageUsageResponse struct { + UsedBytes int64 `json:"used_bytes"` + QuotaBytes int64 `json:"quota_bytes"` + UsedPct float64 `json:"used_pct"` +} + +func (s *ImageService) GetStorageUsage(ctx context.Context) (*StorageUsageResponse, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + usageRepo := repository.NewImageUsageRepository(s.pool) + + if err := usageRepo.EnsureRow(ctx, tenantID); err != nil { + return nil, err + } + + usage, err := usageRepo.Get(ctx, tenantID) + if err != nil { + return nil, err + } + + var quotaBytes int64 + row := s.pool.QueryRow(ctx, ` + SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, 104857600) AS quota_bytes + FROM tenant_plan_subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 + AND s.status = 'active' + AND s.deleted_at IS NULL + LIMIT 1 + `, tenantID) + if err := row.Scan("aBytes); err != nil { + quotaBytes = 104857600 + } + + usedPct := float64(0) + if quotaBytes > 0 { + usedPct = float64(usage.UsedBytes) / float64(quotaBytes) * 100 + } + + return &StorageUsageResponse{ + UsedBytes: usage.UsedBytes, + QuotaBytes: quotaBytes, + UsedPct: usedPct, + }, nil +} + +func deriveImageName(fileName string) string { + name := strings.TrimSpace(fileName) + if name == "" { + return "untitled" + } + ext := filepath.Ext(name) + if ext != "" { + name = strings.TrimSuffix(name, ext) + } + if len(name) > 200 { + name = name[:200] + } + return name +} +``` + +- [ ] **Step 2: Install Go dependency** + +```bash +cd server && go get github.com/chai2010/webp && go get golang.org/x/image/webp +``` + +- [ ] **Step 3: Verify compilation** + +```bash +cd server && go build ./... +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/tenant/app/image_service.go server/go.mod server/go.sum +git commit -m "feat(images): add ImageService with WebP upload pipeline, delete, and quota management" +``` + +--- + +## Task 7: HTTP Handler + +**Files:** +- Create: `server/internal/tenant/transport/image_handler.go` + +- [ ] **Step 1: Create ImageHandler** + +Create `server/internal/tenant/transport/image_handler.go`: + +```go +package transport + +import ( + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +type ImageHandler struct { + imageSvc *app.ImageService + folderSvc *app.ImageFolderService + assets *AssetHandler +} + +func NewImageHandler(a *bootstrap.App) *ImageHandler { + return &ImageHandler{ + imageSvc: app.NewImageService(a.DB, a.ObjectStorage, a.Logger), + folderSvc: app.NewImageFolderService(a.DB), + assets: NewAssetHandler(a), + } +} + +// --- Folders --- + +func (h *ImageHandler) ListFolders(c *gin.Context) { + folders, err := h.folderSvc.List(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, folders) +} + +func (h *ImageHandler) CreateFolder(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body")) + return + } + id, err := h.folderSvc.Create(c.Request.Context(), body.Name) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, http.StatusCreated, gin.H{"id": id}) +} + +func (h *ImageHandler) UpdateFolder(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id")) + return + } + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body")) + return + } + if err := h.folderSvc.Update(c.Request.Context(), id, body.Name); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) FolderDeletePreview(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id")) + return + } + preview, err := h.folderSvc.DeletePreview(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, preview) +} + +func (h *ImageHandler) DeleteFolder(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id")) + return + } + force := c.Query("force") == "1" + if err := h.folderSvc.Delete(c.Request.Context(), id, force); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +// --- Images --- + +func (h *ImageHandler) ListImages(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + var folderID *int64 + if fid := c.Query("folder_id"); fid != "" { + if fid == "null" || fid == "uncategorized" { + zero := int64(0) + folderID = &zero + } else { + v, err := strconv.ParseInt(fid, 10, 64) + if err == nil { + folderID = &v + } + } + } + + var query *string + if q := c.Query("q"); q != "" { + query = &q + } + + result, err := h.imageSvc.List(c.Request.Context(), folderID, query, page, pageSize, h.assets.BuildArticleImageURL) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) +} + +func (h *ImageHandler) UploadImage(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "missing_file", "file is required")) + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "read_file_failed", "failed to read file")) + return + } + + var folderID *int64 + if fid := c.PostForm("folder_id"); fid != "" { + v, err := strconv.ParseInt(fid, 10, 64) + if err == nil { + folderID = &v + } + } + + result, err := h.imageSvc.Upload(c.Request.Context(), header.Filename, content, folderID, h.assets.BuildArticleImageURL) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, http.StatusCreated, result) +} + +func (h *ImageHandler) UpdateImage(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id")) + return + } + + var body app.ImageUpdateRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body")) + return + } + + if err := h.imageSvc.Update(c.Request.Context(), id, body); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) GetImageReferences(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id")) + return + } + + refs, err := h.imageSvc.GetReferences(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, refs) +} + +func (h *ImageHandler) DeleteImage(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id")) + return + } + force := c.Query("force") == "1" + if err := h.imageSvc.Delete(c.Request.Context(), id, force); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) StorageUsage(c *gin.Context) { + usage, err := h.imageSvc.GetStorageUsage(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, usage) +} +``` + +- [ ] **Step 2: Verify compilation** + +```bash +cd server && go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/tenant/transport/image_handler.go +git commit -m "feat(images): add HTTP handler for image management endpoints" +``` + +--- + +## Task 8: Route Registration + +**Files:** +- Modify: `server/internal/tenant/transport/router.go` + +- [ ] **Step 1: Add image routes** + +In `server/internal/tenant/transport/router.go`, add the following block after the `instantTasks` routes (before the closing `}`): + +```go + // Image Management + images := protected.Group("/tenant/images") + imgHandler := NewImageHandler(app) + 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 := protected.Group("/tenant/images/folders") + imageFolders.GET("", imgHandler.ListFolders) + imageFolders.POST("", imgHandler.CreateFolder) + imageFolders.PUT("/:id", imgHandler.UpdateFolder) + imageFolders.GET("/:id/delete-preview", imgHandler.FolderDeletePreview) + imageFolders.DELETE("/:id", imgHandler.DeleteFolder) +``` + +- [ ] **Step 2: Verify compilation** + +```bash +cd server && go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/tenant/transport/router.go +git commit -m "feat(images): register image management API routes" +``` + +--- + +## Task 9: Shared TypeScript Types + +**Files:** +- Modify: `packages/shared-types/src/index.ts` + +- [ ] **Step 1: Add image management types** + +Append to `packages/shared-types/src/index.ts`: + +```typescript +// --- Image Management --- + +export interface ImageFolder { + id: number; + name: string; + image_count: number; + created_at: string; +} + +export interface ImageAssetItem { + id: number; + folder_id: number | null; + name: string; + url: string; + size_bytes: number; + created_at: string; +} + +export interface ImageAssetListResponse { + items: ImageAssetItem[]; + total: number; +} + +export interface ImageUploadResponse { + id: number; + url: string; + name: string; + size_bytes: number; +} + +export interface ImageReferenceArticleBrief { + article_id: number; + scopes: string[]; +} + +export interface ImageReferenceDetail { + total: number; + article_body: number; + article_cover: number; + articles: ImageReferenceArticleBrief[]; +} + +export interface FolderDeletePreview { + image_count: number; + referenced_articles: ImageReferenceArticleBrief[]; +} + +export interface ImageStorageUsage { + used_bytes: number; + quota_bytes: number; + used_pct: number; +} + +export interface ImageUpdateRequest { + name?: string; + folder_id?: number | null; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/shared-types/src/index.ts +git commit -m "feat(images): add shared TypeScript types for image management" +``` + +--- + +## Task 10: Frontend API Client + +**Files:** +- Modify: `apps/admin-web/src/lib/api.ts` + +- [ ] **Step 1: Add imagesApi** + +In `apps/admin-web/src/lib/api.ts`, add the import of new types at the top (inside the existing `import type { ... }` block from `@geo/shared-types`): + +Add: `ImageFolder`, `ImageAssetListResponse`, `ImageUploadResponse`, `ImageReferenceDetail`, `FolderDeletePreview`, `ImageStorageUsage`, `ImageUpdateRequest` + +Then add the `imagesApi` object after `instantTasksApi`: + +```typescript +export const imagesApi = { + listFolders() { + return apiClient.get("/api/tenant/images/folders"); + }, + createFolder(name: string) { + return apiClient.post<{ id: number }, { name: string }>("/api/tenant/images/folders", { name }); + }, + updateFolder(id: number, name: string) { + return apiClient.put(`/api/tenant/images/folders/${id}`, { name }); + }, + folderDeletePreview(id: number) { + return apiClient.get(`/api/tenant/images/folders/${id}/delete-preview`); + }, + deleteFolder(id: number, force = false) { + return apiClient.remove(`/api/tenant/images/folders/${id}${force ? "?force=1" : ""}`); + }, + list(params?: { folder_id?: number | string; q?: string; page?: number; page_size?: number }) { + return apiClient.get("/api/tenant/images", { params }); + }, + async upload(file: File, folderId?: number) { + const form = new FormData(); + form.append("file", file); + if (folderId != null) { + form.append("folder_id", String(folderId)); + } + const { data } = await apiClient.raw.post("/api/tenant/images", form); + const result = (data as any).data as ImageUploadResponse; + return { ...result, url: resolveApiURL(result.url) }; + }, + update(id: number, payload: ImageUpdateRequest) { + return apiClient.put(`/api/tenant/images/${id}`, payload); + }, + getReferences(id: number) { + return apiClient.get(`/api/tenant/images/${id}/references`); + }, + delete(id: number, force = false) { + return apiClient.remove(`/api/tenant/images/${id}${force ? "?force=1" : ""}`); + }, + storageUsage() { + return apiClient.get("/api/tenant/images/storage-usage"); + }, +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/admin-web/src/lib/api.ts +git commit -m "feat(images): add frontend API client for image management" +``` + +--- + +## Task 11: i18n Messages + +**Files:** +- Modify: `apps/admin-web/src/i18n/messages/zh-CN.ts` +- Modify: `apps/admin-web/src/i18n/messages/en-US.ts` + +- [ ] **Step 1: Add Chinese translations** + +In `apps/admin-web/src/i18n/messages/zh-CN.ts`, add `images` key inside the `nav` object: + +```typescript +images: "图片管理", +``` + +Add the route entry inside `route`: + +```typescript +images: { + title: "图片管理", + description: "管理图片素材库,支持文件夹分类和搜索。", +}, +``` + +Add top-level `images` section (at the same level as `nav`, `auth`, etc.): + +```typescript +images: { + uploadBtn: "上传图片", + folders: "文件夹", + uncategorized: "未分类", + searchPlaceholder: "搜索图片名...", + newFolder: "新建文件夹", + folderName: "文件夹名称", + rename: "改名", + move: "移动", + deleteImage: "删除", + deleteFolder: "删除文件夹", + forceDelete: "仍然删除", + confirmDeleteTitle: "确认删除", + confirmDeleteImage: "确定要删除这张图片吗?", + confirmDeleteFolder: "确定要删除此文件夹吗?", + imageInUse: "该图片被以下文章引用:", + folderHasRefs: "文件夹中包含被引用的图片", + refArticleBody: "正文引用", + refArticleCover: "封面引用", + storageUsage: "存储用量", + storageUsageDetail: "{used} / {quota}", + quotaExceeded: "存储空间不足", + uploadSuccess: "上传成功", + uploadFailed: "上传失败", + deleteSuccess: "删除成功", + folderCreated: "文件夹已创建", + folderRenamed: "文件夹已重命名", + folderDeleted: "文件夹已删除", + imageRenamed: "图片已重命名", + imageMoved: "图片已移动", + noImages: "暂无图片", + allFolders: "全部", + selectImage: "选择图片", + libraryTab: "本地素材", +}, +``` + +- [ ] **Step 2: Add English translations** + +Add equivalent keys to `apps/admin-web/src/i18n/messages/en-US.ts`: + +Nav: `images: "Images"`, route: `images: { title: "Images", description: "Manage image assets with folder organization and search." }` + +Top-level `images` section: + +```typescript +images: { + uploadBtn: "Upload Image", + folders: "Folders", + uncategorized: "Uncategorized", + searchPlaceholder: "Search images...", + newFolder: "New Folder", + folderName: "Folder Name", + rename: "Rename", + move: "Move", + deleteImage: "Delete", + deleteFolder: "Delete Folder", + forceDelete: "Delete Anyway", + confirmDeleteTitle: "Confirm Delete", + confirmDeleteImage: "Are you sure you want to delete this image?", + confirmDeleteFolder: "Are you sure you want to delete this folder?", + imageInUse: "This image is referenced by the following articles:", + folderHasRefs: "Folder contains referenced images", + refArticleBody: "Body reference", + refArticleCover: "Cover reference", + storageUsage: "Storage Usage", + storageUsageDetail: "{used} / {quota}", + quotaExceeded: "Storage quota exceeded", + uploadSuccess: "Upload successful", + uploadFailed: "Upload failed", + deleteSuccess: "Deleted successfully", + folderCreated: "Folder created", + folderRenamed: "Folder renamed", + folderDeleted: "Folder deleted", + imageRenamed: "Image renamed", + imageMoved: "Image moved", + noImages: "No images", + allFolders: "All", + selectImage: "Select Image", + libraryTab: "Image Library", +}, +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin-web/src/i18n/messages/zh-CN.ts apps/admin-web/src/i18n/messages/en-US.ts +git commit -m "feat(images): add i18n translations for image management" +``` + +--- + +## Task 12: Router + Navigation + +**Files:** +- Modify: `apps/admin-web/src/router/index.ts` +- Modify: `apps/admin-web/src/layouts/AppShell.vue` + +- [ ] **Step 1: Add route** + +In `apps/admin-web/src/router/index.ts`, add a new route inside the children array (after the `knowledge` route): + +```typescript +{ + path: "images", + name: "images", + component: () => import("@/views/ImagesView.vue"), + meta: { + titleKey: "route.images.title", + descriptionKey: "route.images.description", + navKey: "/images", + }, +}, +``` + +- [ ] **Step 2: Add nav item** + +In `apps/admin-web/src/layouts/AppShell.vue`, add the `PictureOutlined` icon import at the top: + +```typescript +import { + AppstoreOutlined, + BookOutlined, + GlobalOutlined, + PictureOutlined, + RadarChartOutlined, + SearchOutlined, + DownOutlined, + LogoutOutlined, + TranslationOutlined, +} from "@ant-design/icons-vue"; +``` + +In the `contentManagement` nav section, add the images item after knowledge: + +```typescript +{ + key: "contentManagement", + title: t("nav.contentManagement"), + items: [ + { key: "/knowledge", label: t("nav.knowledge"), icon: BookOutlined }, + { key: "/images", label: t("nav.images"), icon: PictureOutlined }, + ], +}, +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin-web/src/router/index.ts apps/admin-web/src/layouts/AppShell.vue +git commit -m "feat(images): add /images route and navigation item" +``` + +--- + +## Task 13: ImagesView Page + +**Files:** +- Create: `apps/admin-web/src/views/ImagesView.vue` + +- [ ] **Step 1: Create ImagesView** + +Create `apps/admin-web/src/views/ImagesView.vue` — a full page with: +- Left sidebar: folder list with "未分类" + user folders + create button +- Right panel: search bar + image grid (4 columns) + pagination +- Hover actions on image cards: rename, move, delete +- Delete confirmation with reference warning +- Storage usage bar + +The component uses TanStack Query for data fetching and Ant Design Vue components. Due to plan length, the implementation should follow the spec layout exactly: + +``` +┌─────────────────────────────────────────────────────┐ +│ 图片管理 [+ 上传图片] │ +├──────────────┬──────────────────────────────────────┤ +│ 文件夹 [+] │ 🔍 搜索图片名... │ +│──────────────│──────────────────────────────────────│ +│ 📁 未分类(12)│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ 📁 产品图(8) │ │img │ │img │ │img │ │img │ │ +│ 📁 博客配图 │ └────┘ └────┘ └────┘ └────┘ │ +│ │ │ +│ │ 文件名 · 大小 · 日期 [改名] [移动] │ +└──────────────┴──────────────────────────────────────┘ +``` + +Key implementation details: +- Use `useQuery` with queryKey `["images", "list", { folder_id, q, page }]` and `["images", "folders"]` +- Use `useMutation` for upload, delete, rename, move, folder CRUD +- Debounced search input (300ms) +- When search query is active, disable folder sidebar selection +- `` with custom request for file upload (multipart) +- Image grid uses CSS Grid with 4 columns +- Delete flow: GET references first, if refs exist show warning modal, user can force-delete + +The complete Vue SFC should be self-contained (~400-500 lines). The worker implementing this task should build the full component following these specifications and the existing codebase patterns (see `KnowledgeView.vue` for a similar sidebar + content layout pattern). + +- [ ] **Step 2: Start dev server and test the page** + +```bash +cd apps/admin-web && npm run dev +``` + +Navigate to `http://localhost:5173/images` and verify: +- Folder sidebar renders +- Image grid renders (empty state if no images) +- Upload works (file is converted to WebP on server) +- Folder CRUD works +- Search works with debounce +- Delete with reference warning works + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin-web/src/views/ImagesView.vue +git commit -m "feat(images): add ImagesView page with folder sidebar and image grid" +``` + +--- + +## Task 14: Image Library Picker Component + +**Files:** +- Create: `apps/admin-web/src/components/ImageLibraryPicker.vue` + +- [ ] **Step 1: Create ImageLibraryPicker** + +Create `apps/admin-web/src/components/ImageLibraryPicker.vue` — a reusable picker component that can be embedded in modals: + +Props: +- `singleSelect: boolean` (default true) — for cover picker, only one image can be selected +- `onSelect: (image: ImageAssetItem) => void` — callback when image is selected + +Features: +- Folder dropdown filter (uses `imagesApi.listFolders`) +- Search input with 300ms debounce +- Image grid (4 columns, scrollable) +- Click-to-select (highlight border on selected) +- Pagination +- Storage usage bar at bottom + +This component is used by: +1. CoverPickerModal "本地素材" tab +2. (Future) Article editor "从图片库选择" flow + +The component loads images via `imagesApi.list()` and renders them in a grid. Selected image has a blue border highlight. Storage usage bar at the bottom shows `imagesApi.storageUsage()`. + +- [ ] **Step 2: Commit** + +```bash +git add apps/admin-web/src/components/ImageLibraryPicker.vue +git commit -m "feat(images): add reusable ImageLibraryPicker component" +``` + +--- + +## Task 15: Cover Picker Modal Integration + +**Files:** +- Modify: `apps/admin-web/src/components/CoverPickerModal.vue` + +- [ ] **Step 1: Replace AI tab with library picker tab** + +In `apps/admin-web/src/components/CoverPickerModal.vue`: + +1. Import the `ImageLibraryPicker` component: +```typescript +import ImageLibraryPicker from "@/components/ImageLibraryPicker.vue"; +``` + +2. Add a ref for the selected library image: +```typescript +const selectedLibraryImage = ref<{ url: string; assetId: number } | null>(null); +``` + +3. Replace the `` block with: +```html + + +
+ + {{ t("common.confirm") }} + +
+
+``` + +4. Add handlers: +```typescript +function handleLibrarySelect(image: { id: number; url: string; name: string }) { + selectedLibraryImage.value = { url: image.url, assetId: image.id }; +} + +function confirmLibraryImage() { + if (!selectedLibraryImage.value) return; + + if (aspectRatioLocked.value) { + // Baijiahao: route into crop workspace + workingUrl.value = selectedLibraryImage.value.url; + workingFileName.value = "library-cover"; + sourceKind.value = "remote"; + activeTab.value = "local"; + void loadImageMeta(selectedLibraryImage.value.url); + return; + } + + // Non-crop platforms: use library image directly + emit("confirmed", { + url: selectedLibraryImage.value.url, + fileName: "library-cover", + }); + emit("update:open", false); +} +``` + +5. Update the i18n key for the tab label — the `t('coverPicker.tabs.ai')` is now replaced with `t('images.libraryTab')`. + +- [ ] **Step 2: Test in browser** + +Start the dev server and navigate to an article editor. Open the cover picker modal. Verify: +- "AI封面" tab is replaced with "本地素材" tab +- Library tab shows image grid from the library +- Selecting an image and clicking confirm works +- For Baijiahao platforms, selecting routes to the crop workspace +- For other platforms, the library image URL is used directly + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin-web/src/components/CoverPickerModal.vue +git commit -m "feat(images): replace AI cover tab with image library picker in CoverPickerModal" +``` + +--- + +## Task 16: Article Update Contract (Image References) + +**Files:** +- Modify: `packages/shared-types/src/index.ts` (UpdateArticleRequest) +- Modify: `server/internal/tenant/app/article_service.go` (Update method) +- Modify: `server/internal/tenant/transport/article_handler.go` (Update handler) + +- [ ] **Step 1: Extend UpdateArticleRequest type** + +In `packages/shared-types/src/index.ts`, modify `UpdateArticleRequest`: + +```typescript +export interface UpdateArticleRequest { + title: string; + markdown_content: string; + platforms?: string[]; + cover_asset_url?: string | null; + referenced_image_asset_ids?: number[]; + cover_image_asset_id?: number | null; +} +``` + +- [ ] **Step 2: Handle image references in article update (backend)** + +In `server/internal/tenant/app/article_service.go`, in the `Update` method (or the handler that processes article updates), after the existing article update logic, add image reference upsert: + +```go +// After the existing article update logic: +if len(input.ReferencedImageAssetIDs) > 0 || input.CoverImageAssetID != nil { + refRepo := repository.NewImageReferenceRepository(s.pool) + + // Clear existing body references for this article, then re-insert + _ = refRepo.DeleteByArticleScope(ctx, tenantID, articleID, "article_body") + for _, assetID := range input.ReferencedImageAssetIDs { + _ = refRepo.Upsert(ctx, tenantID, assetID, articleID, "article_body") + } + + // Handle cover reference + _ = refRepo.DeleteByArticleScope(ctx, tenantID, articleID, "article_cover") + if input.CoverImageAssetID != nil && *input.CoverImageAssetID > 0 { + _ = refRepo.Upsert(ctx, tenantID, *input.CoverImageAssetID, articleID, "article_cover") + } +} +``` + +The exact integration point depends on the existing `Update` method signature. The worker should read the current `Update` method and add the reference handling at the end, accepting `referenced_image_asset_ids` and `cover_image_asset_id` from the JSON request body. + +- [ ] **Step 3: Commit** + +```bash +git add packages/shared-types/src/index.ts server/internal/tenant/app/article_service.go server/internal/tenant/transport/article_handler.go +git commit -m "feat(images): add image reference tracking on article save" +``` + +--- + +## Deferred Items (Not in This Plan) + +The following items from the spec are deferred to follow-up work: + +1. **Article editor toolbar integration** — Adding "从图片库选择" dropdown to the Milkdown editor toolbar requires deeper integration with the editor plugin system. The ImageLibraryPicker component is ready to be embedded; the editor toolbar wiring can be done in a follow-up. + +2. **RabbitMQ consumer for finalize/compensation** — The spec describes async compensation consumers for `finalize_upload` and `finalize_delete`. The current implementation handles finalization synchronously in the request thread (mark active after successful PutBytes, mark deleted after successful Delete). The async compensation path via MQ is infrastructure for edge-case recovery and can be added in a follow-up without changing the API contract. + +3. **Folder delete with image finalization** — Currently folder delete marks images as `pending_delete` but does not trigger the OSS deletion of each image. A follow-up should add a background job that processes `pending_delete` images. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 25198af..7df3dc3 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -256,6 +256,7 @@ export interface ArticleDetail { generation_mode: string | null; platforms: string[]; cover_asset_url: string | null; + cover_image_asset_id: number | null; generate_status: string; publish_status: string; title: string | null; @@ -278,6 +279,66 @@ export interface UpdateArticleRequest { markdown_content: string; platforms?: string[]; cover_asset_url?: string | null; + referenced_image_asset_ids?: number[]; + cover_image_asset_id?: number | null; +} + +export interface ImageFolder { + id: number; + name: string; + image_count: number; + created_at: string; +} + +export interface ImageAssetItem { + id: number; + folder_id: number | null; + name: string; + url: string; + size_bytes: number; + created_at: string; +} + +export interface ImageAssetListResponse { + items: ImageAssetItem[]; + total: number; +} + +export interface ImageUploadResponse { + id: number; + url: string; + name: string; + size_bytes: number; + reused?: boolean; +} + +export interface ImageReferenceArticleBrief { + article_id: number; + title: string; + scopes: string[]; +} + +export interface ImageReferenceDetail { + total: number; + article_body: number; + article_cover: number; + articles: ImageReferenceArticleBrief[]; +} + +export interface FolderDeletePreview { + image_count: number; + referenced_articles: ImageReferenceArticleBrief[]; +} + +export interface ImageStorageUsage { + used_bytes: number; + quota_bytes: number; + used_pct: number; +} + +export interface ImageUpdateRequest { + name?: string; + folder_id?: number | null; } export interface ArticleImageUploadResponse { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cb8926..ab5cadb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@geo/shared-types': specifier: workspace:* version: link:../../packages/shared-types + '@jsquash/webp': + specifier: ^1.5.0 + version: 1.5.0 '@milkdown/crepe': specifier: ^7.20.0 version: 7.20.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(typescript@5.9.3) @@ -499,6 +502,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsquash/webp@1.5.0': + resolution: {integrity: sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==} + '@lezer/common@1.5.1': resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} @@ -2693,6 +2699,9 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -3246,6 +3255,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsquash/webp@1.5.0': + dependencies: + wasm-feature-detect: 1.8.0 + '@lezer/common@1.5.1': {} '@lezer/cpp@1.1.5': @@ -5883,6 +5896,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + wasm-feature-detect@1.8.0: {} + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 diff --git a/server/cmd/tenant-api/main.go b/server/cmd/tenant-api/main.go index 2dd8758..6185d14 100644 --- a/server/cmd/tenant-api/main.go +++ b/server/cmd/tenant-api/main.go @@ -33,6 +33,8 @@ func main() { monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger) tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx) tenantapp.NewMonitoringProjectionRebuildWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx) + imageService := tenantapp.NewImageService(app.DB, app.ObjectStorage, app.RabbitMQ, app.Logger) + tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx) transport.RegisterRoutes(app) diff --git a/server/go.mod b/server/go.mod index d4f4e45..ce6f845 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,6 +5,7 @@ go 1.22.2 require ( github.com/alicebob/miniredis/v2 v2.37.0 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible + github.com/chai2010/webp v1.4.0 github.com/extrame/xls v0.0.1 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.1 @@ -15,6 +16,7 @@ require ( github.com/qdrant/go-client v1.13.0 github.com/rabbitmq/amqp091-go v1.10.0 github.com/redis/go-redis/v9 v9.5.1 + github.com/robfig/cron/v3 v3.0.1 github.com/sergi/go-diff v1.4.0 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 @@ -22,7 +24,10 @@ require ( github.com/xuri/excelize/v2 v2.8.1 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.31.0 + golang.org/x/image v0.14.0 golang.org/x/net v0.33.0 + golang.org/x/sync v0.10.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -61,7 +66,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.3 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -79,7 +83,6 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect @@ -88,5 +91,4 @@ require ( google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index 1fa9951..3ec8e2d 100644 --- a/server/go.sum +++ b/server/go.sum @@ -15,6 +15,8 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= +github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= @@ -221,6 +223,8 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -236,6 +240,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -244,6 +250,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 2e57247..3f42ae2 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -80,6 +80,12 @@ type RabbitMQConfig struct { TemplateAssistDLX string `mapstructure:"template_assist_dlx"` TemplateAssistDLQ string `mapstructure:"template_assist_dlq"` TemplateAssistDLQRouteKey string `mapstructure:"template_assist_dlq_routing_key"` + ImageAssetExchange string `mapstructure:"image_asset_exchange"` + ImageAssetRoutingKey string `mapstructure:"image_asset_routing_key"` + ImageAssetQueue string `mapstructure:"image_asset_queue"` + ImageAssetDLX string `mapstructure:"image_asset_dlx"` + ImageAssetDLQ string `mapstructure:"image_asset_dlq"` + ImageAssetDLQRouteKey string `mapstructure:"image_asset_dlq_routing_key"` ConsumerPrefetch int `mapstructure:"consumer_prefetch"` PublishChannelPoolSize int `mapstructure:"publish_channel_pool_size"` } @@ -337,6 +343,24 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) { if strings.TrimSpace(cfg.TemplateAssistDLQRouteKey) == "" { cfg.TemplateAssistDLQRouteKey = "template.assist.run.dlq" } + if strings.TrimSpace(cfg.ImageAssetExchange) == "" { + cfg.ImageAssetExchange = "image.asset" + } + if strings.TrimSpace(cfg.ImageAssetRoutingKey) == "" { + cfg.ImageAssetRoutingKey = "image.asset.finalize" + } + if strings.TrimSpace(cfg.ImageAssetQueue) == "" { + cfg.ImageAssetQueue = "image.asset.finalize" + } + if strings.TrimSpace(cfg.ImageAssetDLX) == "" { + cfg.ImageAssetDLX = "image.asset.dlx" + } + if strings.TrimSpace(cfg.ImageAssetDLQ) == "" { + cfg.ImageAssetDLQ = "image.asset.finalize.dlq" + } + if strings.TrimSpace(cfg.ImageAssetDLQRouteKey) == "" { + cfg.ImageAssetDLQRouteKey = "image.asset.finalize.dlq" + } if cfg.PublishChannelPoolSize <= 0 { cfg.PublishChannelPoolSize = 16 } diff --git a/server/internal/shared/messaging/rabbitmq/client.go b/server/internal/shared/messaging/rabbitmq/client.go index a5c53fb..c1c95d1 100644 --- a/server/internal/shared/messaging/rabbitmq/client.go +++ b/server/internal/shared/messaging/rabbitmq/client.go @@ -102,6 +102,10 @@ func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) err return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body) } +func (c *Client) PublishImageAssetTask(ctx context.Context, body []byte) error { + return c.publish(ctx, c.cfg.ImageAssetExchange, c.cfg.ImageAssetRoutingKey, body) +} + func (c *Client) publish(ctx context.Context, exchange, routingKey string, body []byte) error { for attempt := 0; attempt < 2; attempt++ { conn, ch, pooled, err := c.acquirePublishChannel() @@ -176,6 +180,10 @@ func (c *Client) ConsumeTemplateAssistTask(consumerName string) (<-chan amqp.Del return c.consume(consumerName, c.cfg.TemplateAssistQueue) } +func (c *Client) ConsumeImageAssetTask(consumerName string) (<-chan amqp.Delivery, *amqp.Channel, error) { + return c.consume(consumerName, c.cfg.ImageAssetQueue) +} + func (c *Client) consume(consumerName, queue string) (<-chan amqp.Delivery, *amqp.Channel, error) { for attempt := 0; attempt < 2; attempt++ { conn, ch, err := c.openChannel() @@ -554,6 +562,18 @@ func (c *Client) ensureTopology(conn *amqp.Connection) error { return err } + if err := c.ensureWorkQueue( + ch, + c.cfg.ImageAssetExchange, + c.cfg.ImageAssetRoutingKey, + c.cfg.ImageAssetQueue, + c.cfg.ImageAssetDLX, + c.cfg.ImageAssetDLQ, + c.cfg.ImageAssetDLQRouteKey, + ); err != nil { + return err + } + return nil } diff --git a/server/internal/shared/objectstorage/aliyun.go b/server/internal/shared/objectstorage/aliyun.go index 84d220d..6212ef2 100644 --- a/server/internal/shared/objectstorage/aliyun.go +++ b/server/internal/shared/objectstorage/aliyun.go @@ -158,6 +158,36 @@ func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, return data, nil } +func (c *aliyunClient) Exists(ctx context.Context, objectKey string) (bool, error) { + if err := c.Validate(); err != nil { + return false, err + } + if strings.TrimSpace(objectKey) == "" { + return false, nil + } + + bucket, err := c.client.Bucket(c.bucket) + if err != nil { + c.logError(ctx, "aliyun get bucket failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return false, fmt.Errorf("get aliyun bucket: %w", err) + } + + exists, err := bucket.IsObjectExist(objectKey) + if err != nil { + c.logError(ctx, "aliyun stat object failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return false, fmt.Errorf("stat aliyun object: %w", err) + } + return exists, nil +} + func (c *aliyunClient) Delete(ctx context.Context, objectKey string) error { if err := c.Validate(); err != nil { return err diff --git a/server/internal/shared/objectstorage/client.go b/server/internal/shared/objectstorage/client.go index b055b35..57ead75 100644 --- a/server/internal/shared/objectstorage/client.go +++ b/server/internal/shared/objectstorage/client.go @@ -17,6 +17,7 @@ type Client interface { Validate() error PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error GetBytes(ctx context.Context, objectKey string) ([]byte, error) + Exists(ctx context.Context, objectKey string) (bool, error) Delete(ctx context.Context, objectKey string) error PublicURL(objectKey string) (string, error) } @@ -54,6 +55,10 @@ func (c disabledClient) GetBytes(context.Context, string) ([]byte, error) { return nil, c.Validate() } +func (c disabledClient) Exists(context.Context, string) (bool, error) { + return false, c.Validate() +} + func (c disabledClient) Delete(context.Context, string) error { return c.Validate() } diff --git a/server/internal/shared/objectstorage/minio.go b/server/internal/shared/objectstorage/minio.go index 5ccab07..7664473 100644 --- a/server/internal/shared/objectstorage/minio.go +++ b/server/internal/shared/objectstorage/minio.go @@ -135,6 +135,32 @@ func (c *minioClient) GetBytes(ctx context.Context, objectKey string) ([]byte, e return data, nil } +func (c *minioClient) Exists(ctx context.Context, objectKey string) (bool, error) { + if err := c.Validate(); err != nil { + return false, err + } + if strings.TrimSpace(objectKey) == "" { + return false, nil + } + + _, err := c.client.StatObject(ctx, c.bucket, objectKey, minio.StatObjectOptions{}) + if err == nil { + return true, nil + } + + responseErr := minio.ToErrorResponse(err) + if responseErr.StatusCode == 404 || responseErr.Code == "NoSuchKey" || responseErr.Code == "NoSuchObject" { + return false, nil + } + + c.logError(ctx, "minio stat object failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return false, fmt.Errorf("stat minio object: %w", err) +} + func (c *minioClient) Delete(ctx context.Context, objectKey string) error { if err := c.Validate(); err != nil { return err diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index 7172d2c..96f06a6 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -1,20 +1,27 @@ package app import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "net/http" "path/filepath" "strconv" "strings" "time" + "github.com/chai2010/webp" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/sync/singleflight" + _ "golang.org/x/image/webp" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "github.com/geo-platform/tenant-api/internal/shared/auth" @@ -106,6 +113,7 @@ type ArticleDetailResponse struct { GenerationMode *string `json:"generation_mode"` Platforms []string `json:"platforms"` CoverAssetURL *string `json:"cover_asset_url"` + CoverImageAssetID *int64 `json:"cover_image_asset_id"` GenerateStatus string `json:"generate_status"` PublishStatus string `json:"publish_status"` Title *string `json:"title"` @@ -345,6 +353,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON) item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON) item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON) + item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON) item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount) return &item, true, nil } @@ -404,10 +413,12 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) ( } type UpdateArticleRequest struct { - Title string `json:"title"` - MarkdownContent string `json:"markdown_content"` - Platforms []string `json:"platforms"` - CoverAssetURL *string `json:"cover_asset_url"` + Title string `json:"title"` + MarkdownContent string `json:"markdown_content"` + Platforms []string `json:"platforms"` + CoverAssetURL *string `json:"cover_asset_url"` + ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"` + CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"` } type ArticleImageUploadResponse struct { @@ -426,6 +437,14 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle } markdown := strings.TrimSpace(req.MarkdownContent) + referencedImageAssetIDs, err := normalizeImageAssetIDs(req.ReferencedImageAssetIDs) + if err != nil { + return nil, err + } + coverImageAssetID, err := normalizeOptionalImageAssetID(req.CoverImageAssetID) + if err != nil { + return nil, err + } tx, err := s.pool.Begin(ctx) if err != nil { @@ -449,6 +468,11 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited") } + imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID) + if err := s.ensureActiveImageAssets(ctx, tx, actor.TenantID, imageAssetIDsToValidate); err != nil { + return nil, err + } + var nextVersionNo int if err := tx.QueryRow(ctx, ` SELECT COALESCE(MAX(version_no), 0) + 1 @@ -460,7 +484,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle wordCount := countArticleWords(markdown) sourceLabel := "手动编辑" - nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL) + nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL, req.CoverImageAssetID) if err != nil { return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata") } @@ -495,6 +519,28 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle return nil, response.ErrNotFound(40411, "article_not_found", "article not found") } + imageRefRepo := repository.NewImageReferenceRepository(tx) + if req.ReferencedImageAssetIDs != nil { + if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_body"); err != nil { + return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references") + } + for _, imageAssetID := range referencedImageAssetIDs { + if err := imageRefRepo.Upsert(ctx, actor.TenantID, imageAssetID, id, "article_body"); err != nil { + return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references") + } + } + } + if req.CoverImageAssetID.Set { + if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_cover"); err != nil { + return nil, response.ErrInternal(50012, "update_failed", "failed to update article cover reference") + } + if coverImageAssetID != nil { + if err := imageRefRepo.Upsert(ctx, actor.TenantID, *coverImageAssetID, id, "article_cover"); err != nil { + return nil, response.ErrInternal(50012, "update_failed", "failed to update article cover reference") + } + } + } + if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update") } @@ -526,6 +572,19 @@ func (s *ArticleService) UploadImage( if !ok { return nil, response.ErrBadRequest(40018, "article_image_type_not_supported", "only PNG, JPG, GIF, and WebP images are supported") } + imageConfig, _, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return nil, response.ErrBadRequest(40016, "invalid_image", "image file cannot be decoded") + } + if imageConfig.Width <= 0 || imageConfig.Height <= 0 { + return nil, response.ErrBadRequest(40016, "invalid_image", "image file cannot be decoded") + } + if imageConfig.Width > maxImageDimension || imageConfig.Height > maxImageDimension { + return nil, response.ErrBadRequest(40018, "article_image_dimensions_too_large", "image dimensions exceed the maximum allowed size") + } + if int64(imageConfig.Width)*int64(imageConfig.Height) > maxImageDecodePixels { + return nil, response.ErrBadRequest(40018, "article_image_dimensions_too_large", "image pixel count exceeds the maximum allowed") + } generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID) if err != nil { @@ -535,8 +594,25 @@ func (s *ArticleService) UploadImage( return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited") } + storedContent := content + storedContentType := contentType + if contentType != "image/webp" { + decodedImage, _, decodeErr := image.Decode(bytes.NewReader(content)) + if decodeErr != nil { + return nil, response.ErrBadRequest(40016, "invalid_image", "image file cannot be decoded") + } + + var webpBuffer bytes.Buffer + if encodeErr := webp.Encode(&webpBuffer, decodedImage, &webp.Options{Quality: float32(webpEncodeQuality)}); encodeErr != nil { + return nil, response.ErrInternal(50013, "article_image_encode_failed", "failed to encode article image") + } + storedContent = webpBuffer.Bytes() + storedContentType = "image/webp" + resolvedExt = "webp" + } + objectKey := buildArticleImageObjectKey(actor.TenantID, articleID, fileName, resolvedExt) - if err := s.objectStorage.PutBytes(ctx, objectKey, content, contentType); err != nil { + if err := s.objectStorage.PutBytes(ctx, objectKey, storedContent, storedContentType); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } @@ -546,8 +622,8 @@ func (s *ArticleService) UploadImage( return &ArticleImageUploadResponse{ URL: "", ObjectKey: objectKey, - ContentType: contentType, - SizeBytes: int64(len(content)), + ContentType: storedContentType, + SizeBytes: int64(len(storedContent)), }, nil } @@ -602,13 +678,25 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error { _ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID).Scan(&beforeJSON) - tag, err := s.pool.Exec(ctx, ` + tx, err := s.pool.Begin(ctx) + if err != nil { + return response.ErrInternal(50010, "delete_failed", "failed to begin article delete transaction") + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, ` UPDATE articles SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40411, "article_not_found", "article not found") } + if err := repository.NewImageReferenceRepository(tx).DeleteByArticle(ctx, actor.TenantID, id); err != nil { + return response.ErrInternal(50010, "delete_failed", "failed to delete article image references") + } + if err := tx.Commit(ctx); err != nil { + return response.ErrInternal(50010, "delete_failed", "failed to commit article delete") + } result := "success" resourceType := "article" @@ -633,6 +721,97 @@ func countArticleWords(input string) int { return contentstats.CountWords(input) } +func normalizeImageAssetIDs(ids []int64) ([]int64, error) { + if ids == nil { + return nil, nil + } + + seen := make(map[int64]struct{}, len(ids)) + normalized := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + return nil, response.ErrBadRequest(40019, "invalid_image_asset_ids", "image asset ids must be positive integers") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + + if len(normalized) == 0 { + return []int64{}, nil + } + return normalized, nil +} + +func normalizeOptionalImageAssetID(input NullableInt64Input) (*int64, error) { + if !input.Set || input.Value == nil { + return input.Value, nil + } + if *input.Value <= 0 { + return nil, response.ErrBadRequest(40020, "invalid_cover_image_asset_id", "cover_image_asset_id must be a positive integer or null") + } + return input.Value, nil +} + +func collectImageAssetIDsForValidation(bodyImageIDs []int64, coverImageAssetID *int64) []int64 { + if len(bodyImageIDs) == 0 && coverImageAssetID == nil { + return nil + } + + seen := make(map[int64]struct{}, len(bodyImageIDs)+1) + result := make([]int64, 0, len(bodyImageIDs)+1) + for _, id := range bodyImageIDs { + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + if coverImageAssetID != nil { + if _, exists := seen[*coverImageAssetID]; !exists { + result = append(result, *coverImageAssetID) + } + } + return result +} + +func (s *ArticleService) ensureActiveImageAssets(ctx context.Context, tx pgx.Tx, tenantID int64, imageAssetIDs []int64) error { + if len(imageAssetIDs) == 0 { + return nil + } + + rows, err := tx.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) + if err != nil { + return response.ErrInternal(50012, "update_failed", "failed to validate image assets") + } + defer rows.Close() + + validIDs := make(map[int64]struct{}, len(imageAssetIDs)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return response.ErrInternal(50012, "update_failed", "failed to validate image assets") + } + validIDs[id] = struct{}{} + } + if err := rows.Err(); err != nil { + return response.ErrInternal(50012, "update_failed", "failed to validate image assets") + } + if len(validIDs) != len(imageAssetIDs) { + return response.ErrBadRequest(40021, "invalid_image_asset_ids", "one or more image assets are invalid or inactive") + } + return nil +} + func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) { var generateStatus string if err := s.pool.QueryRow(ctx, ` @@ -664,6 +843,11 @@ func buildArticleImageObjectKey(tenantID int64, articleID int64, fileName string } func normalizeArticleImageExtension(fileName string, fallbackExt string) string { + fallbackExt = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(fallbackExt)), ".") + if fallbackExt == "webp" { + return "webp" + } + extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))), ".") if extension != "" { switch extension { diff --git a/server/internal/tenant/app/image_asset_task.go b/server/internal/tenant/app/image_asset_task.go new file mode 100644 index 0000000..f434207 --- /dev/null +++ b/server/internal/tenant/app/image_asset_task.go @@ -0,0 +1,70 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" +) + +const ( + imageAssetTaskKindFinalizeUpload = "finalize_upload" + imageAssetTaskKindFinalizeDelete = "finalize_delete" +) + +type imageAssetTaskEnvelope struct { + Kind string `json:"kind"` + TenantID int64 `json:"tenant_id"` + AssetID int64 `json:"asset_id"` +} + +type ImageAssetTaskEnvelope struct { + Kind string `json:"kind"` + TenantID int64 `json:"tenant_id"` + AssetID int64 `json:"asset_id"` +} + +func publishImageAssetTask(ctx context.Context, client *rabbitmq.Client, envelope imageAssetTaskEnvelope) error { + if client == nil { + return fmt.Errorf("rabbitmq client is not configured") + } + + payload, err := json.Marshal(envelope) + if err != nil { + return fmt.Errorf("marshal image asset task envelope: %w", err) + } + + if err := client.PublishImageAssetTask(ctx, payload); err != nil { + return fmt.Errorf("publish image asset task: %w", err) + } + return nil +} + +func decodeImageAssetTaskEnvelope(payload []byte) (*imageAssetTaskEnvelope, error) { + var envelope imageAssetTaskEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + return nil, fmt.Errorf("decode image asset task envelope: %w", err) + } + if envelope.TenantID <= 0 || envelope.AssetID <= 0 { + return nil, fmt.Errorf("image asset task envelope is incomplete") + } + switch envelope.Kind { + case imageAssetTaskKindFinalizeUpload, imageAssetTaskKindFinalizeDelete: + default: + return nil, fmt.Errorf("unsupported image asset task kind %q", envelope.Kind) + } + return &envelope, nil +} + +func DecodeImageAssetTaskEnvelope(payload []byte) (*ImageAssetTaskEnvelope, error) { + envelope, err := decodeImageAssetTaskEnvelope(payload) + if err != nil { + return nil, err + } + return &ImageAssetTaskEnvelope{ + Kind: envelope.Kind, + TenantID: envelope.TenantID, + AssetID: envelope.AssetID, + }, nil +} diff --git a/server/internal/tenant/app/image_asset_worker.go b/server/internal/tenant/app/image_asset_worker.go new file mode 100644 index 0000000..bacb267 --- /dev/null +++ b/server/internal/tenant/app/image_asset_worker.go @@ -0,0 +1,167 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + amqp "github.com/rabbitmq/amqp091-go" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +const ( + defaultImageAssetWorkerRetryInterval = 5 * time.Second + defaultImageAssetWorkerProcessTimeout = 2 * time.Minute +) + +var errImageAssetConsumerClosed = errors.New("image asset consumer channel closed") + +type ImageAssetWorker struct { + rabbitMQ *rabbitmq.Client + service *ImageService + logger *zap.Logger + retryInterval time.Duration + processTimeout time.Duration + consumerPrefix string + concurrency int +} + +func NewImageAssetWorker(service *ImageService, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageAssetWorker { + return &ImageAssetWorker{ + rabbitMQ: rabbitMQClient, + service: service, + logger: logger, + retryInterval: defaultImageAssetWorkerRetryInterval, + processTimeout: defaultImageAssetWorkerProcessTimeout, + consumerPrefix: fmt.Sprintf("tenant-api-image-asset-%d", os.Getpid()), + concurrency: 1, + } +} + +func (w *ImageAssetWorker) Start(ctx context.Context) { + if w == nil { + return + } + for i := 0; i < w.concurrency; i++ { + consumerName := fmt.Sprintf("%s-%d", w.consumerPrefix, i+1) + go w.run(ctx, consumerName) + } +} + +func (w *ImageAssetWorker) run(ctx context.Context, consumerName string) { + if w.rabbitMQ == nil || w.service == nil { + return + } + + for { + if err := w.consumeOnce(ctx, consumerName); err != nil && ctx.Err() == nil { + if w.logger != nil { + if errors.Is(err, errImageAssetConsumerClosed) { + w.logger.Info("image asset consumer channel closed, retrying", + zap.String("consumer", consumerName), + ) + } else { + w.logger.Warn("image asset worker stopped unexpectedly", + zap.Error(err), + zap.String("consumer", consumerName), + ) + } + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(w.retryInterval): + } + } +} + +func (w *ImageAssetWorker) consumeOnce(ctx context.Context, consumerName string) error { + deliveries, ch, err := w.rabbitMQ.ConsumeImageAssetTask(consumerName) + if err != nil { + return err + } + defer ch.Close() + + for { + select { + case <-ctx.Done(): + return nil + case delivery, ok := <-deliveries: + if !ok { + return errImageAssetConsumerClosed + } + w.handleDelivery(ctx, delivery) + } + } +} + +func (w *ImageAssetWorker) handleDelivery(parent context.Context, delivery amqp.Delivery) { + envelope, err := decodeImageAssetTaskEnvelope(delivery.Body) + if err != nil { + w.rejectDelivery(delivery, false, err, 0, 0, "") + return + } + + ctx, cancel := context.WithTimeout(parent, w.processTimeout) + defer cancel() + + switch envelope.Kind { + case imageAssetTaskKindFinalizeUpload: + err = w.service.ProcessFinalizeUpload(ctx, envelope.TenantID, envelope.AssetID) + case imageAssetTaskKindFinalizeDelete: + err = w.service.ProcessFinalizeDelete(ctx, envelope.TenantID, envelope.AssetID) + default: + err = fmt.Errorf("unsupported image asset task kind %q", envelope.Kind) + } + if err != nil { + requeue := shouldRequeueImageAssetError(err, delivery.Redelivered) + w.rejectDelivery(delivery, requeue, err, envelope.TenantID, envelope.AssetID, envelope.Kind) + return + } + + if err := delivery.Ack(false); err != nil && w.logger != nil { + w.logger.Warn("image asset ack failed", + zap.Error(err), + zap.Int64("tenant_id", envelope.TenantID), + zap.Int64("asset_id", envelope.AssetID), + zap.String("kind", envelope.Kind), + ) + } +} + +func (w *ImageAssetWorker) rejectDelivery(delivery amqp.Delivery, requeue bool, err error, tenantID, assetID int64, kind string) { + if nackErr := delivery.Nack(false, requeue); nackErr != nil && w.logger != nil { + w.logger.Warn("image asset nack failed", + zap.Error(nackErr), + zap.Int64("tenant_id", tenantID), + zap.Int64("asset_id", assetID), + zap.String("kind", kind), + ) + } + if w.logger != nil { + w.logger.Warn("image asset processing failed", + zap.Error(err), + zap.Bool("requeue", requeue), + zap.Bool("redelivered", delivery.Redelivered), + zap.Int64("tenant_id", tenantID), + zap.Int64("asset_id", assetID), + zap.String("kind", kind), + ) + } +} + +func shouldRequeueImageAssetError(err error, redelivered bool) bool { + if redelivered { + return false + } + + appErr := response.Normalize(err) + return appErr.HTTPStatus >= 500 +} diff --git a/server/internal/tenant/app/image_folder_service.go b/server/internal/tenant/app/image_folder_service.go new file mode 100644 index 0000000..efd0116 --- /dev/null +++ b/server/internal/tenant/app/image_folder_service.go @@ -0,0 +1,215 @@ +package app + +import ( + "context" + "errors" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "go.uber.org/zap" + + "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" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type ImageFolderService struct { + pool *pgxpool.Pool + imageSvc *ImageService +} + +func NewImageFolderService(pool *pgxpool.Pool, objectStorage objectstorage.Client, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageFolderService { + return &ImageFolderService{ + pool: pool, + imageSvc: NewImageService(pool, objectStorage, rabbitMQClient, logger), + } +} + +type ImageFolderItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + ImageCount int `json:"image_count"` + CreatedAt string `json:"created_at"` +} + +type FolderDeletePreview struct { + ImageCount int `json:"image_count"` + ReferencedList []ImageReferenceArticleBrief `json:"referenced_articles"` +} + +type ImageReferenceArticleBrief struct { + ArticleID int64 `json:"article_id"` + Scopes []string `json:"scopes"` +} + +func (s *ImageFolderService) List(ctx context.Context) ([]ImageFolderItem, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageFolderRepository(s.pool) + + folders, err := repo.List(ctx, tenantID) + if err != nil { + return nil, err + } + + items := make([]ImageFolderItem, len(folders)) + for i, folder := range folders { + items[i] = ImageFolderItem{ + ID: folder.ID, + Name: folder.Name, + ImageCount: folder.ImageCount, + CreatedAt: folder.CreatedAt.Format(timeLayoutRFC3339), + } + } + if items == nil { + items = []ImageFolderItem{} + } + return items, nil +} + +func (s *ImageFolderService) Create(ctx context.Context, name string) (int64, error) { + name = strings.TrimSpace(name) + if err := validateImageFolderName(name); err != nil { + return 0, err + } + + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageFolderRepository(s.pool) + + id, err := repo.Create(ctx, tenantID, name) + if err != nil { + if isConstraintError(err, "uq_image_folders_tenant_name") { + return 0, response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists") + } + return 0, err + } + return id, nil +} + +func (s *ImageFolderService) Update(ctx context.Context, id int64, name string) error { + name = strings.TrimSpace(name) + if err := validateImageFolderName(name); err != nil { + return err + } + + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageFolderRepository(s.pool) + if _, err := repo.Get(ctx, tenantID, id); err != nil { + return mapImageFolderNotFound(err) + } + + if err := repo.Update(ctx, tenantID, id, name); err != nil { + if isConstraintError(err, "uq_image_folders_tenant_name") { + return response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists") + } + return err + } + return nil +} + +func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64) (*FolderDeletePreview, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + folderRepo := repository.NewImageFolderRepository(s.pool) + + if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil { + return nil, mapImageFolderNotFound(err) + } + + imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID) + if err != nil { + return nil, err + } + + return &FolderDeletePreview{ + ImageCount: imageCount, + ReferencedList: []ImageReferenceArticleBrief{}, + }, nil +} + +func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force bool) error { + tenantID := middleware.TenantIDFromCtx(ctx) + folderRepo := repository.NewImageFolderRepository(s.pool) + imageRepo := repository.NewImageRepository(s.pool) + + if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil { + return mapImageFolderNotFound(err) + } + + imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID) + if err != nil { + return err + } + if imageCount == 0 { + return folderRepo.Delete(ctx, tenantID, folderID) + } + + if !force { + return response.ErrConflict(40902, "folder_not_empty", "folder contains images that will be deleted with the folder") + } + + if s.imageSvc != nil { + if err := s.imageSvc.objectStorage.Validate(); err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return response.ErrInternal(50019, "image_storage_validation_failed", "failed to validate object storage") + } + } + + images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID) + if err != nil { + return err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return response.ErrInternal(50047, "folder_delete_tx_begin_failed", "failed to delete folder") + } + defer func() { + _ = tx.Rollback(ctx) + }() + + imageRepoTx := repository.NewImageRepository(tx) + folderRepoTx := repository.NewImageFolderRepository(tx) + if err := imageRepoTx.MarkImagesInFolderPendingDelete(ctx, tenantID, 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 { + return err + } + if err := tx.Commit(ctx); err != nil { + return response.ErrInternal(50049, "folder_delete_tx_commit_failed", "failed to delete folder") + } + + for _, image := range images { + if err := s.imageSvc.finalizeDeleteNowOrEnqueue(ctx, tenantID, image.ID); err != nil { + return err + } + } + return nil +} + +func validateImageFolderName(name string) error { + if name == "" { + return response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required") + } + if len(name) > 100 { + return response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters") + } + return nil +} + +func mapImageFolderNotFound(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return response.ErrNotFound(40402, "image_folder_not_found", "folder not found") + } + return err +} + +func isConstraintError(err error, constraint string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.ConstraintName == constraint +} diff --git a/server/internal/tenant/app/image_service.go b/server/internal/tenant/app/image_service.go new file mode 100644 index 0000000..2e992b0 --- /dev/null +++ b/server/internal/tenant/app/image_service.go @@ -0,0 +1,854 @@ +package app + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "net/http" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + "github.com/chai2010/webp" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "go.uber.org/zap" + _ "golang.org/x/image/webp" + + "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" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +const ( + maxImageUploadSizeBytes = 10 * 1024 * 1024 + maxImageDimension = 16384 + maxImageDecodePixels = 40_000_000 + webpEncodeQuality = 82 + defaultImageQuotaBytes = 104857600 + imageAssetQueueIOTimeout = 5 * time.Second + timeLayoutRFC3339 = "2006-01-02T15:04:05Z07:00" +) + +var allowedImageMIMEs = map[string]struct{}{ + "image/gif": {}, + "image/jpeg": {}, + "image/png": {}, + "image/webp": {}, +} + +type ImageService struct { + pool *pgxpool.Pool + objectStorage objectstorage.Client + rabbitMQ *rabbitmq.Client + logger *zap.Logger +} + +func NewImageService(pool *pgxpool.Pool, objectStorage objectstorage.Client, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageService { + return &ImageService{pool: pool, objectStorage: objectStorage, rabbitMQ: rabbitMQClient, logger: logger} +} + +type ImageUploadResult struct { + ID int64 `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + Reused bool `json:"reused"` +} + +type ImageListResult struct { + Items []ImageListItem `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ImageListItem struct { + ID int64 `json:"id"` + FolderID *int64 `json:"folder_id"` + Name string `json:"name"` + URL string `json:"url"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +type NullableInt64Input struct { + Set bool + Value *int64 +} + +func (n *NullableInt64Input) UnmarshalJSON(data []byte) error { + n.Set = true + var value *int64 + if err := json.Unmarshal(data, &value); err != nil { + return err + } + n.Value = value + return nil +} + +type ImageUpdateRequest struct { + Name *string `json:"name"` + FolderID NullableInt64Input `json:"folder_id"` +} + +type ImageReferenceDetail struct { + Total int `json:"total"` + ArticleBody int `json:"article_body"` + ArticleCover int `json:"article_cover"` + Articles []ImageReferenceArticleBrief `json:"articles"` +} + +type StorageUsageResponse struct { + UsedBytes int64 `json:"used_bytes"` + QuotaBytes int64 `json:"quota_bytes"` + UsedPct float64 `json:"used_pct"` +} + +func (s *ImageService) Upload(ctx context.Context, fileName string, content []byte, folderID *int64, buildURL func(string) string) (*ImageUploadResult, error) { + if err := s.objectStorage.Validate(); err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return nil, response.ErrInternal(50010, "image_storage_validation_failed", "failed to validate object storage") + } + if len(content) == 0 { + return nil, response.ErrBadRequest(40010, "invalid_image", "image file is required") + } + if len(content) > maxImageUploadSizeBytes { + return nil, response.ErrBadRequest(40011, "image_too_large", "image size cannot exceed 10 MB") + } + + contentType := http.DetectContentType(content) + if _, ok := allowedImageMIMEs[contentType]; !ok { + return nil, response.ErrBadRequest(40012, "invalid_image_type", "only PNG, JPEG, GIF, and WebP images are supported") + } + + imageConfig, _, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return nil, response.ErrBadRequest(40013, "invalid_image", "unable to decode image") + } + if imageConfig.Width <= 0 || imageConfig.Height <= 0 { + return nil, response.ErrBadRequest(40013, "invalid_image", "unable to decode image") + } + if imageConfig.Width > maxImageDimension || imageConfig.Height > maxImageDimension { + return nil, response.ErrBadRequest(40014, "image_dimensions_too_large", "image dimensions exceed the maximum allowed size") + } + if int64(imageConfig.Width)*int64(imageConfig.Height) > maxImageDecodePixels { + return nil, response.ErrBadRequest(40014, "image_dimensions_too_large", "image pixel count exceeds the maximum allowed") + } + + folderID, err = normalizeAssignableFolderID(folderID) + if err != nil { + return nil, err + } + + tenantID := middleware.TenantIDFromCtx(ctx) + actor := auth.MustActor(ctx) + if folderID != nil { + if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil { + return nil, err + } + } + + webpBytes := content + if contentType != "image/webp" { + decodedImage, _, err := image.Decode(bytes.NewReader(content)) + if err != nil { + return nil, response.ErrBadRequest(40013, "invalid_image", "unable to decode image") + } + + var webpBuffer bytes.Buffer + if err := webp.Encode(&webpBuffer, decodedImage, &webp.Options{Quality: float32(webpEncodeQuality)}); err != nil { + return nil, response.ErrInternal(50011, "image_encode_failed", "failed to encode image") + } + webpBytes = webpBuffer.Bytes() + } + + sizeBytes := int64(len(webpBytes)) + contentHash := hashImageContent(webpBytes) + existingAsset, err := s.findActiveImageAssetByContentHash(ctx, tenantID, contentHash) + if err != nil { + return nil, response.ErrInternal(50012, "image_asset_lookup_failed", "failed to check existing image asset") + } + if existingAsset != nil { + return &ImageUploadResult{ + ID: existingAsset.ID, + URL: buildURL(existingAsset.ObjectKey), + Name: existingAsset.Name, + SizeBytes: existingAsset.SizeBytes, + Reused: true, + }, nil + } + + displayName := deriveImageName(fileName) + objectKey := buildImageObjectKey(tenantID) + + usageRepo := repository.NewImageUsageRepository(s.pool) + if err := usageRepo.EnsureRow(ctx, tenantID); err != nil { + return nil, response.ErrInternal(50013, "image_usage_init_failed", "failed to initialize image storage usage") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50014, "image_upload_tx_begin_failed", "failed to start upload transaction") + } + defer func() { + _ = tx.Rollback(ctx) + }() + + usageTx := repository.NewImageUsageRepository(tx) + if _, err := usageTx.AtomicReserve(ctx, tenantID, sizeBytes); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40903, "image_storage_quota_exceeded", "image storage quota exceeded") + } + 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) + if err != nil { + if isConstraintError(err, "fk_image_assets_folder") { + 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") + } + + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50017, "image_upload_tx_commit_failed", "failed to finalize upload transaction") + } + + if err := s.objectStorage.PutBytes(ctx, objectKey, webpBytes, "image/webp"); err != nil { + s.logError("image upload to object storage failed", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.String("object_key", objectKey), zap.Error(err)) + s.rollbackUpload(ctx, tenantID, assetID, sizeBytes) + if errors.Is(err, objectstorage.ErrNotConfigured) { + return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return nil, response.ErrInternal(50018, "image_upload_failed", "failed to upload image") + } + + imageRepo := repository.NewImageRepository(s.pool) + if err := imageRepo.UpdateStatus(ctx, tenantID, 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{ + ID: assetID, + URL: buildURL(objectKey), + Name: displayName, + SizeBytes: sizeBytes, + }, nil + } else { + s.logError("failed to enqueue upload finalization compensation", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(queueErr)) + } + if deleteErr := s.objectStorage.Delete(ctx, objectKey); deleteErr != nil { + s.logError("failed to clean up uploaded object after finalize error", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.String("object_key", objectKey), zap.Error(deleteErr)) + } + s.rollbackUpload(ctx, tenantID, assetID, sizeBytes) + return nil, response.ErrInternal(50019, "image_upload_finalize_failed", "failed to finalize image upload") + } + + return &ImageUploadResult{ + ID: assetID, + URL: buildURL(objectKey), + Name: displayName, + SizeBytes: sizeBytes, + }, nil +} + +func (s *ImageService) List(ctx context.Context, folderID *int64, query *string, page, pageSize int, buildURL func(string) string) (*ImageListResult, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + searchQuery := normalizeSearchQuery(query) + if searchQuery != nil { + folderID = nil + } + + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageRepository(s.pool) + assets, total, err := repo.List(ctx, repository.ImageAssetListParams{ + TenantID: tenantID, + FolderID: folderID, + SearchQuery: searchQuery, + PageSize: pageSize, + PageOffset: (page - 1) * pageSize, + }) + if err != nil { + return nil, err + } + + items := make([]ImageListItem, len(assets)) + for i, asset := range assets { + items[i] = ImageListItem{ + ID: asset.ID, + FolderID: asset.FolderID, + Name: asset.Name, + URL: buildURL(asset.ObjectKey), + SizeBytes: asset.SizeBytes, + CreatedAt: asset.CreatedAt.Format(timeLayoutRFC3339), + } + } + if items == nil { + items = []ImageListItem{} + } + + return &ImageListResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequest) error { + if req.Name == nil && !req.FolderID.Set { + return response.ErrBadRequest(40000, "invalid_request", "at least one field is required") + } + + tenantID := middleware.TenantIDFromCtx(ctx) + repo := repository.NewImageRepository(s.pool) + + asset, err := repo.Get(ctx, tenantID, id) + if err != nil { + return mapImageNotFound(err) + } + if asset.Status != "active" { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return response.ErrBadRequest(40001, "invalid_name", "name is required") + } + 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 { + return err + } + } + + if req.FolderID.Set { + folderID, err := normalizeAssignableFolderID(req.FolderID.Value) + if err != nil { + return err + } + if folderID != nil { + if err := s.ensureFolderExists(ctx, tenantID, *folderID); err != nil { + return err + } + } + if err := repo.UpdateFolder(ctx, tenantID, id, folderID); err != nil { + if isConstraintError(err, "fk_image_assets_folder") { + return response.ErrNotFound(40402, "image_folder_not_found", "folder not found") + } + return err + } + } + + return nil +} + +type existingImageAsset struct { + ID int64 + ObjectKey string + Name string + SizeBytes int64 +} + +func (s *ImageService) findActiveImageAssetByContentHash(ctx context.Context, tenantID int64, contentHash string) (*existingImageAsset, error) { + if strings.TrimSpace(contentHash) == "" { + return nil, nil + } + + var asset existingImageAsset + err := s.pool.QueryRow(ctx, ` +SELECT id, object_key, name, size_bytes +FROM image_assets +WHERE tenant_id = $1 + AND content_hash = $2 + 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) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &asset, nil +} + +func (s *ImageService) insertPendingImageAsset( + ctx context.Context, + tx pgx.Tx, + tenantID int64, + folderID *int64, + objectKey string, + name string, + sizeBytes int64, + contentHash string, + 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) +RETURNING id +`, tenantID, nullableImageFolderValue(folderID), objectKey, name, sizeBytes, contentHash, createdBy) + + var assetID int64 + if err := row.Scan(&assetID); err != nil { + return 0, err + } + return assetID, nil +} + +func (s *ImageService) GetReferences(ctx context.Context, imageID int64) (*ImageReferenceDetail, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + imageRepo := repository.NewImageRepository(s.pool) + refRepo := repository.NewImageReferenceRepository(s.pool) + + asset, err := imageRepo.Get(ctx, tenantID, imageID) + if err != nil { + return nil, mapImageNotFound(err) + } + if asset.Status != "active" { + return nil, response.ErrNotFound(40401, "image_not_found", "image not found") + } + + articles, err := refRepo.ListArticles(ctx, tenantID, imageID) + if err != nil { + return nil, err + } + + articleMap := make(map[int64]map[string]struct{}) + bodyCount := 0 + coverCount := 0 + for _, article := range articles { + if articleMap[article.ArticleID] == nil { + articleMap[article.ArticleID] = make(map[string]struct{}) + } + articleMap[article.ArticleID][article.RefScope] = struct{}{} + switch article.RefScope { + case "article_body": + bodyCount++ + case "article_cover": + coverCount++ + } + } + + briefs := make([]ImageReferenceArticleBrief, 0, len(articleMap)) + for articleID, scopeSet := range articleMap { + scopes := make([]string, 0, len(scopeSet)) + for scope := range scopeSet { + scopes = append(scopes, scope) + } + slices.Sort(scopes) + briefs = append(briefs, ImageReferenceArticleBrief{ + ArticleID: articleID, + Scopes: scopes, + }) + } + sort.Slice(briefs, func(i, j int) bool { + return briefs[i].ArticleID < briefs[j].ArticleID + }) + if briefs == nil { + briefs = []ImageReferenceArticleBrief{} + } + + return &ImageReferenceDetail{ + Total: len(articles), + ArticleBody: bodyCount, + ArticleCover: coverCount, + Articles: briefs, + }, nil +} + +func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error { + if err := s.objectStorage.Validate(); err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return response.ErrInternal(50019, "image_storage_validation_failed", "failed to validate object storage") + } + + tenantID := middleware.TenantIDFromCtx(ctx) + imageRepo := repository.NewImageRepository(s.pool) + refRepo := repository.NewImageReferenceRepository(s.pool) + + asset, err := imageRepo.Get(ctx, tenantID, id) + if err != nil { + return mapImageNotFound(err) + } + if asset.Status != "active" { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + + if !force { + count, err := refRepo.Count(ctx, tenantID, id) + if err != nil { + return err + } + if count > 0 { + return response.ErrConflict(40904, "image_asset_in_use", "image is referenced by articles") + } + } + + if err := imageRepo.UpdateStatus(ctx, tenantID, id, "pending_delete"); err != nil { + return err + } + + return s.finalizeDeleteNowOrEnqueue(ctx, tenantID, id) +} + +func (s *ImageService) GetStorageUsage(ctx context.Context) (*StorageUsageResponse, error) { + tenantID := middleware.TenantIDFromCtx(ctx) + usageRepo := repository.NewImageUsageRepository(s.pool) + + if err := usageRepo.EnsureRow(ctx, tenantID); err != nil { + return nil, response.ErrInternal(50025, "image_usage_init_failed", "failed to initialize image storage usage") + } + + usage, err := usageRepo.Get(ctx, tenantID) + if err != nil { + return nil, response.ErrInternal(50026, "image_usage_query_failed", "failed to query image storage usage") + } + + quotaBytes, err := s.lookupImageQuotaBytes(ctx, tenantID) + if err != nil { + return nil, response.ErrInternal(50027, "image_quota_query_failed", "failed to query image storage quota") + } + + usedPct := 0.0 + if quotaBytes > 0 { + usedPct = float64(usage.UsedBytes) / float64(quotaBytes) * 100 + } + + return &StorageUsageResponse{ + UsedBytes: usage.UsedBytes, + QuotaBytes: quotaBytes, + UsedPct: usedPct, + }, nil +} + +func (s *ImageService) rollbackUpload(ctx context.Context, tenantID, assetID, sizeBytes int64) { + imageRepo := repository.NewImageRepository(s.pool) + usageRepo := repository.NewImageUsageRepository(s.pool) + + if err := imageRepo.UpdateStatus(ctx, tenantID, 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 { + s.logError("failed to release image storage quota after upload rollback", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err)) + } +} + +func (s *ImageService) ProcessFinalizeUpload(ctx context.Context, tenantID, assetID int64) error { + asset, err := s.loadImageAsset(ctx, tenantID, assetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return response.ErrInternal(50028, "image_upload_lookup_failed", "failed to load image upload state") + } + if asset.Status != "pending_upload" { + return nil + } + + exists, err := s.objectStorage.Exists(ctx, asset.ObjectKey) + if err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return response.ErrInternal(50029, "image_upload_finalize_failed", "failed to check image upload state") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return response.ErrInternal(50030, "image_upload_tx_begin_failed", "failed to finalize image upload") + } + defer func() { + _ = tx.Rollback(ctx) + }() + + lockedAsset, found, err := s.lockImageAsset(ctx, tx, tenantID, assetID) + if err != nil { + return response.ErrInternal(50031, "image_upload_lock_failed", "failed to lock image upload state") + } + if !found || lockedAsset.Status != "pending_upload" { + if err := tx.Commit(ctx); err != nil { + return response.ErrInternal(50032, "image_upload_tx_commit_failed", "failed to finalize image upload") + } + return nil + } + + imageRepoTx := repository.NewImageRepository(tx) + if exists { + if err := imageRepoTx.UpdateStatus(ctx, tenantID, 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 { + return response.ErrInternal(50034, "image_upload_finalize_failed", "failed to mark image upload as failed") + } + usageRepoTx := repository.NewImageUsageRepository(tx) + if err := usageRepoTx.Release(ctx, tenantID, lockedAsset.SizeBytes); err != nil { + return response.ErrInternal(50035, "image_quota_release_failed", "failed to release image storage quota") + } + } + + if err := tx.Commit(ctx); err != nil { + return response.ErrInternal(50036, "image_upload_tx_commit_failed", "failed to finalize image upload") + } + return nil +} + +func (s *ImageService) ProcessFinalizeDelete(ctx context.Context, tenantID, assetID int64) error { + asset, err := s.loadImageAsset(ctx, tenantID, assetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return response.ErrInternal(50037, "image_delete_lookup_failed", "failed to load image delete state") + } + if asset.Status != "pending_delete" { + return nil + } + + exists, err := s.objectStorage.Exists(ctx, asset.ObjectKey) + if err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return response.ErrInternal(50038, "image_delete_finalize_failed", "failed to check image object state") + } + if exists { + if err := s.objectStorage.Delete(ctx, asset.ObjectKey); err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return response.ErrInternal(50039, "image_delete_failed", "failed to delete image from storage") + } + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return response.ErrInternal(50040, "image_delete_tx_begin_failed", "failed to finalize image deletion") + } + defer func() { + _ = tx.Rollback(ctx) + }() + + lockedAsset, found, err := s.lockImageAsset(ctx, tx, tenantID, assetID) + if err != nil { + return response.ErrInternal(50041, "image_delete_lock_failed", "failed to lock image delete state") + } + if !found || lockedAsset.Status != "pending_delete" { + if err := tx.Commit(ctx); err != nil { + return response.ErrInternal(50042, "image_delete_tx_commit_failed", "failed to finalize image deletion") + } + return nil + } + + if _, err := tx.Exec(ctx, ` +DELETE FROM image_asset_references +WHERE tenant_id = $1 AND image_asset_id = $2 +`, tenantID, assetID); err != nil { + return response.ErrInternal(50043, "image_delete_reference_cleanup_failed", "failed to clean image references") + } + + imageRepoTx := repository.NewImageRepository(tx) + usageRepoTx := repository.NewImageUsageRepository(tx) + if err := imageRepoTx.MarkDeleted(ctx, tenantID, 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 { + return response.ErrInternal(50045, "image_quota_release_failed", "failed to release image storage quota") + } + if err := tx.Commit(ctx); err != nil { + return response.ErrInternal(50046, "image_delete_tx_commit_failed", "failed to finalize image deletion") + } + + return nil +} + +func (s *ImageService) finalizeDeleteNowOrEnqueue(ctx context.Context, tenantID, assetID int64) error { + if err := s.ProcessFinalizeDelete(ctx, tenantID, assetID); err != nil { + s.logError("failed to finalize image delete synchronously", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(err)) + if queueErr := s.enqueueFinalizeDeleteTask(tenantID, assetID); queueErr != nil { + s.logError("failed to enqueue image delete compensation", zap.Int64("tenant_id", tenantID), zap.Int64("asset_id", assetID), zap.Error(queueErr)) + return err + } + } + return nil +} + +func (s *ImageService) enqueueFinalizeUploadTask(tenantID, assetID int64) error { + ctx, cancel := context.WithTimeout(context.Background(), imageAssetQueueIOTimeout) + defer cancel() + return publishImageAssetTask(ctx, s.rabbitMQ, imageAssetTaskEnvelope{ + Kind: imageAssetTaskKindFinalizeUpload, + TenantID: tenantID, + AssetID: assetID, + }) +} + +func (s *ImageService) enqueueFinalizeDeleteTask(tenantID, assetID int64) error { + ctx, cancel := context.WithTimeout(context.Background(), imageAssetQueueIOTimeout) + defer cancel() + return publishImageAssetTask(ctx, s.rabbitMQ, imageAssetTaskEnvelope{ + Kind: imageAssetTaskKindFinalizeDelete, + TenantID: tenantID, + AssetID: assetID, + }) +} + +func (s *ImageService) loadImageAsset(ctx context.Context, tenantID, assetID int64) (*repository.ImageAsset, error) { + return repository.NewImageRepository(s.pool).Get(ctx, tenantID, assetID) +} + +type lockedImageAsset struct { + ObjectKey string + SizeBytes int64 + Status string + Deleted bool +} + +func (s *ImageService) lockImageAsset(ctx context.Context, tx pgx.Tx, tenantID, assetID int64) (*lockedImageAsset, bool, error) { + var asset lockedImageAsset + var deletedAt *time.Time + err := tx.QueryRow(ctx, ` +SELECT 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) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, err + } + asset.Deleted = deletedAt != nil + return &asset, true, nil +} + +func (s *ImageService) ensureFolderExists(ctx context.Context, tenantID, folderID int64) error { + folderRepo := repository.NewImageFolderRepository(s.pool) + if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil { + return mapImageFolderNotFound(err) + } + return nil +} + +func (s *ImageService) lookupImageQuotaBytes(ctx context.Context, tenantID int64) (int64, error) { + var quotaBytes int64 + err := s.pool.QueryRow(ctx, ` +SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, $2) AS quota_bytes +FROM tenant_plan_subscriptions s +JOIN plans p ON p.id = s.plan_id +WHERE s.tenant_id = $1 + AND s.status = 'active' + AND s.deleted_at IS NULL +LIMIT 1 +`, tenantID, defaultImageQuotaBytes).Scan("aBytes) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return defaultImageQuotaBytes, nil + } + return 0, err + } + return quotaBytes, nil +} + +func mapImageNotFound(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return response.ErrNotFound(40401, "image_not_found", "image not found") + } + return err +} + +func normalizeSearchQuery(query *string) *string { + if query == nil { + return nil + } + trimmed := strings.TrimSpace(*query) + if trimmed == "" { + return nil + } + return &trimmed +} + +func normalizeAssignableFolderID(folderID *int64) (*int64, error) { + if folderID == nil { + return nil, nil + } + if *folderID <= 0 { + return nil, response.ErrBadRequest(40003, "invalid_folder_id", "folder_id must be a positive integer or null") + } + return folderID, nil +} + +func buildImageObjectKey(tenantID int64) string { + return fmt.Sprintf("tenants/%d/images/%s.webp", tenantID, uuid.NewString()) +} + +func hashImageContent(content []byte) string { + if len(content) == 0 { + return "" + } + digest := sha256.Sum256(content) + return hex.EncodeToString(digest[:]) +} + +func nullableImageFolderValue(value *int64) interface{} { + if value == nil { + return nil + } + return *value +} + +func deriveImageName(fileName string) string { + name := strings.TrimSpace(filepath.Base(fileName)) + if name == "" { + return "untitled" + } + if ext := filepath.Ext(name); ext != "" { + name = strings.TrimSuffix(name, ext) + } + name = strings.TrimSpace(name) + if name == "" { + return "untitled" + } + if len(name) > 255 { + name = name[:255] + } + return name +} + +func (s *ImageService) logError(msg string, fields ...zap.Field) { + if s.logger != nil { + s.logger.Error(msg, fields...) + } +} diff --git a/server/internal/tenant/app/prompt_generate_service.go b/server/internal/tenant/app/prompt_generate_service.go index c417876..35dbae6 100644 --- a/server/internal/tenant/app/prompt_generate_service.go +++ b/server/internal/tenant/app/prompt_generate_service.go @@ -301,7 +301,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co } inputJSON, _ := json.Marshal(inputParams) - wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs, nil) + wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs, nil, NullableInt64Input{}) tx, err := s.pool.Begin(ctx) if err != nil { diff --git a/server/internal/tenant/app/publish_platforms.go b/server/internal/tenant/app/publish_platforms.go index 05ca1bc..bbf90b0 100644 --- a/server/internal/tenant/app/publish_platforms.go +++ b/server/internal/tenant/app/publish_platforms.go @@ -2,6 +2,7 @@ package app import ( "encoding/json" + "strconv" "strings" ) @@ -86,6 +87,14 @@ func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string { return &value } +func resolveArticleCoverImageAssetID(wizardStateJSON []byte) *int64 { + value, ok := extractInt64FromJSONPayload(wizardStateJSON, "cover_image_asset_id") + if !ok || value <= 0 { + return nil + } + return &value +} + func extractStringFromJSONPayload(raw []byte, key string) string { if len(raw) == 0 { return "" @@ -103,6 +112,40 @@ func extractStringFromJSONPayload(raw []byte, key string) string { return strings.TrimSpace(value) } +func extractInt64FromJSONPayload(raw []byte, key string) (int64, bool) { + if len(raw) == 0 { + return 0, false + } + + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + return 0, false + } + + switch value := payload[key].(type) { + case nil: + return 0, false + case float64: + return int64(value), true + case int64: + return value, true + case int: + return int64(value), true + case string: + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + func normalizePlatformsFromValue(raw interface{}) []string { switch typed := raw.(type) { case nil: @@ -126,7 +169,7 @@ func normalizePlatformsFromValue(raw interface{}) []string { } } -func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string) ([]byte, error) { +func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) { state := make(map[string]interface{}) if len(raw) > 0 { _ = json.Unmarshal(raw, &state) @@ -157,5 +200,13 @@ func mergeArticleWizardState(raw []byte, title string, platforms []string, cover } } + if coverImageAssetID.Set { + if coverImageAssetID.Value == nil { + delete(state, "cover_image_asset_id") + } else { + state["cover_image_asset_id"] = *coverImageAssetID.Value + } + } + return json.Marshal(state) } diff --git a/server/internal/tenant/repository/generated/image.sql.go b/server/internal/tenant/repository/generated/image.sql.go new file mode 100644 index 0000000..70db33d --- /dev/null +++ b/server/internal/tenant/repository/generated/image.sql.go @@ -0,0 +1,328 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: image.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countImageAssets = `-- name: CountImageAssets :one +SELECT COUNT(*)::INT AS total +FROM image_assets +WHERE tenant_id = $1 + 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 + ) + AND ($3::TEXT IS NULL OR name ILIKE '%' || $3::TEXT || '%') +` + +type CountImageAssetsParams struct { + TenantID int64 `json:"tenant_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) + var total int32 + err := row.Scan(&total) + return total, err +} + +const countUncategorizedImages = `-- name: CountUncategorizedImages :one +SELECT COUNT(*)::INT AS count +FROM image_assets +WHERE tenant_id = $1 + 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) + 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 +FROM image_assets +WHERE id = $1 AND tenant_id = $2 +` + +type GetImageAssetParams struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +type GetImageAssetRow struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + FolderID pgtype.Int8 `json:"folder_id"` + ObjectKey string `json:"object_key"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + Status string `json:"status"` + CreatedBy int64 `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetImageAsset(ctx context.Context, arg GetImageAssetParams) (GetImageAssetRow, error) { + row := q.db.QueryRow(ctx, getImageAsset, arg.ID, arg.TenantID) + var i GetImageAssetRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.FolderID, + &i.ObjectKey, + &i.Name, + &i.SizeBytes, + &i.Status, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +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) +RETURNING id +` + +type InsertImageAssetParams struct { + TenantID int64 `json:"tenant_id"` + FolderID pgtype.Int8 `json:"folder_id"` + ObjectKey string `json:"object_key"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + Status string `json:"status"` + CreatedBy int64 `json:"created_by"` +} + +func (q *Queries) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) { + row := q.db.QueryRow(ctx, insertImageAsset, + arg.TenantID, + arg.FolderID, + arg.ObjectKey, + arg.Name, + arg.SizeBytes, + arg.Status, + arg.CreatedBy, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listActiveImagesByFolder = `-- name: ListActiveImagesByFolder :many +SELECT id, object_key, size_bytes +FROM image_assets +WHERE tenant_id = $1 + AND folder_id = $2 + AND status = 'active' + AND deleted_at IS NULL +` + +type ListActiveImagesByFolderParams struct { + TenantID int64 `json:"tenant_id"` + FolderID pgtype.Int8 `json:"folder_id"` +} + +type ListActiveImagesByFolderRow struct { + ID int64 `json:"id"` + ObjectKey string `json:"object_key"` + SizeBytes int64 `json:"size_bytes"` +} + +func (q *Queries) ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error) { + rows, err := q.db.Query(ctx, listActiveImagesByFolder, arg.TenantID, arg.FolderID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveImagesByFolderRow + for rows.Next() { + var i ListActiveImagesByFolderRow + if err := rows.Scan(&i.ID, &i.ObjectKey, &i.SizeBytes); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listImageAssets = `-- name: ListImageAssets :many +SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at +FROM image_assets +WHERE tenant_id = $1 + 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 + ) + AND ($3::TEXT IS NULL OR name ILIKE '%' || $3::TEXT || '%') +ORDER BY created_at DESC +LIMIT $5 OFFSET $4 +` + +type ListImageAssetsParams struct { + TenantID int64 `json:"tenant_id"` + FolderID pgtype.Int8 `json:"folder_id"` + SearchQuery pgtype.Text `json:"search_query"` + PageOffset int32 `json:"page_offset"` + PageSize int32 `json:"page_size"` +} + +type ListImageAssetsRow struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + FolderID pgtype.Int8 `json:"folder_id"` + ObjectKey string `json:"object_key"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + Status string `json:"status"` + CreatedBy int64 `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error) { + rows, err := q.db.Query(ctx, listImageAssets, + arg.TenantID, + arg.FolderID, + arg.SearchQuery, + arg.PageOffset, + arg.PageSize, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListImageAssetsRow + for rows.Next() { + var i ListImageAssetsRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.FolderID, + &i.ObjectKey, + &i.Name, + &i.SizeBytes, + &i.Status, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markImagesInFolderPendingDelete = `-- name: MarkImagesInFolderPendingDelete :exec +UPDATE image_assets +SET status = 'pending_delete', folder_id = NULL, updated_at = NOW() +WHERE tenant_id = $1 + AND folder_id = $2 + AND status = 'active' + AND deleted_at IS NULL +` + +type MarkImagesInFolderPendingDeleteParams struct { + TenantID int64 `json:"tenant_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) + 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 +` + +type UpdateImageAssetDeletedParams struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) UpdateImageAssetDeleted(ctx context.Context, arg UpdateImageAssetDeletedParams) error { + _, err := q.db.Exec(ctx, updateImageAssetDeleted, arg.ID, arg.TenantID) + return err +} + +const updateImageAssetFolder = `-- name: UpdateImageAssetFolder :exec +UPDATE image_assets +SET folder_id = $1, updated_at = NOW() +WHERE id = $2 AND tenant_id = $3 +` + +type UpdateImageAssetFolderParams struct { + FolderID pgtype.Int8 `json:"folder_id"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) UpdateImageAssetFolder(ctx context.Context, arg UpdateImageAssetFolderParams) error { + _, err := q.db.Exec(ctx, updateImageAssetFolder, arg.FolderID, arg.ID, arg.TenantID) + return err +} + +const updateImageAssetName = `-- name: UpdateImageAssetName :exec +UPDATE image_assets +SET name = $1, updated_at = NOW() +WHERE id = $2 AND tenant_id = $3 +` + +type UpdateImageAssetNameParams struct { + Name string `json:"name"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) UpdateImageAssetName(ctx context.Context, arg UpdateImageAssetNameParams) error { + _, err := q.db.Exec(ctx, updateImageAssetName, arg.Name, arg.ID, arg.TenantID) + return err +} + +const updateImageAssetStatus = `-- name: UpdateImageAssetStatus :exec +UPDATE image_assets +SET status = $1, updated_at = NOW() +WHERE id = $2 AND tenant_id = $3 +` + +type UpdateImageAssetStatusParams struct { + Status string `json:"status"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) UpdateImageAssetStatus(ctx context.Context, arg UpdateImageAssetStatusParams) error { + _, err := q.db.Exec(ctx, updateImageAssetStatus, arg.Status, arg.ID, arg.TenantID) + return err +} diff --git a/server/internal/tenant/repository/generated/image_folder.sql.go b/server/internal/tenant/repository/generated/image_folder.sql.go new file mode 100644 index 0000000..43dab2a --- /dev/null +++ b/server/internal/tenant/repository/generated/image_folder.sql.go @@ -0,0 +1,161 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: image_folder.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countActiveImagesInFolder = `-- name: CountActiveImagesInFolder :one +SELECT COUNT(*)::INT AS count +FROM image_assets +WHERE tenant_id = $1 + AND folder_id = $2 + AND status = 'active' + AND deleted_at IS NULL +` + +type CountActiveImagesInFolderParams struct { + TenantID int64 `json:"tenant_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) + 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) +RETURNING id +` + +type CreateImageFolderParams struct { + TenantID int64 `json:"tenant_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) + var id int64 + err := row.Scan(&id) + return id, err +} + +const deleteImageFolder = `-- name: DeleteImageFolder :exec +DELETE FROM image_folders +WHERE id = $1 AND tenant_id = $2 +` + +type DeleteImageFolderParams struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) DeleteImageFolder(ctx context.Context, arg DeleteImageFolderParams) error { + _, err := q.db.Exec(ctx, deleteImageFolder, arg.ID, arg.TenantID) + return err +} + +const getImageFolder = `-- name: GetImageFolder :one +SELECT id, tenant_id, name, created_at, updated_at +FROM image_folders +WHERE id = $1 AND tenant_id = $2 +` + +type GetImageFolderParams struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +type GetImageFolderRow struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_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) + var i GetImageFolderRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listImageFolders = `-- name: ListImageFolders :many +SELECT f.id, f.tenant_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 +WHERE f.tenant_id = $1 +GROUP BY f.id +ORDER BY f.name +` + +type ListImageFoldersRow struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_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) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListImageFoldersRow + for rows.Next() { + var i ListImageFoldersRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + &i.ImageCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateImageFolder = `-- name: UpdateImageFolder :exec +UPDATE image_folders +SET name = $1, updated_at = NOW() +WHERE id = $2 AND tenant_id = $3 +` + +type UpdateImageFolderParams struct { + Name string `json:"name"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) UpdateImageFolder(ctx context.Context, arg UpdateImageFolderParams) error { + _, err := q.db.Exec(ctx, updateImageFolder, arg.Name, arg.ID, arg.TenantID) + return err +} diff --git a/server/internal/tenant/repository/generated/image_reference.sql.go b/server/internal/tenant/repository/generated/image_reference.sql.go new file mode 100644 index 0000000..e08f922 --- /dev/null +++ b/server/internal/tenant/repository/generated/image_reference.sql.go @@ -0,0 +1,167 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: image_reference.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countImageReferences = `-- name: CountImageReferences :one +SELECT COUNT(*)::INT AS total +FROM image_asset_references +WHERE tenant_id = $1 AND image_asset_id = $2 +` + +type CountImageReferencesParams struct { + TenantID int64 `json:"tenant_id"` + ImageAssetID int64 `json:"image_asset_id"` +} + +func (q *Queries) CountImageReferences(ctx context.Context, arg CountImageReferencesParams) (int32, error) { + row := q.db.QueryRow(ctx, countImageReferences, arg.TenantID, arg.ImageAssetID) + var total int32 + err := row.Scan(&total) + return total, err +} + +const deleteImageReferencesByArticle = `-- name: DeleteImageReferencesByArticle :exec +DELETE FROM image_asset_references +WHERE tenant_id = $1 AND article_id = $2 +` + +type DeleteImageReferencesByArticleParams struct { + TenantID int64 `json:"tenant_id"` + ArticleID int64 `json:"article_id"` +} + +func (q *Queries) DeleteImageReferencesByArticle(ctx context.Context, arg DeleteImageReferencesByArticleParams) error { + _, err := q.db.Exec(ctx, deleteImageReferencesByArticle, arg.TenantID, arg.ArticleID) + return err +} + +const deleteImageReferencesByArticleScope = `-- name: DeleteImageReferencesByArticleScope :exec +DELETE FROM image_asset_references +WHERE tenant_id = $1 AND article_id = $2 AND ref_scope = $3 +` + +type DeleteImageReferencesByArticleScopeParams struct { + TenantID int64 `json:"tenant_id"` + ArticleID int64 `json:"article_id"` + RefScope string `json:"ref_scope"` +} + +func (q *Queries) DeleteImageReferencesByArticleScope(ctx context.Context, arg DeleteImageReferencesByArticleScopeParams) error { + _, err := q.db.Exec(ctx, deleteImageReferencesByArticleScope, arg.TenantID, arg.ArticleID, arg.RefScope) + return err +} + +const listImageReferenceArticles = `-- name: ListImageReferenceArticles :many +SELECT DISTINCT r.article_id, r.ref_scope +FROM image_asset_references r +WHERE r.tenant_id = $1 AND r.image_asset_id = $2 +` + +type ListImageReferenceArticlesParams struct { + TenantID int64 `json:"tenant_id"` + ImageAssetID int64 `json:"image_asset_id"` +} + +type ListImageReferenceArticlesRow struct { + ArticleID int64 `json:"article_id"` + RefScope string `json:"ref_scope"` +} + +func (q *Queries) ListImageReferenceArticles(ctx context.Context, arg ListImageReferenceArticlesParams) ([]ListImageReferenceArticlesRow, error) { + rows, err := q.db.Query(ctx, listImageReferenceArticles, arg.TenantID, arg.ImageAssetID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListImageReferenceArticlesRow + for rows.Next() { + var i ListImageReferenceArticlesRow + if err := rows.Scan(&i.ArticleID, &i.RefScope); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listImageReferences = `-- name: ListImageReferences :many +SELECT r.id, r.tenant_id, r.image_asset_id, r.article_id, r.ref_scope, r.created_at +FROM image_asset_references r +WHERE r.tenant_id = $1 AND r.image_asset_id = $2 +` + +type ListImageReferencesParams struct { + TenantID int64 `json:"tenant_id"` + ImageAssetID int64 `json:"image_asset_id"` +} + +type ListImageReferencesRow struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + ImageAssetID int64 `json:"image_asset_id"` + ArticleID int64 `json:"article_id"` + RefScope string `json:"ref_scope"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) ListImageReferences(ctx context.Context, arg ListImageReferencesParams) ([]ListImageReferencesRow, error) { + rows, err := q.db.Query(ctx, listImageReferences, arg.TenantID, arg.ImageAssetID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListImageReferencesRow + for rows.Next() { + var i ListImageReferencesRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.ImageAssetID, + &i.ArticleID, + &i.RefScope, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertImageReference = `-- name: UpsertImageReference :exec +INSERT INTO image_asset_references (tenant_id, image_asset_id, article_id, ref_scope) +VALUES ($1, $2, $3, $4) +ON CONFLICT (image_asset_id, article_id, ref_scope) DO UPDATE SET updated_at = NOW() +` + +type UpsertImageReferenceParams struct { + TenantID int64 `json:"tenant_id"` + ImageAssetID int64 `json:"image_asset_id"` + ArticleID int64 `json:"article_id"` + RefScope string `json:"ref_scope"` +} + +func (q *Queries) UpsertImageReference(ctx context.Context, arg UpsertImageReferenceParams) error { + _, err := q.db.Exec(ctx, upsertImageReference, + arg.TenantID, + arg.ImageAssetID, + arg.ArticleID, + arg.RefScope, + ) + return err +} diff --git a/server/internal/tenant/repository/generated/image_usage.sql.go b/server/internal/tenant/repository/generated/image_usage.sql.go new file mode 100644 index 0000000..c019063 --- /dev/null +++ b/server/internal/tenant/repository/generated/image_usage.sql.go @@ -0,0 +1,58 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: image_usage.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const ensureImageStorageUsageRow = `-- name: EnsureImageStorageUsageRow :exec +INSERT INTO tenant_image_storage_usage (tenant_id, used_bytes) +VALUES ($1, 0) +ON CONFLICT (tenant_id) DO NOTHING +` + +func (q *Queries) EnsureImageStorageUsageRow(ctx context.Context, tenantID int64) error { + _, err := q.db.Exec(ctx, ensureImageStorageUsageRow, tenantID) + return err +} + +const getImageStorageUsage = `-- name: GetImageStorageUsage :one +SELECT used_bytes, updated_at +FROM tenant_image_storage_usage +WHERE tenant_id = $1 +` + +type GetImageStorageUsageRow struct { + UsedBytes int64 `json:"used_bytes"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetImageStorageUsage(ctx context.Context, tenantID int64) (GetImageStorageUsageRow, error) { + row := q.db.QueryRow(ctx, getImageStorageUsage, tenantID) + var i GetImageStorageUsageRow + err := row.Scan(&i.UsedBytes, &i.UpdatedAt) + return i, err +} + +const releaseImageStorageQuota = `-- name: ReleaseImageStorageQuota :exec +UPDATE tenant_image_storage_usage +SET used_bytes = GREATEST(0, used_bytes - $1), + updated_at = NOW() +WHERE tenant_id = $2 +` + +type ReleaseImageStorageQuotaParams struct { + ReleaseBytes int64 `json:"release_bytes"` + TenantID int64 `json:"tenant_id"` +} + +func (q *Queries) ReleaseImageStorageQuota(ctx context.Context, arg ReleaseImageStorageQuotaParams) error { + _, err := q.db.Exec(ctx, releaseImageStorageQuota, arg.ReleaseBytes, arg.TenantID) + return err +} diff --git a/server/internal/tenant/repository/generated/models.go b/server/internal/tenant/repository/generated/models.go index e5aeef7..4c4875f 100644 --- a/server/internal/tenant/repository/generated/models.go +++ b/server/internal/tenant/repository/generated/models.go @@ -136,6 +136,38 @@ type GenerationTask struct { OperatorID pgtype.Int8 `json:"operator_id"` } +type ImageAsset struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + FolderID pgtype.Int8 `json:"folder_id"` + ObjectKey string `json:"object_key"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + Status string `json:"status"` + CreatedBy int64 `json:"created_by"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +type ImageAssetReference struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + ImageAssetID int64 `json:"image_asset_id"` + ArticleID int64 `json:"article_id"` + RefScope string `json:"ref_scope"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ImageFolder struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + Name string `json:"name"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type KnowledgeChunksMetum struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` @@ -357,20 +389,23 @@ type QuotaReservation struct { } type ScheduleTask struct { - ID int64 `json:"id"` - TenantID int64 `json:"tenant_id"` - PromptRuleID int64 `json:"prompt_rule_id"` - BrandID pgtype.Int8 `json:"brand_id"` - Name string `json:"name"` - CronExpr string `json:"cron_expr"` - TargetPlatform pgtype.Text `json:"target_platform"` - StartAt pgtype.Timestamptz `json:"start_at"` - EndAt pgtype.Timestamptz `json:"end_at"` - NextRunAt pgtype.Timestamptz `json:"next_run_at"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + PromptRuleID int64 `json:"prompt_rule_id"` + BrandID pgtype.Int8 `json:"brand_id"` + Name string `json:"name"` + CronExpr string `json:"cron_expr"` + TargetPlatform pgtype.Text `json:"target_platform"` + StartAt pgtype.Timestamptz `json:"start_at"` + EndAt pgtype.Timestamptz `json:"end_at"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + OperatorID pgtype.Int8 `json:"operator_id"` + EnableWebSearch bool `json:"enable_web_search"` + GenerateCount int32 `json:"generate_count"` } type TaskRecord struct { @@ -414,6 +449,12 @@ type Tenant struct { DeletedAt pgtype.Timestamptz `json:"deleted_at"` } +type TenantImageStorageUsage struct { + TenantID int64 `json:"tenant_id"` + UsedBytes int64 `json:"used_bytes"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type TenantMembership struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` @@ -424,6 +465,20 @@ type TenantMembership struct { DeletedAt pgtype.Timestamptz `json:"deleted_at"` } +type TenantMonitoringQuota struct { + TenantID int64 `json:"tenant_id"` + MaxBrands int32 `json:"max_brands"` + CollectionMode string `json:"collection_mode"` + QuestionSelectionMode string `json:"question_selection_mode"` + CollectFrequency string `json:"collect_frequency"` + EnabledPlatforms []byte `json:"enabled_platforms"` + PrimaryInstallationID pgtype.Int8 `json:"primary_installation_id"` + TaskDailyHardCap int32 `json:"task_daily_hard_cap"` + PlanTier string `json:"plan_tier"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type TenantPlanSubscription struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 56af0c5..c7150e1 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -10,30 +10,42 @@ import ( type Querier interface { ConfirmReservation(ctx context.Context, arg ConfirmReservationParams) error + CountActiveImagesInFolder(ctx context.Context, arg CountActiveImagesInFolderParams) (int32, error) CountArticles(ctx context.Context, arg CountArticlesParams) (int64, error) CountArticlesByTenant(ctx context.Context, tenantID int64) (int64, error) CountBrandsByTenant(ctx context.Context, tenantID int64) (int64, error) + CountImageAssets(ctx context.Context, arg CountImageAssetsParams) (int32, error) + CountImageReferences(ctx context.Context, arg CountImageReferencesParams) (int32, error) CountPromptRules(ctx context.Context, arg CountPromptRulesParams) (int64, error) CountPromptRulesUngrouped(ctx context.Context, tenantID int64) (int64, error) CountPublishedArticlesByTenant(ctx context.Context, tenantID int64) (int64, error) CountScheduleTasks(ctx context.Context, arg CountScheduleTasksParams) (int64, error) + CountUncategorizedImages(ctx context.Context, tenantID int64) (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 CreateBrand(ctx context.Context, arg CreateBrandParams) (CreateBrandRow, error) CreateCompetitor(ctx context.Context, arg CreateCompetitorParams) (CreateCompetitorRow, error) CreateGenerationTask(ctx context.Context, arg CreateGenerationTaskParams) (int64, error) + CreateImageFolder(ctx context.Context, arg CreateImageFolderParams) (int64, error) CreateKeyword(ctx context.Context, arg CreateKeywordParams) (CreateKeywordRow, error) CreatePromptRule(ctx context.Context, arg CreatePromptRuleParams) (CreatePromptRuleRow, error) CreatePromptRuleGroup(ctx context.Context, arg CreatePromptRuleGroupParams) (CreatePromptRuleGroupRow, error) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (int64, error) CreateQuotaReservation(ctx context.Context, arg CreateQuotaReservationParams) (int64, error) CreateScheduleTask(ctx context.Context, arg CreateScheduleTaskParams) (CreateScheduleTaskRow, error) + DeleteImageFolder(ctx context.Context, arg DeleteImageFolderParams) error + DeleteImageReferencesByArticle(ctx context.Context, arg DeleteImageReferencesByArticleParams) error + DeleteImageReferencesByArticleScope(ctx context.Context, arg DeleteImageReferencesByArticleScopeParams) error + EnsureImageStorageUsageRow(ctx context.Context, tenantID int64) error GetActivePlanForTenant(ctx context.Context, tenantID int64) (GetActivePlanForTenantRow, error) GetArticleByID(ctx context.Context, arg GetArticleByIDParams) (GetArticleByIDRow, error) GetArticleVersions(ctx context.Context, articleID int64) ([]GetArticleVersionsRow, error) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (GetBrandByIDRow, error) GetCurrentBalance(ctx context.Context, arg GetCurrentBalanceParams) (int32, error) + GetImageAsset(ctx context.Context, arg GetImageAssetParams) (GetImageAssetRow, error) + GetImageFolder(ctx context.Context, arg GetImageFolderParams) (GetImageFolderRow, error) + GetImageStorageUsage(ctx context.Context, tenantID int64) (GetImageStorageUsageRow, error) GetPromptRuleByID(ctx context.Context, arg GetPromptRuleByIDParams) (GetPromptRuleByIDRow, error) GetQuotaSummary(ctx context.Context, tenantID int64) (int32, error) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error) @@ -43,11 +55,17 @@ type Querier interface { GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) + InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error) + ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error) ListAllPromptRulesSimple(ctx context.Context, tenantID int64) ([]ListAllPromptRulesSimpleRow, error) ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error) ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error) ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error) + ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error) + ListImageFolders(ctx context.Context, tenantID int64) ([]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) // ============================================================ // Prompt Rule Groups @@ -61,7 +79,9 @@ type Querier interface { ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) ListScheduleTasks(ctx context.Context, arg ListScheduleTasksParams) ([]ListScheduleTasksRow, error) ListTemplates(ctx context.Context, tenantID int64) ([]ListTemplatesRow, error) + MarkImagesInFolderPendingDelete(ctx context.Context, arg MarkImagesInFolderPendingDeleteParams) error RefundReservation(ctx context.Context, arg RefundReservationParams) error + ReleaseImageStorageQuota(ctx context.Context, arg ReleaseImageStorageQuotaParams) error SoftDeleteArticle(ctx context.Context, arg SoftDeleteArticleParams) error SoftDeleteBrand(ctx context.Context, arg SoftDeleteBrandParams) error SoftDeleteCompetitor(ctx context.Context, arg SoftDeleteCompetitorParams) error @@ -78,6 +98,11 @@ type Querier interface { UpdateBrand(ctx context.Context, arg UpdateBrandParams) error UpdateCompetitor(ctx context.Context, arg UpdateCompetitorParams) error UpdateGenerationTaskStatus(ctx context.Context, arg UpdateGenerationTaskStatusParams) error + UpdateImageAssetDeleted(ctx context.Context, arg UpdateImageAssetDeletedParams) error + UpdateImageAssetFolder(ctx context.Context, arg UpdateImageAssetFolderParams) error + UpdateImageAssetName(ctx context.Context, arg UpdateImageAssetNameParams) error + UpdateImageAssetStatus(ctx context.Context, arg UpdateImageAssetStatusParams) error + UpdateImageFolder(ctx context.Context, arg UpdateImageFolderParams) error UpdateKeyword(ctx context.Context, arg UpdateKeywordParams) error UpdatePromptRule(ctx context.Context, arg UpdatePromptRuleParams) error UpdatePromptRuleGroup(ctx context.Context, arg UpdatePromptRuleGroupParams) error @@ -86,6 +111,7 @@ type Querier interface { UpdateQuotaReservationResource(ctx context.Context, arg UpdateQuotaReservationResourceParams) error UpdateScheduleTask(ctx context.Context, arg UpdateScheduleTaskParams) error UpdateScheduleTaskStatus(ctx context.Context, arg UpdateScheduleTaskStatusParams) error + UpsertImageReference(ctx context.Context, arg UpsertImageReferenceParams) error } var _ Querier = (*Queries)(nil) diff --git a/server/internal/tenant/repository/image_folder_repo.go b/server/internal/tenant/repository/image_folder_repo.go new file mode 100644 index 0000000..b02c4b9 --- /dev/null +++ b/server/internal/tenant/repository/image_folder_repo.go @@ -0,0 +1,105 @@ +package repository + +import ( + "context" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageFolder struct { + ID int64 + TenantID int64 + Name string + ImageCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +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) +} + +type imageFolderRepository struct { + q generated.Querier +} + +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) + if err != nil { + return nil, err + } + + folders := make([]ImageFolder, len(rows)) + for i, row := range rows { + folders[i] = ImageFolder{ + ID: row.ID, + TenantID: row.TenantID, + Name: row.Name, + ImageCount: int(row.ImageCount), + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + } + } + return folders, nil +} + +func (r *imageFolderRepository) Get(ctx context.Context, tenantID, id int64) (*ImageFolder, error) { + row, err := r.q.GetImageFolder(ctx, generated.GetImageFolderParams{ + ID: id, + TenantID: tenantID, + }) + if err != nil { + return nil, err + } + + return &ImageFolder{ + ID: row.ID, + TenantID: row.TenantID, + 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) { + return r.q.CreateImageFolder(ctx, generated.CreateImageFolderParams{ + TenantID: tenantID, + Name: name, + }) +} + +func (r *imageFolderRepository) Update(ctx context.Context, tenantID, id int64, name string) error { + return r.q.UpdateImageFolder(ctx, generated.UpdateImageFolderParams{ + Name: name, + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageFolderRepository) Delete(ctx context.Context, tenantID, id int64) error { + return r.q.DeleteImageFolder(ctx, generated.DeleteImageFolderParams{ + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageFolderRepository) CountActiveImages(ctx context.Context, tenantID, folderID int64) (int, error) { + count, err := r.q.CountActiveImagesInFolder(ctx, generated.CountActiveImagesInFolderParams{ + TenantID: tenantID, + FolderID: pgInt8(&folderID), + }) + if err != nil { + return 0, err + } + return int(count), nil +} diff --git a/server/internal/tenant/repository/image_reference_repo.go b/server/internal/tenant/repository/image_reference_repo.go new file mode 100644 index 0000000..bb9f4a4 --- /dev/null +++ b/server/internal/tenant/repository/image_reference_repo.go @@ -0,0 +1,116 @@ +package repository + +import ( + "context" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageReference struct { + ID int64 + TenantID int64 + ImageAssetID int64 + ArticleID int64 + RefScope string + CreatedAt time.Time +} + +type ImageReferenceArticle struct { + ArticleID int64 + RefScope string +} + +type ImageReferenceRepository interface { + List(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReference, error) + Count(ctx context.Context, tenantID, imageAssetID int64) (int, error) + Upsert(ctx context.Context, tenantID, imageAssetID, articleID int64, refScope string) error + DeleteByArticle(ctx context.Context, tenantID, articleID int64) error + DeleteByArticleScope(ctx context.Context, tenantID, articleID int64, refScope string) error + ListArticles(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReferenceArticle, error) +} + +type imageReferenceRepository struct { + q generated.Querier +} + +func NewImageReferenceRepository(db generated.DBTX) ImageReferenceRepository { + return &imageReferenceRepository{q: newQuerier(db)} +} + +func (r *imageReferenceRepository) List(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReference, error) { + rows, err := r.q.ListImageReferences(ctx, generated.ListImageReferencesParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + }) + if err != nil { + return nil, err + } + + refs := make([]ImageReference, len(rows)) + for i, row := range rows { + refs[i] = ImageReference{ + ID: row.ID, + TenantID: row.TenantID, + ImageAssetID: row.ImageAssetID, + ArticleID: row.ArticleID, + RefScope: row.RefScope, + CreatedAt: timeFromTimestamp(row.CreatedAt), + } + } + return refs, nil +} + +func (r *imageReferenceRepository) Count(ctx context.Context, tenantID, imageAssetID int64) (int, error) { + total, err := r.q.CountImageReferences(ctx, generated.CountImageReferencesParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + }) + if err != nil { + return 0, err + } + return int(total), nil +} + +func (r *imageReferenceRepository) Upsert(ctx context.Context, tenantID, imageAssetID, articleID int64, refScope string) error { + return r.q.UpsertImageReference(ctx, generated.UpsertImageReferenceParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + ArticleID: articleID, + RefScope: refScope, + }) +} + +func (r *imageReferenceRepository) DeleteByArticle(ctx context.Context, tenantID, articleID int64) error { + return r.q.DeleteImageReferencesByArticle(ctx, generated.DeleteImageReferencesByArticleParams{ + TenantID: tenantID, + ArticleID: articleID, + }) +} + +func (r *imageReferenceRepository) DeleteByArticleScope(ctx context.Context, tenantID, articleID int64, refScope string) error { + return r.q.DeleteImageReferencesByArticleScope(ctx, generated.DeleteImageReferencesByArticleScopeParams{ + TenantID: tenantID, + ArticleID: articleID, + RefScope: refScope, + }) +} + +func (r *imageReferenceRepository) ListArticles(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReferenceArticle, error) { + rows, err := r.q.ListImageReferenceArticles(ctx, generated.ListImageReferenceArticlesParams{ + TenantID: tenantID, + ImageAssetID: imageAssetID, + }) + if err != nil { + return nil, err + } + + articles := make([]ImageReferenceArticle, len(rows)) + for i, row := range rows { + articles[i] = ImageReferenceArticle{ + ArticleID: row.ArticleID, + RefScope: row.RefScope, + } + } + return articles, nil +} diff --git a/server/internal/tenant/repository/image_repo.go b/server/internal/tenant/repository/image_repo.go new file mode 100644 index 0000000..8933211 --- /dev/null +++ b/server/internal/tenant/repository/image_repo.go @@ -0,0 +1,209 @@ +package repository + +import ( + "context" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageAsset struct { + ID int64 + TenantID int64 + FolderID *int64 + ObjectKey string + Name string + SizeBytes int64 + Status string + CreatedBy int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type ImageAssetListParams struct { + TenantID int64 + FolderID *int64 + SearchQuery *string + PageSize int + PageOffset int +} + +type InsertImageAssetInput struct { + TenantID int64 + FolderID *int64 + ObjectKey string + Name string + SizeBytes int64 + Status string + CreatedBy int64 +} + +type FolderImageSummary struct { + ID int64 + ObjectKey string + SizeBytes int64 +} + +type ImageRepository interface { + List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error) + Get(ctx context.Context, tenantID, 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) +} + +type imageRepository struct { + q generated.Querier +} + +func NewImageRepository(db generated.DBTX) ImageRepository { + return &imageRepository{q: newQuerier(db)} +} + +func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error) { + listParams := generated.ListImageAssetsParams{ + TenantID: params.TenantID, + FolderID: pgInt8(params.FolderID), + PageOffset: int32(params.PageOffset), + PageSize: int32(params.PageSize), + SearchQuery: pgText(params.SearchQuery), + } + + rows, err := r.q.ListImageAssets(ctx, listParams) + if err != nil { + return nil, 0, err + } + + total, err := r.q.CountImageAssets(ctx, generated.CountImageAssetsParams{ + TenantID: params.TenantID, + FolderID: listParams.FolderID, + SearchQuery: listParams.SearchQuery, + }) + if err != nil { + return nil, 0, err + } + + assets := make([]ImageAsset, len(rows)) + for i, row := range rows { + assets[i] = ImageAsset{ + ID: row.ID, + TenantID: row.TenantID, + FolderID: nullableInt64(row.FolderID), + ObjectKey: row.ObjectKey, + Name: row.Name, + SizeBytes: row.SizeBytes, + Status: row.Status, + CreatedBy: row.CreatedBy, + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + } + } + + return assets, int(total), nil +} + +func (r *imageRepository) Get(ctx context.Context, tenantID, id int64) (*ImageAsset, error) { + row, err := r.q.GetImageAsset(ctx, generated.GetImageAssetParams{ + ID: id, + TenantID: tenantID, + }) + if err != nil { + return nil, err + } + + return &ImageAsset{ + ID: row.ID, + TenantID: row.TenantID, + FolderID: nullableInt64(row.FolderID), + ObjectKey: row.ObjectKey, + Name: row.Name, + SizeBytes: row.SizeBytes, + Status: row.Status, + CreatedBy: row.CreatedBy, + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + }, nil +} + +func (r *imageRepository) Insert(ctx context.Context, input InsertImageAssetInput) (int64, error) { + return r.q.InsertImageAsset(ctx, generated.InsertImageAssetParams{ + TenantID: input.TenantID, + FolderID: pgInt8(input.FolderID), + ObjectKey: input.ObjectKey, + Name: input.Name, + SizeBytes: input.SizeBytes, + Status: input.Status, + CreatedBy: input.CreatedBy, + }) +} + +func (r *imageRepository) UpdateStatus(ctx context.Context, tenantID, id int64, status string) error { + return r.q.UpdateImageAssetStatus(ctx, generated.UpdateImageAssetStatusParams{ + Status: status, + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) MarkDeleted(ctx context.Context, tenantID, id int64) error { + return r.q.UpdateImageAssetDeleted(ctx, generated.UpdateImageAssetDeletedParams{ + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) UpdateName(ctx context.Context, tenantID, id int64, name string) error { + return r.q.UpdateImageAssetName(ctx, generated.UpdateImageAssetNameParams{ + Name: name, + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) UpdateFolder(ctx context.Context, tenantID, id int64, folderID *int64) error { + return r.q.UpdateImageAssetFolder(ctx, generated.UpdateImageAssetFolderParams{ + FolderID: pgInt8(folderID), + ID: id, + TenantID: tenantID, + }) +} + +func (r *imageRepository) ListActiveByFolder(ctx context.Context, tenantID, folderID int64) ([]FolderImageSummary, error) { + rows, err := r.q.ListActiveImagesByFolder(ctx, generated.ListActiveImagesByFolderParams{ + TenantID: tenantID, + FolderID: pgInt8(&folderID), + }) + if err != nil { + return nil, err + } + + result := make([]FolderImageSummary, len(rows)) + for i, row := range rows { + result[i] = FolderImageSummary{ + ID: row.ID, + ObjectKey: row.ObjectKey, + SizeBytes: row.SizeBytes, + } + } + return result, nil +} + +func (r *imageRepository) MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, folderID int64) error { + return r.q.MarkImagesInFolderPendingDelete(ctx, generated.MarkImagesInFolderPendingDeleteParams{ + TenantID: tenantID, + FolderID: pgInt8(&folderID), + }) +} + +func (r *imageRepository) CountUncategorized(ctx context.Context, tenantID int64) (int, error) { + count, err := r.q.CountUncategorizedImages(ctx, tenantID) + if err != nil { + return 0, err + } + return int(count), nil +} diff --git a/server/internal/tenant/repository/image_usage_repo.go b/server/internal/tenant/repository/image_usage_repo.go new file mode 100644 index 0000000..4795c6a --- /dev/null +++ b/server/internal/tenant/repository/image_usage_repo.go @@ -0,0 +1,84 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type ImageStorageUsage struct { + UsedBytes int64 + UpdatedAt time.Time +} + +type QuotaReserveResult struct { + UsedBytes int64 + QuotaBytes int64 +} + +type ImageUsageRepository interface { + EnsureRow(ctx context.Context, tenantID int64) error + Get(ctx context.Context, tenantID int64) (*ImageStorageUsage, error) + Release(ctx context.Context, tenantID, releaseBytes int64) error + AtomicReserve(ctx context.Context, tenantID, sizeBytes int64) (*QuotaReserveResult, error) +} + +type imageUsageRepository struct { + q generated.Querier + db generated.DBTX +} + +func NewImageUsageRepository(db generated.DBTX) ImageUsageRepository { + return &imageUsageRepository{q: newQuerier(db), db: db} +} + +func (r *imageUsageRepository) EnsureRow(ctx context.Context, tenantID int64) error { + return r.q.EnsureImageStorageUsageRow(ctx, tenantID) +} + +func (r *imageUsageRepository) Get(ctx context.Context, tenantID int64) (*ImageStorageUsage, error) { + row, err := r.q.GetImageStorageUsage(ctx, tenantID) + if err != nil { + return nil, err + } + return &ImageStorageUsage{ + UsedBytes: row.UsedBytes, + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + }, nil +} + +func (r *imageUsageRepository) Release(ctx context.Context, tenantID, releaseBytes int64) error { + return r.q.ReleaseImageStorageQuota(ctx, generated.ReleaseImageStorageQuotaParams{ + ReleaseBytes: releaseBytes, + TenantID: tenantID, + }) +} + +func (r *imageUsageRepository) AtomicReserve(ctx context.Context, tenantID, sizeBytes int64) (*QuotaReserveResult, error) { + row := r.db.QueryRow(ctx, ` +WITH plan AS ( + SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, 104857600) AS quota_bytes + FROM tenant_plan_subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 + AND s.status = 'active' + AND s.deleted_at IS NULL + LIMIT 1 +) +UPDATE tenant_image_storage_usage u +SET used_bytes = u.used_bytes + $2, + updated_at = NOW() +FROM plan +WHERE u.tenant_id = $1 + AND u.used_bytes + $2 <= plan.quota_bytes +RETURNING u.used_bytes, plan.quota_bytes +`, tenantID, sizeBytes) + + var result QuotaReserveResult + if err := row.Scan(&result.UsedBytes, &result.QuotaBytes); err != nil { + return nil, fmt.Errorf("reserve image storage quota: %w", err) + } + return &result, nil +} diff --git a/server/internal/tenant/repository/queries/image.sql b/server/internal/tenant/repository/queries/image.sql new file mode 100644 index 0000000..99217b1 --- /dev/null +++ b/server/internal/tenant/repository/queries/image.sql @@ -0,0 +1,81 @@ +-- name: ListImageAssets :many +SELECT id, tenant_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 status = 'active' + AND deleted_at IS NULL + AND ( + sqlc.narg(folder_id)::BIGINT IS NULL + OR (sqlc.narg(folder_id)::BIGINT = 0 AND folder_id IS NULL) + OR folder_id = sqlc.narg(folder_id)::BIGINT + ) + AND (sqlc.narg(search_query)::TEXT IS NULL OR name ILIKE '%' || sqlc.narg(search_query)::TEXT || '%') +ORDER BY created_at DESC +LIMIT sqlc.arg(page_size) OFFSET sqlc.arg(page_offset); + +-- name: CountImageAssets :one +SELECT COUNT(*)::INT AS total +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) + AND status = 'active' + AND deleted_at IS NULL + AND ( + sqlc.narg(folder_id)::BIGINT IS NULL + OR (sqlc.narg(folder_id)::BIGINT = 0 AND folder_id IS NULL) + OR folder_id = sqlc.narg(folder_id)::BIGINT + ) + 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 +FROM image_assets +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_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)) +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); + +-- 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); + +-- 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); + +-- 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); + +-- name: ListActiveImagesByFolder :many +SELECT id, object_key, size_bytes +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) + AND folder_id = sqlc.arg(folder_id) + AND status = 'active' + AND deleted_at IS NULL; + +-- name: MarkImagesInFolderPendingDelete :exec +UPDATE image_assets +SET status = 'pending_delete', folder_id = NULL, updated_at = NOW() +WHERE tenant_id = sqlc.arg(tenant_id) + AND folder_id = sqlc.arg(folder_id) + AND status = 'active' + AND deleted_at IS NULL; + +-- name: CountUncategorizedImages :one +SELECT COUNT(*)::INT AS count +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) + AND folder_id IS NULL + AND status = 'active' + AND deleted_at IS NULL; diff --git a/server/internal/tenant/repository/queries/image_folder.sql b/server/internal/tenant/repository/queries/image_folder.sql new file mode 100644 index 0000000..b0afb7c --- /dev/null +++ b/server/internal/tenant/repository/queries/image_folder.sql @@ -0,0 +1,35 @@ +-- name: ListImageFolders :many +SELECT f.id, f.tenant_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 +WHERE f.tenant_id = sqlc.arg(tenant_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)) +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); + +-- name: GetImageFolder :one +SELECT id, tenant_id, name, created_at, updated_at +FROM image_folders +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); + +-- name: DeleteImageFolder :exec +DELETE FROM image_folders +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); + +-- name: CountActiveImagesInFolder :one +SELECT COUNT(*)::INT AS count +FROM image_assets +WHERE tenant_id = sqlc.arg(tenant_id) + AND folder_id = sqlc.arg(folder_id) + AND status = 'active' + AND deleted_at IS NULL; diff --git a/server/internal/tenant/repository/queries/image_reference.sql b/server/internal/tenant/repository/queries/image_reference.sql new file mode 100644 index 0000000..dc7dee0 --- /dev/null +++ b/server/internal/tenant/repository/queries/image_reference.sql @@ -0,0 +1,27 @@ +-- name: ListImageReferences :many +SELECT r.id, r.tenant_id, r.image_asset_id, r.article_id, r.ref_scope, r.created_at +FROM image_asset_references r +WHERE r.tenant_id = sqlc.arg(tenant_id) AND r.image_asset_id = sqlc.arg(image_asset_id); + +-- name: CountImageReferences :one +SELECT COUNT(*)::INT AS total +FROM image_asset_references +WHERE tenant_id = sqlc.arg(tenant_id) AND image_asset_id = sqlc.arg(image_asset_id); + +-- name: UpsertImageReference :exec +INSERT INTO image_asset_references (tenant_id, image_asset_id, article_id, ref_scope) +VALUES (sqlc.arg(tenant_id), sqlc.arg(image_asset_id), sqlc.arg(article_id), sqlc.arg(ref_scope)) +ON CONFLICT (image_asset_id, article_id, ref_scope) DO UPDATE SET updated_at = NOW(); + +-- name: DeleteImageReferencesByArticle :exec +DELETE FROM image_asset_references +WHERE tenant_id = sqlc.arg(tenant_id) AND article_id = sqlc.arg(article_id); + +-- name: DeleteImageReferencesByArticleScope :exec +DELETE FROM image_asset_references +WHERE tenant_id = sqlc.arg(tenant_id) AND article_id = sqlc.arg(article_id) AND ref_scope = sqlc.arg(ref_scope); + +-- name: ListImageReferenceArticles :many +SELECT DISTINCT r.article_id, r.ref_scope +FROM image_asset_references r +WHERE r.tenant_id = sqlc.arg(tenant_id) AND r.image_asset_id = sqlc.arg(image_asset_id); diff --git a/server/internal/tenant/repository/queries/image_usage.sql b/server/internal/tenant/repository/queries/image_usage.sql new file mode 100644 index 0000000..e2b6d76 --- /dev/null +++ b/server/internal/tenant/repository/queries/image_usage.sql @@ -0,0 +1,15 @@ +-- name: EnsureImageStorageUsageRow :exec +INSERT INTO tenant_image_storage_usage (tenant_id, used_bytes) +VALUES (sqlc.arg(tenant_id), 0) +ON CONFLICT (tenant_id) DO NOTHING; + +-- name: GetImageStorageUsage :one +SELECT used_bytes, updated_at +FROM tenant_image_storage_usage +WHERE tenant_id = sqlc.arg(tenant_id); + +-- name: ReleaseImageStorageQuota :exec +UPDATE tenant_image_storage_usage +SET used_bytes = GREATEST(0, used_bytes - sqlc.arg(release_bytes)), + updated_at = NOW() +WHERE tenant_id = sqlc.arg(tenant_id); diff --git a/server/internal/tenant/transport/image_handler.go b/server/internal/tenant/transport/image_handler.go new file mode 100644 index 0000000..28f6c09 --- /dev/null +++ b/server/internal/tenant/transport/image_handler.go @@ -0,0 +1,249 @@ +package transport + +import ( + "io" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +type ImageHandler struct { + imageSvc *app.ImageService + folderSvc *app.ImageFolderService + assets *AssetHandler +} + +func NewImageHandler(a *bootstrap.App) *ImageHandler { + return &ImageHandler{ + imageSvc: app.NewImageService(a.DB, a.ObjectStorage, a.RabbitMQ, a.Logger), + folderSvc: app.NewImageFolderService(a.DB, a.ObjectStorage, a.RabbitMQ, a.Logger), + assets: NewAssetHandler(a), + } +} + +func (h *ImageHandler) ListFolders(c *gin.Context) { + folders, err := h.folderSvc.List(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, folders) +} + +func (h *ImageHandler) CreateFolder(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body")) + return + } + + id, err := h.folderSvc.Create(c.Request.Context(), body.Name) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, http.StatusCreated, gin.H{"id": id}) +} + +func (h *ImageHandler) UpdateFolder(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id")) + return + } + + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body")) + return + } + + if err := h.folderSvc.Update(c.Request.Context(), id, body.Name); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) FolderDeletePreview(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id")) + return + } + + preview, err := h.folderSvc.DeletePreview(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, preview) +} + +func (h *ImageHandler) DeleteFolder(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id")) + return + } + + if err := h.folderSvc.Delete(c.Request.Context(), id, c.Query("force") == "1"); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) ListImages(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + folderID, err := parseImageFolderFilter(c.Query("folder_id")) + if err != nil { + response.Error(c, err) + return + } + + var query *string + if q := strings.TrimSpace(c.Query("q")); q != "" { + query = &q + } + + result, err := h.imageSvc.List(c.Request.Context(), folderID, query, page, pageSize, h.assets.BuildArticleImageURL) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) +} + +func (h *ImageHandler) UploadImage(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11*1024*1024) + file, header, err := c.Request.FormFile("file") + if err != nil { + if strings.Contains(err.Error(), "too large") { + response.Error(c, response.ErrBadRequest(40011, "image_too_large", "image size cannot exceed 10 MB")) + return + } + response.Error(c, response.ErrBadRequest(40000, "missing_file", "file is required")) + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "read_file_failed", "failed to read file")) + return + } + + folderID, err := parseAssignableFolderID(c.PostForm("folder_id")) + if err != nil { + response.Error(c, err) + return + } + + result, err := h.imageSvc.Upload(c.Request.Context(), header.Filename, content, folderID, h.assets.BuildArticleImageURL) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, http.StatusCreated, result) +} + +func (h *ImageHandler) UpdateImage(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id")) + return + } + + var body app.ImageUpdateRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body")) + return + } + + if err := h.imageSvc.Update(c.Request.Context(), id, body); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) GetImageReferences(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id")) + return + } + + refs, err := h.imageSvc.GetReferences(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, refs) +} + +func (h *ImageHandler) DeleteImage(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id")) + return + } + + if err := h.imageSvc.Delete(c.Request.Context(), id, c.Query("force") == "1"); err != nil { + response.Error(c, err) + return + } + response.Success(c, nil) +} + +func (h *ImageHandler) StorageUsage(c *gin.Context) { + usage, err := h.imageSvc.GetStorageUsage(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, usage) +} + +func parseImageFolderFilter(raw string) (*int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if raw == "null" || raw == "uncategorized" { + uncategorized := int64(0) + return &uncategorized, nil + } + + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + return nil, response.ErrBadRequest(40003, "invalid_folder_id", "folder_id must be a positive integer") + } + return &value, nil +} + +func parseAssignableFolderID(raw string) (*int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "null" || raw == "uncategorized" { + return nil, nil + } + + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + return nil, response.ErrBadRequest(40003, "invalid_folder_id", "folder_id must be a positive integer") + } + return &value, nil +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index cbe784a..f5e7a81 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -145,4 +145,20 @@ func RegisterRoutes(app *bootstrap.App) { instantTasks := protected.Group("/tenant/instant-tasks") instHandler := NewInstantTaskHandler(app) instantTasks.GET("", instHandler.List) + + images := protected.Group("/tenant/images") + imgHandler := NewImageHandler(app) + 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 := protected.Group("/tenant/images/folders") + imageFolders.GET("", imgHandler.ListFolders) + imageFolders.POST("", imgHandler.CreateFolder) + imageFolders.PUT("/:id", imgHandler.UpdateFolder) + imageFolders.GET("/:id/delete-preview", imgHandler.FolderDeletePreview) + imageFolders.DELETE("/:id", imgHandler.DeleteFolder) } diff --git a/server/migrations/20260415100000_create_image_tables.down.sql b/server/migrations/20260415100000_create_image_tables.down.sql new file mode 100644 index 0000000..59a0ec4 --- /dev/null +++ b/server/migrations/20260415100000_create_image_tables.down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS image_asset_references; +DROP TABLE IF EXISTS image_assets; +DROP TABLE IF EXISTS image_folders; +DROP TABLE IF EXISTS tenant_image_storage_usage; + +UPDATE plans +SET quota_policy_json = quota_policy_json - 'image_storage_bytes' +WHERE deleted_at IS NULL; diff --git a/server/migrations/20260415100000_create_image_tables.up.sql b/server/migrations/20260415100000_create_image_tables.up.sql new file mode 100644 index 0000000..8d19829 --- /dev/null +++ b/server/migrations/20260415100000_create_image_tables.up.sql @@ -0,0 +1,70 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE image_folders ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_image_folders_tenant_name UNIQUE (tenant_id, name), + CONSTRAINT uq_image_folders_tenant_id UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_image_folders_tenant ON image_folders (tenant_id); + +CREATE TABLE image_assets ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + folder_id BIGINT, + object_key TEXT NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + size_bytes BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_by BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT fk_image_assets_folder + FOREIGN KEY (tenant_id, folder_id) + REFERENCES image_folders(tenant_id, id), + CONSTRAINT uq_image_assets_tenant_id UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_image_assets_tenant_folder ON image_assets (tenant_id, folder_id) WHERE status = 'active' AND deleted_at IS NULL; +CREATE INDEX idx_image_assets_tenant_created ON image_assets (tenant_id, created_at DESC) WHERE status = 'active' AND deleted_at IS NULL; +CREATE INDEX idx_image_assets_tenant_status ON image_assets (tenant_id, status, created_at DESC); +CREATE INDEX idx_image_assets_name_trgm ON image_assets USING GIN (name gin_trgm_ops) WHERE status = 'active' AND deleted_at IS NULL; + +CREATE TABLE image_asset_references ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + image_asset_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + ref_scope VARCHAR(20) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_image_asset_references_asset + FOREIGN KEY (tenant_id, image_asset_id) + REFERENCES image_assets(tenant_id, id) + ON DELETE CASCADE, + CONSTRAINT uq_image_asset_reference UNIQUE (image_asset_id, article_id, ref_scope) +); + +CREATE INDEX idx_image_asset_references_asset ON image_asset_references (tenant_id, image_asset_id); +CREATE INDEX idx_image_asset_references_article ON image_asset_references (tenant_id, article_id); + +CREATE TABLE tenant_image_storage_usage ( + tenant_id BIGINT PRIMARY KEY, + used_bytes BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +UPDATE plans +SET quota_policy_json = quota_policy_json || + CASE plan_code + WHEN 'free' THEN '{"image_storage_bytes":104857600}'::jsonb + WHEN 'regular' THEN '{"image_storage_bytes":1073741824}'::jsonb + WHEN 'premium' THEN '{"image_storage_bytes":2147483648}'::jsonb + ELSE '{}'::jsonb + END +WHERE deleted_at IS NULL; diff --git a/server/migrations/20260416113000_add_image_asset_content_hash.down.sql b/server/migrations/20260416113000_add_image_asset_content_hash.down.sql new file mode 100644 index 0000000..c758343 --- /dev/null +++ b/server/migrations/20260416113000_add_image_asset_content_hash.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_image_assets_tenant_content_hash_active; + +ALTER TABLE image_assets +DROP COLUMN IF EXISTS content_hash; diff --git a/server/migrations/20260416113000_add_image_asset_content_hash.up.sql b/server/migrations/20260416113000_add_image_asset_content_hash.up.sql new file mode 100644 index 0000000..0f8fea0 --- /dev/null +++ b/server/migrations/20260416113000_add_image_asset_content_hash.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE image_assets +ADD COLUMN content_hash VARCHAR(64); + +CREATE INDEX idx_image_assets_tenant_content_hash_active +ON image_assets (tenant_id, content_hash) +WHERE content_hash IS NOT NULL + AND status = 'active' + AND deleted_at IS NULL;