Files
geo/docs/superpowers/plans/2026-04-15-image-management.md
T
root 27389164b0 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.
2026-04-16 20:40:41 +08:00

76 KiB

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:

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:

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
cd server && go run cmd/migrate/main.go up
  • Step 4: Commit
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:

-- 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:

-- 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:

-- 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:

-- 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
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
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:

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:

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:

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:

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
cd server && go build ./...
  • Step 6: Commit
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):

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):

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:

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:

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
cd server && go build ./...
  • Step 4: Commit
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:

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
cd server && go build ./...
  • Step 3: Commit
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):

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(&quotaBytes); 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
cd server && go get github.com/chai2010/webp && go get golang.org/x/image/webp
  • Step 3: Verify compilation
cd server && go build ./...
  • Step 4: Commit
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:

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
cd server && go build ./...
  • Step 3: Commit
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 }):

	// 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
cd server && go build ./...
  • Step 3: Commit
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:

// --- 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
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:

export const imagesApi = {
  listFolders() {
    return apiClient.get<ImageFolder[]>("/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<null, { name: string }>(`/api/tenant/images/folders/${id}`, { name });
  },
  folderDeletePreview(id: number) {
    return apiClient.get<FolderDeletePreview>(`/api/tenant/images/folders/${id}/delete-preview`);
  },
  deleteFolder(id: number, force = false) {
    return apiClient.remove<null>(`/api/tenant/images/folders/${id}${force ? "?force=1" : ""}`);
  },
  list(params?: { folder_id?: number | string; q?: string; page?: number; page_size?: number }) {
    return apiClient.get<ImageAssetListResponse>("/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<null, ImageUpdateRequest>(`/api/tenant/images/${id}`, payload);
  },
  getReferences(id: number) {
    return apiClient.get<ImageReferenceDetail>(`/api/tenant/images/${id}/references`);
  },
  delete(id: number, force = false) {
    return apiClient.remove<null>(`/api/tenant/images/${id}${force ? "?force=1" : ""}`);
  },
  storageUsage() {
    return apiClient.get<ImageStorageUsage>("/api/tenant/images/storage-usage");
  },
};
  • Step 2: Commit
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:

images: "图片管理",

Add the route entry inside route:

images: {
  title: "图片管理",
  description: "管理图片素材库,支持文件夹分类和搜索。",
},

Add top-level images section (at the same level as nav, auth, etc.):

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:

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
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):

{
  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:

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:

{
  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
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
  • <a-upload> 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
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

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
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:
import ImageLibraryPicker from "@/components/ImageLibraryPicker.vue";
  1. Add a ref for the selected library image:
const selectedLibraryImage = ref<{ url: string; assetId: number } | null>(null);
  1. Replace the <a-tab-pane key="ai"> block with:
<a-tab-pane key="library" :tab="t('images.libraryTab')">
  <ImageLibraryPicker
    :single-select="true"
    @select="handleLibrarySelect"
  />
  <div v-if="selectedLibraryImage" style="margin-top: 12px; text-align: right;">
    <a-button type="primary" @click="confirmLibraryImage">
      {{ t("common.confirm") }}
    </a-button>
  </div>
</a-tab-pane>
  1. Add handlers:
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);
}
  1. 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

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:

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:

// 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
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.