- 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.
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("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
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
useQuerywith queryKey["images", "list", { folder_id, q, page }]and["images", "folders"] - Use
useMutationfor 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 selectedonSelect: (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:
- CoverPickerModal "本地素材" tab
- (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:
- Import the
ImageLibraryPickercomponent:
import ImageLibraryPicker from "@/components/ImageLibraryPicker.vue";
- Add a ref for the selected library image:
const selectedLibraryImage = ref<{ url: string; assetId: number } | null>(null);
- 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>
- 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);
}
- Update the i18n key for the tab label — the
t('coverPicker.tabs.ai')is now replaced witht('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:
-
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.
-
RabbitMQ consumer for finalize/compensation — The spec describes async compensation consumers for
finalize_uploadandfinalize_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. -
Folder delete with image finalization — Currently folder delete marks images as
pending_deletebut does not trigger the OSS deletion of each image. A follow-up should add a background job that processespending_deleteimages.