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

2542 lines
76 KiB
Markdown

# Image Management Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a standalone image asset management system with folder organization, WebP conversion, quota enforcement, and article editor integration.
**Architecture:** Layered Go backend (repository/app/transport) with sqlc-generated queries, atomic quota counter, and RabbitMQ-based compensation for upload/delete finalization. Vue 3 + Ant Design frontend with a new `/images` page and library picker integrated into the article editor and cover modal.
**Tech Stack:** Go (gin, pgx, chai2010/webp, x/image/webp), PostgreSQL (pg_trgm), RabbitMQ, Vue 3, TypeScript, Ant Design Vue, TanStack Query.
---
## File Structure
### Backend — New Files
| File | Responsibility |
|------|---------------|
| `server/migrations/20260415100000_create_image_tables.up.sql` | DDL for 4 tables + indexes + plan seed update |
| `server/migrations/20260415100000_create_image_tables.down.sql` | Rollback DDL |
| `server/internal/tenant/repository/queries/image.sql` | sqlc queries for image_assets |
| `server/internal/tenant/repository/queries/image_folder.sql` | sqlc queries for image_folders |
| `server/internal/tenant/repository/queries/image_reference.sql` | sqlc queries for image_asset_references |
| `server/internal/tenant/repository/queries/image_usage.sql` | sqlc queries for tenant_image_storage_usage |
| `server/internal/tenant/repository/image_repo.go` | ImageRepository interface + impl |
| `server/internal/tenant/repository/image_folder_repo.go` | ImageFolderRepository interface + impl |
| `server/internal/tenant/repository/image_reference_repo.go` | ImageReferenceRepository interface + impl |
| `server/internal/tenant/repository/image_usage_repo.go` | ImageUsageRepository interface + impl |
| `server/internal/tenant/app/image_service.go` | ImageService (upload pipeline, list, update, delete, quota) |
| `server/internal/tenant/app/image_folder_service.go` | ImageFolderService (CRUD) |
| `server/internal/tenant/transport/image_handler.go` | HTTP handlers for all /api/tenant/images endpoints |
### Backend — Modified Files
| File | Change |
|------|--------|
| `server/internal/shared/config/config.go` | Add ImageAsset queue config fields to RabbitMQConfig |
| `server/internal/shared/messaging/rabbitmq/client.go` | Add Publish/Consume for image asset queue + topology |
| `server/internal/tenant/transport/router.go` | Register image routes |
| `server/internal/tenant/repository/sqlc.yaml` | No change needed — queries auto-discovered from queries/ dir |
### Frontend — New Files
| File | Responsibility |
|------|---------------|
| `apps/admin-web/src/views/ImagesView.vue` | Main `/images` page with folder sidebar + image grid |
| `apps/admin-web/src/components/ImageLibraryPicker.vue` | Reusable image library picker (used in cover modal + editor) |
### Frontend — Modified Files
| File | Change |
|------|--------|
| `packages/shared-types/src/index.ts` | Add image-related TypeScript interfaces |
| `apps/admin-web/src/lib/api.ts` | Add `imagesApi` client |
| `apps/admin-web/src/router/index.ts` | Add `/images` route |
| `apps/admin-web/src/layouts/AppShell.vue` | Add nav item under contentManagement |
| `apps/admin-web/src/i18n/messages/zh-CN.ts` | Add Chinese translations for images feature |
| `apps/admin-web/src/i18n/messages/en-US.ts` | Add English translations for images feature |
| `apps/admin-web/src/components/CoverPickerModal.vue` | Replace "AI封面" tab with "本地素材" library picker tab |
---
## Task 1: Database Migration
**Files:**
- Create: `server/migrations/20260415100000_create_image_tables.up.sql`
- Create: `server/migrations/20260415100000_create_image_tables.down.sql`
- [ ] **Step 1: Create up migration**
Create `server/migrations/20260415100000_create_image_tables.up.sql`:
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE image_folders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_image_folders_tenant_name UNIQUE (tenant_id, name),
CONSTRAINT uq_image_folders_tenant_id UNIQUE (tenant_id, id)
);
CREATE INDEX idx_image_folders_tenant ON image_folders (tenant_id);
CREATE TABLE image_assets (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
folder_id BIGINT,
object_key TEXT NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_by BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT fk_image_assets_folder
FOREIGN KEY (tenant_id, folder_id)
REFERENCES image_folders(tenant_id, id),
CONSTRAINT uq_image_assets_tenant_id UNIQUE (tenant_id, id)
);
CREATE INDEX idx_image_assets_tenant_folder ON image_assets (tenant_id, folder_id) WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX idx_image_assets_tenant_created ON image_assets (tenant_id, created_at DESC) WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX idx_image_assets_tenant_status ON image_assets (tenant_id, status, created_at DESC);
CREATE INDEX idx_image_assets_name_trgm ON image_assets USING GIN (name gin_trgm_ops) WHERE status = 'active' AND deleted_at IS NULL;
CREATE TABLE image_asset_references (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
image_asset_id BIGINT NOT NULL,
article_id BIGINT NOT NULL,
ref_scope VARCHAR(20) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_image_asset_references_asset
FOREIGN KEY (tenant_id, image_asset_id)
REFERENCES image_assets(tenant_id, id)
ON DELETE CASCADE,
CONSTRAINT uq_image_asset_reference UNIQUE (image_asset_id, article_id, ref_scope)
);
CREATE INDEX idx_image_asset_references_asset ON image_asset_references (tenant_id, image_asset_id);
CREATE INDEX idx_image_asset_references_article ON image_asset_references (tenant_id, article_id);
CREATE TABLE tenant_image_storage_usage (
tenant_id BIGINT PRIMARY KEY,
used_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
UPDATE plans
SET quota_policy_json = quota_policy_json ||
CASE plan_code
WHEN 'free' THEN '{"image_storage_bytes":104857600}'::jsonb
WHEN 'regular' THEN '{"image_storage_bytes":1073741824}'::jsonb
WHEN 'premium' THEN '{"image_storage_bytes":2147483648}'::jsonb
ELSE '{}'::jsonb
END
WHERE deleted_at IS NULL;
```
- [ ] **Step 2: Create down migration**
Create `server/migrations/20260415100000_create_image_tables.down.sql`:
```sql
DROP TABLE IF EXISTS image_asset_references;
DROP TABLE IF EXISTS image_assets;
DROP TABLE IF EXISTS image_folders;
DROP TABLE IF EXISTS tenant_image_storage_usage;
UPDATE plans
SET quota_policy_json = quota_policy_json - 'image_storage_bytes'
WHERE deleted_at IS NULL;
```
- [ ] **Step 3: Run migration**
```bash
cd server && go run cmd/migrate/main.go up
```
- [ ] **Step 4: Commit**
```bash
git add server/migrations/20260415100000_create_image_tables.up.sql server/migrations/20260415100000_create_image_tables.down.sql
git commit -m "feat(images): add database migration for image management tables"
```
---
## Task 2: sqlc Queries
**Files:**
- Create: `server/internal/tenant/repository/queries/image.sql`
- Create: `server/internal/tenant/repository/queries/image_folder.sql`
- Create: `server/internal/tenant/repository/queries/image_reference.sql`
- Create: `server/internal/tenant/repository/queries/image_usage.sql`
- [ ] **Step 1: Create image folder queries**
Create `server/internal/tenant/repository/queries/image_folder.sql`:
```sql
-- name: ListImageFolders :many
SELECT f.id, f.tenant_id, f.name, f.created_at, f.updated_at,
COUNT(ia.id) FILTER (WHERE ia.status = 'active' AND ia.deleted_at IS NULL)::INT AS image_count
FROM image_folders f
LEFT JOIN image_assets ia ON ia.tenant_id = f.tenant_id AND ia.folder_id = f.id
WHERE f.tenant_id = sqlc.arg(tenant_id)
GROUP BY f.id
ORDER BY f.name;
-- name: CreateImageFolder :one
INSERT INTO image_folders (tenant_id, name)
VALUES (sqlc.arg(tenant_id), sqlc.arg(name))
RETURNING id;
-- name: UpdateImageFolder :exec
UPDATE image_folders SET name = sqlc.arg(name), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: GetImageFolder :one
SELECT id, tenant_id, name, created_at, updated_at
FROM image_folders
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: DeleteImageFolder :exec
DELETE FROM image_folders WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: CountActiveImagesInFolder :one
SELECT COUNT(*)::INT AS count
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id)
AND status = 'active' AND deleted_at IS NULL;
```
- [ ] **Step 2: Create image asset queries**
Create `server/internal/tenant/repository/queries/image.sql`:
```sql
-- name: ListImageAssets :many
SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND status = 'active' AND deleted_at IS NULL
AND (sqlc.narg(folder_id)::BIGINT IS NULL OR folder_id IS NOT DISTINCT FROM sqlc.narg(folder_id)::BIGINT)
AND (sqlc.narg(search_query)::TEXT IS NULL OR name ILIKE '%' || sqlc.narg(search_query)::TEXT || '%')
ORDER BY created_at DESC
LIMIT sqlc.arg(page_size) OFFSET sqlc.arg(page_offset);
-- name: CountImageAssets :one
SELECT COUNT(*)::INT AS total
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id)
AND status = 'active' AND deleted_at IS NULL
AND (sqlc.narg(folder_id)::BIGINT IS NULL OR folder_id IS NOT DISTINCT FROM sqlc.narg(folder_id)::BIGINT)
AND (sqlc.narg(search_query)::TEXT IS NULL OR name ILIKE '%' || sqlc.narg(search_query)::TEXT || '%');
-- name: GetImageAsset :one
SELECT id, tenant_id, folder_id, object_key, name, size_bytes, status, created_by, created_at, updated_at
FROM image_assets
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: InsertImageAsset :one
INSERT INTO image_assets (tenant_id, folder_id, object_key, name, size_bytes, status, created_by)
VALUES (sqlc.arg(tenant_id), sqlc.narg(folder_id), sqlc.arg(object_key), sqlc.arg(name), sqlc.arg(size_bytes), sqlc.arg(status), sqlc.arg(created_by))
RETURNING id;
-- name: UpdateImageAssetStatus :exec
UPDATE image_assets SET status = sqlc.arg(status), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: UpdateImageAssetDeleted :exec
UPDATE image_assets SET status = 'deleted', deleted_at = NOW(), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: UpdateImageAssetName :exec
UPDATE image_assets SET name = sqlc.arg(name), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: UpdateImageAssetFolder :exec
UPDATE image_assets SET folder_id = sqlc.narg(folder_id), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: ListActiveImagesByFolder :many
SELECT id, object_key, size_bytes
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id)
AND status = 'active' AND deleted_at IS NULL;
-- name: NullifyFolderForImages :exec
UPDATE image_assets SET folder_id = NULL, updated_at = NOW()
WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id)
AND status = 'active' AND deleted_at IS NULL;
-- name: MarkImagesInFolderPendingDelete :exec
UPDATE image_assets SET status = 'pending_delete', folder_id = NULL, updated_at = NOW()
WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id = sqlc.arg(folder_id)
AND status = 'active' AND deleted_at IS NULL;
-- name: CountUncategorizedImages :one
SELECT COUNT(*)::INT AS count
FROM image_assets
WHERE tenant_id = sqlc.arg(tenant_id) AND folder_id IS NULL
AND status = 'active' AND deleted_at IS NULL;
```
- [ ] **Step 3: Create image reference queries**
Create `server/internal/tenant/repository/queries/image_reference.sql`:
```sql
-- name: ListImageReferences :many
SELECT r.id, r.tenant_id, r.image_asset_id, r.article_id, r.ref_scope, r.created_at
FROM image_asset_references r
WHERE r.tenant_id = sqlc.arg(tenant_id) AND r.image_asset_id = sqlc.arg(image_asset_id);
-- name: CountImageReferences :one
SELECT COUNT(*)::INT AS total
FROM image_asset_references
WHERE tenant_id = sqlc.arg(tenant_id) AND image_asset_id = sqlc.arg(image_asset_id);
-- name: UpsertImageReference :exec
INSERT INTO image_asset_references (tenant_id, image_asset_id, article_id, ref_scope)
VALUES (sqlc.arg(tenant_id), sqlc.arg(image_asset_id), sqlc.arg(article_id), sqlc.arg(ref_scope))
ON CONFLICT (image_asset_id, article_id, ref_scope) DO UPDATE SET updated_at = NOW();
-- name: DeleteImageReferencesByArticle :exec
DELETE FROM image_asset_references
WHERE tenant_id = sqlc.arg(tenant_id) AND article_id = sqlc.arg(article_id);
-- name: DeleteImageReferencesByArticleScope :exec
DELETE FROM image_asset_references
WHERE tenant_id = sqlc.arg(tenant_id) AND article_id = sqlc.arg(article_id) AND ref_scope = sqlc.arg(ref_scope);
-- name: ListImageReferenceArticles :many
SELECT DISTINCT r.article_id, r.ref_scope
FROM image_asset_references r
WHERE r.tenant_id = sqlc.arg(tenant_id) AND r.image_asset_id = sqlc.arg(image_asset_id);
```
- [ ] **Step 4: Create image usage queries**
Create `server/internal/tenant/repository/queries/image_usage.sql`:
```sql
-- name: EnsureImageStorageUsageRow :exec
INSERT INTO tenant_image_storage_usage (tenant_id, used_bytes)
VALUES (sqlc.arg(tenant_id), 0)
ON CONFLICT (tenant_id) DO NOTHING;
-- name: GetImageStorageUsage :one
SELECT used_bytes, updated_at
FROM tenant_image_storage_usage
WHERE tenant_id = sqlc.arg(tenant_id);
-- name: ReleaseImageStorageQuota :exec
UPDATE tenant_image_storage_usage
SET used_bytes = GREATEST(0, used_bytes - sqlc.arg(release_bytes)),
updated_at = NOW()
WHERE tenant_id = sqlc.arg(tenant_id);
```
Note: The atomic quota reserve query is too complex for sqlc (CTE + conditional UPDATE ... FROM ... RETURNING). It will be executed as raw SQL in the repository layer.
- [ ] **Step 5: Run sqlc generate**
```bash
cd server/internal/tenant/repository && sqlc generate
```
Verify generated files appear in `generated/` directory including `image.sql.go`, `image_folder.sql.go`, `image_reference.sql.go`, `image_usage.sql.go`, updated `querier.go` and `models.go`.
- [ ] **Step 6: Commit**
```bash
git add server/internal/tenant/repository/queries/ server/internal/tenant/repository/generated/
git commit -m "feat(images): add sqlc queries and generated code for image management"
```
---
## Task 3: Repository Layer
**Files:**
- Create: `server/internal/tenant/repository/image_folder_repo.go`
- Create: `server/internal/tenant/repository/image_repo.go`
- Create: `server/internal/tenant/repository/image_reference_repo.go`
- Create: `server/internal/tenant/repository/image_usage_repo.go`
- [ ] **Step 1: Create image folder repository**
Create `server/internal/tenant/repository/image_folder_repo.go`:
```go
package repository
import (
"context"
"time"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ImageFolder struct {
ID int64
TenantID int64
Name string
ImageCount int
CreatedAt time.Time
UpdatedAt time.Time
}
type ImageFolderRepository interface {
List(ctx context.Context, tenantID int64) ([]ImageFolder, error)
Get(ctx context.Context, tenantID, id int64) (*ImageFolder, error)
Create(ctx context.Context, tenantID int64, name string) (int64, error)
Update(ctx context.Context, tenantID, id int64, name string) error
Delete(ctx context.Context, tenantID, id int64) error
CountActiveImages(ctx context.Context, tenantID, folderID int64) (int, error)
}
type imageFolderRepository struct {
q generated.Querier
}
func NewImageFolderRepository(db generated.DBTX) ImageFolderRepository {
return &imageFolderRepository{q: newQuerier(db)}
}
func (r *imageFolderRepository) List(ctx context.Context, tenantID int64) ([]ImageFolder, error) {
rows, err := r.q.ListImageFolders(ctx, tenantID)
if err != nil {
return nil, err
}
folders := make([]ImageFolder, len(rows))
for i, row := range rows {
folders[i] = ImageFolder{
ID: row.ID,
TenantID: row.TenantID,
Name: row.Name,
ImageCount: int(row.ImageCount),
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}
}
return folders, nil
}
func (r *imageFolderRepository) Get(ctx context.Context, tenantID, id int64) (*ImageFolder, error) {
row, err := r.q.GetImageFolder(ctx, generated.GetImageFolderParams{
ID: id,
TenantID: tenantID,
})
if err != nil {
return nil, err
}
return &ImageFolder{
ID: row.ID,
TenantID: row.TenantID,
Name: row.Name,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
func (r *imageFolderRepository) Create(ctx context.Context, tenantID int64, name string) (int64, error) {
return r.q.CreateImageFolder(ctx, generated.CreateImageFolderParams{
TenantID: tenantID,
Name: name,
})
}
func (r *imageFolderRepository) Update(ctx context.Context, tenantID, id int64, name string) error {
return r.q.UpdateImageFolder(ctx, generated.UpdateImageFolderParams{
Name: name,
ID: id,
TenantID: tenantID,
})
}
func (r *imageFolderRepository) Delete(ctx context.Context, tenantID, id int64) error {
return r.q.DeleteImageFolder(ctx, generated.DeleteImageFolderParams{
ID: id,
TenantID: tenantID,
})
}
func (r *imageFolderRepository) CountActiveImages(ctx context.Context, tenantID, folderID int64) (int, error) {
count, err := r.q.CountActiveImagesInFolder(ctx, generated.CountActiveImagesInFolderParams{
TenantID: tenantID,
FolderID: folderID,
})
if err != nil {
return 0, err
}
return int(count), nil
}
```
- [ ] **Step 2: Create image asset repository**
Create `server/internal/tenant/repository/image_repo.go`:
```go
package repository
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ImageAsset struct {
ID int64
TenantID int64
FolderID *int64
ObjectKey string
Name string
SizeBytes int64
Status string
CreatedBy int64
CreatedAt time.Time
UpdatedAt time.Time
}
type ImageAssetListParams struct {
TenantID int64
FolderID *int64
SearchQuery *string
PageSize int
PageOffset int
}
type InsertImageAssetInput struct {
TenantID int64
FolderID *int64
ObjectKey string
Name string
SizeBytes int64
Status string
CreatedBy int64
}
type FolderImageSummary struct {
ID int64
ObjectKey string
SizeBytes int64
}
type ImageRepository interface {
List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error)
Get(ctx context.Context, tenantID, id int64) (*ImageAsset, error)
Insert(ctx context.Context, input InsertImageAssetInput) (int64, error)
UpdateStatus(ctx context.Context, tenantID, id int64, status string) error
MarkDeleted(ctx context.Context, tenantID, id int64) error
UpdateName(ctx context.Context, tenantID, id int64, name string) error
UpdateFolder(ctx context.Context, tenantID, id int64, folderID *int64) error
ListActiveByFolder(ctx context.Context, tenantID, folderID int64) ([]FolderImageSummary, error)
MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, folderID int64) error
CountUncategorized(ctx context.Context, tenantID int64) (int, error)
}
type imageRepository struct {
q generated.Querier
}
func NewImageRepository(db generated.DBTX) ImageRepository {
return &imageRepository{q: newQuerier(db)}
}
func (r *imageRepository) List(ctx context.Context, params ImageAssetListParams) ([]ImageAsset, int, error) {
listParams := generated.ListImageAssetsParams{
TenantID: params.TenantID,
FolderID: pgInt8(params.FolderID),
PageSize: int32(params.PageSize),
PageOffset: int32(params.PageOffset),
}
if params.SearchQuery != nil {
listParams.SearchQuery = pgText(params.SearchQuery)
}
rows, err := r.q.ListImageAssets(ctx, listParams)
if err != nil {
return nil, 0, err
}
countParams := generated.CountImageAssetsParams{
TenantID: params.TenantID,
FolderID: pgInt8(params.FolderID),
SearchQuery: listParams.SearchQuery,
}
total, err := r.q.CountImageAssets(ctx, countParams)
if err != nil {
return nil, 0, err
}
assets := make([]ImageAsset, len(rows))
for i, row := range rows {
assets[i] = ImageAsset{
ID: row.ID,
TenantID: row.TenantID,
FolderID: nullableInt64(row.FolderID),
ObjectKey: row.ObjectKey,
Name: row.Name,
SizeBytes: row.SizeBytes,
Status: row.Status,
CreatedBy: row.CreatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}
}
return assets, int(total), nil
}
func (r *imageRepository) Get(ctx context.Context, tenantID, id int64) (*ImageAsset, error) {
row, err := r.q.GetImageAsset(ctx, generated.GetImageAssetParams{
ID: id,
TenantID: tenantID,
})
if err != nil {
return nil, err
}
return &ImageAsset{
ID: row.ID,
TenantID: row.TenantID,
FolderID: nullableInt64(row.FolderID),
ObjectKey: row.ObjectKey,
Name: row.Name,
SizeBytes: row.SizeBytes,
Status: row.Status,
CreatedBy: row.CreatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
func (r *imageRepository) Insert(ctx context.Context, input InsertImageAssetInput) (int64, error) {
return r.q.InsertImageAsset(ctx, generated.InsertImageAssetParams{
TenantID: input.TenantID,
FolderID: pgInt8(input.FolderID),
ObjectKey: input.ObjectKey,
Name: input.Name,
SizeBytes: input.SizeBytes,
Status: input.Status,
CreatedBy: input.CreatedBy,
})
}
func (r *imageRepository) UpdateStatus(ctx context.Context, tenantID, id int64, status string) error {
return r.q.UpdateImageAssetStatus(ctx, generated.UpdateImageAssetStatusParams{
Status: status,
ID: id,
TenantID: tenantID,
})
}
func (r *imageRepository) MarkDeleted(ctx context.Context, tenantID, id int64) error {
return r.q.UpdateImageAssetDeleted(ctx, generated.UpdateImageAssetDeletedParams{
ID: id,
TenantID: tenantID,
})
}
func (r *imageRepository) UpdateName(ctx context.Context, tenantID, id int64, name string) error {
return r.q.UpdateImageAssetName(ctx, generated.UpdateImageAssetNameParams{
Name: name,
ID: id,
TenantID: tenantID,
})
}
func (r *imageRepository) UpdateFolder(ctx context.Context, tenantID, id int64, folderID *int64) error {
return r.q.UpdateImageAssetFolder(ctx, generated.UpdateImageAssetFolderParams{
FolderID: pgInt8(folderID),
ID: id,
TenantID: tenantID,
})
}
func (r *imageRepository) ListActiveByFolder(ctx context.Context, tenantID, folderID int64) ([]FolderImageSummary, error) {
rows, err := r.q.ListActiveImagesByFolder(ctx, generated.ListActiveImagesByFolderParams{
TenantID: tenantID,
FolderID: folderID,
})
if err != nil {
return nil, err
}
result := make([]FolderImageSummary, len(rows))
for i, row := range rows {
result[i] = FolderImageSummary{
ID: row.ID,
ObjectKey: row.ObjectKey,
SizeBytes: row.SizeBytes,
}
}
return result, nil
}
func (r *imageRepository) MarkImagesInFolderPendingDelete(ctx context.Context, tenantID, folderID int64) error {
return r.q.MarkImagesInFolderPendingDelete(ctx, generated.MarkImagesInFolderPendingDeleteParams{
TenantID: tenantID,
FolderID: folderID,
})
}
func (r *imageRepository) CountUncategorized(ctx context.Context, tenantID int64) (int, error) {
count, err := r.q.CountUncategorizedImages(ctx, tenantID)
if err != nil {
return 0, err
}
return int(count), nil
}
```
Note: The `pgInt8`, `pgText`, `nullableInt64`, `timeFromTimestamp` helpers already exist in `common.go`.
- [ ] **Step 3: Create image reference repository**
Create `server/internal/tenant/repository/image_reference_repo.go`:
```go
package repository
import (
"context"
"time"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ImageReference struct {
ID int64
TenantID int64
ImageAssetID int64
ArticleID int64
RefScope string
CreatedAt time.Time
}
type ImageReferenceArticle struct {
ArticleID int64
RefScope string
}
type ImageReferenceRepository interface {
List(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReference, error)
Count(ctx context.Context, tenantID, imageAssetID int64) (int, error)
Upsert(ctx context.Context, tenantID, imageAssetID, articleID int64, refScope string) error
DeleteByArticle(ctx context.Context, tenantID, articleID int64) error
DeleteByArticleScope(ctx context.Context, tenantID, articleID int64, refScope string) error
ListArticles(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReferenceArticle, error)
}
type imageReferenceRepository struct {
q generated.Querier
}
func NewImageReferenceRepository(db generated.DBTX) ImageReferenceRepository {
return &imageReferenceRepository{q: newQuerier(db)}
}
func (r *imageReferenceRepository) List(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReference, error) {
rows, err := r.q.ListImageReferences(ctx, generated.ListImageReferencesParams{
TenantID: tenantID,
ImageAssetID: imageAssetID,
})
if err != nil {
return nil, err
}
refs := make([]ImageReference, len(rows))
for i, row := range rows {
refs[i] = ImageReference{
ID: row.ID,
TenantID: row.TenantID,
ImageAssetID: row.ImageAssetID,
ArticleID: row.ArticleID,
RefScope: row.RefScope,
CreatedAt: timeFromTimestamp(row.CreatedAt),
}
}
return refs, nil
}
func (r *imageReferenceRepository) Count(ctx context.Context, tenantID, imageAssetID int64) (int, error) {
count, err := r.q.CountImageReferences(ctx, generated.CountImageReferencesParams{
TenantID: tenantID,
ImageAssetID: imageAssetID,
})
if err != nil {
return 0, err
}
return int(count), nil
}
func (r *imageReferenceRepository) Upsert(ctx context.Context, tenantID, imageAssetID, articleID int64, refScope string) error {
return r.q.UpsertImageReference(ctx, generated.UpsertImageReferenceParams{
TenantID: tenantID,
ImageAssetID: imageAssetID,
ArticleID: articleID,
RefScope: refScope,
})
}
func (r *imageReferenceRepository) DeleteByArticle(ctx context.Context, tenantID, articleID int64) error {
return r.q.DeleteImageReferencesByArticle(ctx, generated.DeleteImageReferencesByArticleParams{
TenantID: tenantID,
ArticleID: articleID,
})
}
func (r *imageReferenceRepository) DeleteByArticleScope(ctx context.Context, tenantID, articleID int64, refScope string) error {
return r.q.DeleteImageReferencesByArticleScope(ctx, generated.DeleteImageReferencesByArticleScopeParams{
TenantID: tenantID,
ArticleID: articleID,
RefScope: refScope,
})
}
func (r *imageReferenceRepository) ListArticles(ctx context.Context, tenantID, imageAssetID int64) ([]ImageReferenceArticle, error) {
rows, err := r.q.ListImageReferenceArticles(ctx, generated.ListImageReferenceArticlesParams{
TenantID: tenantID,
ImageAssetID: imageAssetID,
})
if err != nil {
return nil, err
}
articles := make([]ImageReferenceArticle, len(rows))
for i, row := range rows {
articles[i] = ImageReferenceArticle{
ArticleID: row.ArticleID,
RefScope: row.RefScope,
}
}
return articles, nil
}
```
- [ ] **Step 4: Create image usage repository**
Create `server/internal/tenant/repository/image_usage_repo.go`:
```go
package repository
import (
"context"
"fmt"
"time"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ImageStorageUsage struct {
UsedBytes int64
UpdatedAt time.Time
}
type QuotaReserveResult struct {
UsedBytes int64
QuotaBytes int64
}
type ImageUsageRepository interface {
EnsureRow(ctx context.Context, tenantID int64) error
Get(ctx context.Context, tenantID int64) (*ImageStorageUsage, error)
Release(ctx context.Context, tenantID, releaseBytes int64) error
AtomicReserve(ctx context.Context, db generated.DBTX, tenantID, sizeBytes int64) (*QuotaReserveResult, error)
}
type imageUsageRepository struct {
q generated.Querier
db generated.DBTX
}
func NewImageUsageRepository(db generated.DBTX) ImageUsageRepository {
return &imageUsageRepository{q: newQuerier(db), db: db}
}
func (r *imageUsageRepository) EnsureRow(ctx context.Context, tenantID int64) error {
return r.q.EnsureImageStorageUsageRow(ctx, tenantID)
}
func (r *imageUsageRepository) Get(ctx context.Context, tenantID int64) (*ImageStorageUsage, error) {
row, err := r.q.GetImageStorageUsage(ctx, tenantID)
if err != nil {
return nil, err
}
return &ImageStorageUsage{
UsedBytes: row.UsedBytes,
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
func (r *imageUsageRepository) Release(ctx context.Context, tenantID, releaseBytes int64) error {
return r.q.ReleaseImageStorageQuota(ctx, generated.ReleaseImageStorageQuotaParams{
ReleaseBytes: releaseBytes,
TenantID: tenantID,
})
}
func (r *imageUsageRepository) AtomicReserve(ctx context.Context, db generated.DBTX, tenantID, sizeBytes int64) (*QuotaReserveResult, error) {
row := db.QueryRow(ctx, `
WITH plan AS (
SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, 104857600) AS quota_bytes
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.status = 'active'
AND s.deleted_at IS NULL
LIMIT 1
)
UPDATE tenant_image_storage_usage u
SET used_bytes = u.used_bytes + $2,
updated_at = NOW()
FROM plan
WHERE u.tenant_id = $1
AND u.used_bytes + $2 <= plan.quota_bytes
RETURNING u.used_bytes, plan.quota_bytes
`, tenantID, sizeBytes)
var result QuotaReserveResult
err := row.Scan(&result.UsedBytes, &result.QuotaBytes)
if err != nil {
return nil, fmt.Errorf("reserve image storage quota: %w", err)
}
return &result, nil
}
```
- [ ] **Step 5: Verify compilation**
```bash
cd server && go build ./...
```
- [ ] **Step 6: Commit**
```bash
git add server/internal/tenant/repository/image_folder_repo.go server/internal/tenant/repository/image_repo.go server/internal/tenant/repository/image_reference_repo.go server/internal/tenant/repository/image_usage_repo.go
git commit -m "feat(images): add repository layer for image management"
```
---
## Task 4: RabbitMQ Image Asset Queue
**Files:**
- Modify: `server/internal/shared/config/config.go`
- Modify: `server/internal/shared/messaging/rabbitmq/client.go`
- [ ] **Step 1: Add config fields**
In `server/internal/shared/config/config.go`, add the following fields to `RabbitMQConfig` (after `TemplateAssistDLQRouteKey`):
```go
ImageAssetExchange string `mapstructure:"image_asset_exchange"`
ImageAssetRoutingKey string `mapstructure:"image_asset_routing_key"`
ImageAssetQueue string `mapstructure:"image_asset_queue"`
ImageAssetDLX string `mapstructure:"image_asset_dlx"`
ImageAssetDLQ string `mapstructure:"image_asset_dlq"`
ImageAssetDLQRouteKey string `mapstructure:"image_asset_dlq_routing_key"`
```
In `normalizeRabbitMQConfig`, add defaults (after the TemplateAssist block):
```go
if strings.TrimSpace(cfg.ImageAssetExchange) == "" {
cfg.ImageAssetExchange = "image.asset"
}
if strings.TrimSpace(cfg.ImageAssetRoutingKey) == "" {
cfg.ImageAssetRoutingKey = "image.asset.finalize"
}
if strings.TrimSpace(cfg.ImageAssetQueue) == "" {
cfg.ImageAssetQueue = "image.asset.finalize"
}
if strings.TrimSpace(cfg.ImageAssetDLX) == "" {
cfg.ImageAssetDLX = "image.asset.dlx"
}
if strings.TrimSpace(cfg.ImageAssetDLQ) == "" {
cfg.ImageAssetDLQ = "image.asset.finalize.dlq"
}
if strings.TrimSpace(cfg.ImageAssetDLQRouteKey) == "" {
cfg.ImageAssetDLQRouteKey = "image.asset.finalize.dlq"
}
```
- [ ] **Step 2: Add publish/consume methods and topology**
In `server/internal/shared/messaging/rabbitmq/client.go`, add:
```go
func (c *Client) PublishImageAssetTask(ctx context.Context, body []byte) error {
return c.publish(ctx, c.cfg.ImageAssetExchange, c.cfg.ImageAssetRoutingKey, body)
}
func (c *Client) ConsumeImageAssetTask(consumerName string) (<-chan amqp.Delivery, *amqp.Channel, error) {
return c.consume(consumerName, c.cfg.ImageAssetQueue)
}
```
In `ensureTopology`, add before `return nil`:
```go
if err := c.ensureWorkQueue(
ch,
c.cfg.ImageAssetExchange,
c.cfg.ImageAssetRoutingKey,
c.cfg.ImageAssetQueue,
c.cfg.ImageAssetDLX,
c.cfg.ImageAssetDLQ,
c.cfg.ImageAssetDLQRouteKey,
); err != nil {
return err
}
```
- [ ] **Step 3: Verify compilation**
```bash
cd server && go build ./...
```
- [ ] **Step 4: Commit**
```bash
git add server/internal/shared/config/config.go server/internal/shared/messaging/rabbitmq/client.go
git commit -m "feat(images): add RabbitMQ queue for image asset finalization"
```
---
## Task 5: Image Folder Service
**Files:**
- Create: `server/internal/tenant/app/image_folder_service.go`
- [ ] **Step 1: Create ImageFolderService**
Create `server/internal/tenant/app/image_folder_service.go`:
```go
package app
import (
"context"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type ImageFolderService struct {
pool *pgxpool.Pool
}
func NewImageFolderService(pool *pgxpool.Pool) *ImageFolderService {
return &ImageFolderService{pool: pool}
}
type ImageFolderItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
ImageCount int `json:"image_count"`
CreatedAt string `json:"created_at"`
}
func (s *ImageFolderService) List(ctx context.Context) ([]ImageFolderItem, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageFolderRepository(s.pool)
folders, err := repo.List(ctx, tenantID)
if err != nil {
return nil, err
}
items := make([]ImageFolderItem, len(folders))
for i, f := range folders {
items[i] = ImageFolderItem{
ID: f.ID,
Name: f.Name,
ImageCount: f.ImageCount,
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
return items, nil
}
func (s *ImageFolderService) Create(ctx context.Context, name string) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required")
}
if len(name) > 100 {
return 0, response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters")
}
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageFolderRepository(s.pool)
id, err := repo.Create(ctx, tenantID, name)
if err != nil {
if strings.Contains(err.Error(), "uq_image_folders_tenant_name") {
return 0, response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists")
}
return 0, err
}
return id, nil
}
func (s *ImageFolderService) Update(ctx context.Context, id int64, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required")
}
if len(name) > 100 {
return response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters")
}
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageFolderRepository(s.pool)
if err := repo.Update(ctx, tenantID, id, name); err != nil {
if strings.Contains(err.Error(), "uq_image_folders_tenant_name") {
return response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists")
}
return err
}
return nil
}
type FolderDeletePreview struct {
ImageCount int `json:"image_count"`
ReferencedList []ImageReferenceArticleBrief `json:"referenced_articles"`
}
type ImageReferenceArticleBrief struct {
ArticleID int64 `json:"article_id"`
Scopes []string `json:"scopes"`
}
func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64) (*FolderDeletePreview, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID)
if err != nil {
return nil, err
}
articleMap := make(map[int64]map[string]bool)
for _, img := range images {
articles, err := refRepo.ListArticles(ctx, tenantID, img.ID)
if err != nil {
return nil, err
}
for _, a := range articles {
if articleMap[a.ArticleID] == nil {
articleMap[a.ArticleID] = make(map[string]bool)
}
articleMap[a.ArticleID][a.RefScope] = true
}
}
referenced := make([]ImageReferenceArticleBrief, 0, len(articleMap))
for aid, scopes := range articleMap {
scopeList := make([]string, 0, len(scopes))
for s := range scopes {
scopeList = append(scopeList, s)
}
referenced = append(referenced, ImageReferenceArticleBrief{
ArticleID: aid,
Scopes: scopeList,
})
}
return &FolderDeletePreview{
ImageCount: len(images),
ReferencedList: referenced,
}, nil
}
func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force bool) error {
tenantID := middleware.TenantIDFromCtx(ctx)
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
folderRepo := repository.NewImageFolderRepository(s.pool)
images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID)
if err != nil {
return err
}
if !force && len(images) > 0 {
for _, img := range images {
count, err := refRepo.Count(ctx, tenantID, img.ID)
if err != nil {
return err
}
if count > 0 {
return response.ErrConflict(40902, "folder_has_referenced_images", "folder contains images that are referenced by articles")
}
}
}
if len(images) > 0 {
if err := imageRepo.MarkImagesInFolderPendingDelete(ctx, tenantID, folderID); err != nil {
return err
}
}
if err := folderRepo.Delete(ctx, tenantID, folderID); err != nil {
return err
}
return nil
}
```
- [ ] **Step 2: Verify compilation**
```bash
cd server && go build ./...
```
- [ ] **Step 3: Commit**
```bash
git add server/internal/tenant/app/image_folder_service.go
git commit -m "feat(images): add ImageFolderService with CRUD and delete preview"
```
---
## Task 6: Image Service
**Files:**
- Create: `server/internal/tenant/app/image_service.go`
This is the main service with upload pipeline (WebP conversion), list, update, delete, storage usage, and reference lookup.
- [ ] **Step 1: Create ImageService**
Create `server/internal/tenant/app/image_service.go` (large file — all upload/delete/list logic):
```go
package app
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"path/filepath"
"strings"
"github.com/chai2010/webp"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "golang.org/x/image/webp"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const (
maxImageUploadSize = 10 * 1024 * 1024 // 10 MB
webpQuality = 82
maxImageDimension = 16384
)
var allowedImageMIMEs = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
}
type ImageService struct {
pool *pgxpool.Pool
objectStorage objectstorage.Client
logger *zap.Logger
}
func NewImageService(pool *pgxpool.Pool, objectStorage objectstorage.Client, logger *zap.Logger) *ImageService {
return &ImageService{pool: pool, objectStorage: objectStorage, logger: logger}
}
type ImageUploadResult struct {
ID int64 `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
}
func (s *ImageService) Upload(ctx context.Context, fileName string, content []byte, folderID *int64, buildURL func(string) string) (*ImageUploadResult, error) {
if len(content) > maxImageUploadSize {
return nil, response.ErrBadRequest(40010, "image_too_large", "image must be at most 10 MB")
}
contentType := http.DetectContentType(content)
if !allowedImageMIMEs[contentType] {
return nil, response.ErrBadRequest(40011, "invalid_image_type", "only PNG, JPEG, GIF, and WebP images are supported")
}
imgConfig, _, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil {
return nil, response.ErrBadRequest(40012, "invalid_image", "unable to decode image")
}
if imgConfig.Width > maxImageDimension || imgConfig.Height > maxImageDimension {
return nil, response.ErrBadRequest(40013, "image_too_large_dimensions", "image dimensions exceed maximum allowed")
}
img, _, err := image.Decode(bytes.NewReader(content))
if err != nil {
return nil, response.ErrBadRequest(40012, "invalid_image", "unable to decode image")
}
var webpBuf bytes.Buffer
if err := webp.Encode(&webpBuf, img, &webp.Options{Quality: webpQuality}); err != nil {
return nil, fmt.Errorf("encode webp: %w", err)
}
webpBytes := webpBuf.Bytes()
sizeBytes := int64(len(webpBytes))
tenantID := middleware.TenantIDFromCtx(ctx)
actor := auth.MustActor(ctx)
usageRepo := repository.NewImageUsageRepository(s.pool)
if err := usageRepo.EnsureRow(ctx, tenantID); err != nil {
return nil, fmt.Errorf("ensure usage row: %w", err)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
reserveResult, err := usageRepo.AtomicReserve(ctx, tx, tenantID, sizeBytes)
if err != nil {
if strings.Contains(err.Error(), "no rows") || reserveResult == nil {
return nil, response.ErrConflict(40903, "image_storage_quota_exceeded", "image storage quota exceeded")
}
return nil, fmt.Errorf("reserve quota: %w", err)
}
displayName := deriveImageName(fileName)
objectKey := fmt.Sprintf("tenants/%d/images/%s.webp", tenantID, uuid.New().String())
imageRepo := repository.NewImageRepository(tx)
assetID, err := imageRepo.Insert(ctx, repository.InsertImageAssetInput{
TenantID: tenantID,
FolderID: folderID,
ObjectKey: objectKey,
Name: displayName,
SizeBytes: sizeBytes,
Status: "pending_upload",
CreatedBy: actor.UserID,
})
if err != nil {
return nil, fmt.Errorf("insert image asset: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit tx: %w", err)
}
if err := s.objectStorage.PutBytes(ctx, objectKey, webpBytes, "image/webp"); err != nil {
s.logger.Error("image upload to object storage failed, rolling back",
zap.Int64("asset_id", assetID),
zap.Error(err),
)
s.rollbackUpload(ctx, tenantID, assetID, sizeBytes)
return nil, response.ErrInternal(50010, "image_upload_failed", "failed to upload image to storage")
}
imgRepo := repository.NewImageRepository(s.pool)
if err := imgRepo.UpdateStatus(ctx, tenantID, assetID, "active"); err != nil {
s.logger.Error("failed to mark image active after upload",
zap.Int64("asset_id", assetID),
zap.Error(err),
)
}
url := buildURL(objectKey)
return &ImageUploadResult{
ID: assetID,
URL: url,
Name: displayName,
SizeBytes: sizeBytes,
}, nil
}
func (s *ImageService) rollbackUpload(ctx context.Context, tenantID, assetID, sizeBytes int64) {
imgRepo := repository.NewImageRepository(s.pool)
_ = imgRepo.UpdateStatus(ctx, tenantID, assetID, "upload_failed")
usageRepo := repository.NewImageUsageRepository(s.pool)
_ = usageRepo.Release(ctx, tenantID, sizeBytes)
}
type ImageListResult struct {
Items []ImageListItem `json:"items"`
Total int `json:"total"`
}
type ImageListItem struct {
ID int64 `json:"id"`
FolderID *int64 `json:"folder_id"`
Name string `json:"name"`
URL string `json:"url"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
func (s *ImageService) List(ctx context.Context, folderID *int64, query *string, page, pageSize int, buildURL func(string) string) (*ImageListResult, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
if query != nil && strings.TrimSpace(*query) != "" {
folderID = nil
}
repo := repository.NewImageRepository(s.pool)
assets, total, err := repo.List(ctx, repository.ImageAssetListParams{
TenantID: tenantID,
FolderID: folderID,
SearchQuery: query,
PageSize: pageSize,
PageOffset: (page - 1) * pageSize,
})
if err != nil {
return nil, err
}
items := make([]ImageListItem, len(assets))
for i, a := range assets {
items[i] = ImageListItem{
ID: a.ID,
FolderID: a.FolderID,
Name: a.Name,
URL: buildURL(a.ObjectKey),
SizeBytes: a.SizeBytes,
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
return &ImageListResult{Items: items, Total: total}, nil
}
type ImageUpdateRequest struct {
Name *string `json:"name"`
FolderID *int64 `json:"folder_id"`
}
func (s *ImageService) Update(ctx context.Context, id int64, req ImageUpdateRequest) error {
tenantID := middleware.TenantIDFromCtx(ctx)
repo := repository.NewImageRepository(s.pool)
asset, err := repo.Get(ctx, tenantID, id)
if err != nil {
if err == pgx.ErrNoRows {
return response.ErrNotFound(40401, "image_not_found", "image not found")
}
return err
}
if asset.Status != "active" {
return response.ErrNotFound(40401, "image_not_found", "image not found")
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return response.ErrBadRequest(40001, "invalid_name", "name is required")
}
if len(name) > 255 {
return response.ErrBadRequest(40002, "invalid_name", "name must be at most 255 characters")
}
if err := repo.UpdateName(ctx, tenantID, id, name); err != nil {
return err
}
}
if req.FolderID != nil {
if err := repo.UpdateFolder(ctx, tenantID, id, req.FolderID); err != nil {
return err
}
}
return nil
}
type ImageReferenceDetail struct {
Total int `json:"total"`
ArticleBody int `json:"article_body"`
ArticleCover int `json:"article_cover"`
Articles []ImageReferenceArticleBrief `json:"articles"`
}
func (s *ImageService) GetReferences(ctx context.Context, imageID int64) (*ImageReferenceDetail, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
refRepo := repository.NewImageReferenceRepository(s.pool)
articles, err := refRepo.ListArticles(ctx, tenantID, imageID)
if err != nil {
return nil, err
}
articleMap := make(map[int64][]string)
bodyCount, coverCount := 0, 0
for _, a := range articles {
articleMap[a.ArticleID] = append(articleMap[a.ArticleID], a.RefScope)
switch a.RefScope {
case "article_body":
bodyCount++
case "article_cover":
coverCount++
}
}
briefs := make([]ImageReferenceArticleBrief, 0, len(articleMap))
for aid, scopes := range articleMap {
briefs = append(briefs, ImageReferenceArticleBrief{
ArticleID: aid,
Scopes: scopes,
})
}
return &ImageReferenceDetail{
Total: len(articles),
ArticleBody: bodyCount,
ArticleCover: coverCount,
Articles: briefs,
}, nil
}
func (s *ImageService) Delete(ctx context.Context, id int64, force bool) error {
tenantID := middleware.TenantIDFromCtx(ctx)
imageRepo := repository.NewImageRepository(s.pool)
refRepo := repository.NewImageReferenceRepository(s.pool)
usageRepo := repository.NewImageUsageRepository(s.pool)
asset, err := imageRepo.Get(ctx, tenantID, id)
if err != nil {
if err == pgx.ErrNoRows {
return response.ErrNotFound(40401, "image_not_found", "image not found")
}
return err
}
if asset.Status != "active" {
return response.ErrNotFound(40401, "image_not_found", "image not found")
}
if !force {
count, err := refRepo.Count(ctx, tenantID, id)
if err != nil {
return err
}
if count > 0 {
return response.ErrConflict(40904, "image_asset_in_use", "image is referenced by articles")
}
}
if err := imageRepo.UpdateStatus(ctx, tenantID, id, "pending_delete"); err != nil {
return err
}
if err := s.objectStorage.Delete(ctx, asset.ObjectKey); err != nil {
s.logger.Error("failed to delete image from object storage",
zap.Int64("asset_id", id),
zap.String("object_key", asset.ObjectKey),
zap.Error(err),
)
}
if err := imageRepo.MarkDeleted(ctx, tenantID, id); err != nil {
return err
}
if err := usageRepo.Release(ctx, tenantID, asset.SizeBytes); err != nil {
s.logger.Error("failed to release image storage quota",
zap.Int64("asset_id", id),
zap.Error(err),
)
}
return nil
}
type StorageUsageResponse struct {
UsedBytes int64 `json:"used_bytes"`
QuotaBytes int64 `json:"quota_bytes"`
UsedPct float64 `json:"used_pct"`
}
func (s *ImageService) GetStorageUsage(ctx context.Context) (*StorageUsageResponse, error) {
tenantID := middleware.TenantIDFromCtx(ctx)
usageRepo := repository.NewImageUsageRepository(s.pool)
if err := usageRepo.EnsureRow(ctx, tenantID); err != nil {
return nil, err
}
usage, err := usageRepo.Get(ctx, tenantID)
if err != nil {
return nil, err
}
var quotaBytes int64
row := s.pool.QueryRow(ctx, `
SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, 104857600) AS quota_bytes
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.status = 'active'
AND s.deleted_at IS NULL
LIMIT 1
`, tenantID)
if err := row.Scan(&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**
```bash
cd server && go get github.com/chai2010/webp && go get golang.org/x/image/webp
```
- [ ] **Step 3: Verify compilation**
```bash
cd server && go build ./...
```
- [ ] **Step 4: Commit**
```bash
git add server/internal/tenant/app/image_service.go server/go.mod server/go.sum
git commit -m "feat(images): add ImageService with WebP upload pipeline, delete, and quota management"
```
---
## Task 7: HTTP Handler
**Files:**
- Create: `server/internal/tenant/transport/image_handler.go`
- [ ] **Step 1: Create ImageHandler**
Create `server/internal/tenant/transport/image_handler.go`:
```go
package transport
import (
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
type ImageHandler struct {
imageSvc *app.ImageService
folderSvc *app.ImageFolderService
assets *AssetHandler
}
func NewImageHandler(a *bootstrap.App) *ImageHandler {
return &ImageHandler{
imageSvc: app.NewImageService(a.DB, a.ObjectStorage, a.Logger),
folderSvc: app.NewImageFolderService(a.DB),
assets: NewAssetHandler(a),
}
}
// --- Folders ---
func (h *ImageHandler) ListFolders(c *gin.Context) {
folders, err := h.folderSvc.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, folders)
}
func (h *ImageHandler) CreateFolder(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body"))
return
}
id, err := h.folderSvc.Create(c.Request.Context(), body.Name)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, http.StatusCreated, gin.H{"id": id})
}
func (h *ImageHandler) UpdateFolder(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id"))
return
}
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body"))
return
}
if err := h.folderSvc.Update(c.Request.Context(), id, body.Name); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *ImageHandler) FolderDeletePreview(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id"))
return
}
preview, err := h.folderSvc.DeletePreview(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, preview)
}
func (h *ImageHandler) DeleteFolder(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id"))
return
}
force := c.Query("force") == "1"
if err := h.folderSvc.Delete(c.Request.Context(), id, force); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
// --- Images ---
func (h *ImageHandler) ListImages(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var folderID *int64
if fid := c.Query("folder_id"); fid != "" {
if fid == "null" || fid == "uncategorized" {
zero := int64(0)
folderID = &zero
} else {
v, err := strconv.ParseInt(fid, 10, 64)
if err == nil {
folderID = &v
}
}
}
var query *string
if q := c.Query("q"); q != "" {
query = &q
}
result, err := h.imageSvc.List(c.Request.Context(), folderID, query, page, pageSize, h.assets.BuildArticleImageURL)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
func (h *ImageHandler) UploadImage(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "missing_file", "file is required"))
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "read_file_failed", "failed to read file"))
return
}
var folderID *int64
if fid := c.PostForm("folder_id"); fid != "" {
v, err := strconv.ParseInt(fid, 10, 64)
if err == nil {
folderID = &v
}
}
result, err := h.imageSvc.Upload(c.Request.Context(), header.Filename, content, folderID, h.assets.BuildArticleImageURL)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, http.StatusCreated, result)
}
func (h *ImageHandler) UpdateImage(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id"))
return
}
var body app.ImageUpdateRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body"))
return
}
if err := h.imageSvc.Update(c.Request.Context(), id, body); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *ImageHandler) GetImageReferences(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id"))
return
}
refs, err := h.imageSvc.GetReferences(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, refs)
}
func (h *ImageHandler) DeleteImage(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id"))
return
}
force := c.Query("force") == "1"
if err := h.imageSvc.Delete(c.Request.Context(), id, force); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *ImageHandler) StorageUsage(c *gin.Context) {
usage, err := h.imageSvc.GetStorageUsage(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, usage)
}
```
- [ ] **Step 2: Verify compilation**
```bash
cd server && go build ./...
```
- [ ] **Step 3: Commit**
```bash
git add server/internal/tenant/transport/image_handler.go
git commit -m "feat(images): add HTTP handler for image management endpoints"
```
---
## Task 8: Route Registration
**Files:**
- Modify: `server/internal/tenant/transport/router.go`
- [ ] **Step 1: Add image routes**
In `server/internal/tenant/transport/router.go`, add the following block after the `instantTasks` routes (before the closing `}`):
```go
// Image Management
images := protected.Group("/tenant/images")
imgHandler := NewImageHandler(app)
images.GET("", imgHandler.ListImages)
images.POST("", imgHandler.UploadImage)
images.PUT("/:id", imgHandler.UpdateImage)
images.GET("/:id/references", imgHandler.GetImageReferences)
images.DELETE("/:id", imgHandler.DeleteImage)
images.GET("/storage-usage", imgHandler.StorageUsage)
imageFolders := protected.Group("/tenant/images/folders")
imageFolders.GET("", imgHandler.ListFolders)
imageFolders.POST("", imgHandler.CreateFolder)
imageFolders.PUT("/:id", imgHandler.UpdateFolder)
imageFolders.GET("/:id/delete-preview", imgHandler.FolderDeletePreview)
imageFolders.DELETE("/:id", imgHandler.DeleteFolder)
```
- [ ] **Step 2: Verify compilation**
```bash
cd server && go build ./...
```
- [ ] **Step 3: Commit**
```bash
git add server/internal/tenant/transport/router.go
git commit -m "feat(images): register image management API routes"
```
---
## Task 9: Shared TypeScript Types
**Files:**
- Modify: `packages/shared-types/src/index.ts`
- [ ] **Step 1: Add image management types**
Append to `packages/shared-types/src/index.ts`:
```typescript
// --- Image Management ---
export interface ImageFolder {
id: number;
name: string;
image_count: number;
created_at: string;
}
export interface ImageAssetItem {
id: number;
folder_id: number | null;
name: string;
url: string;
size_bytes: number;
created_at: string;
}
export interface ImageAssetListResponse {
items: ImageAssetItem[];
total: number;
}
export interface ImageUploadResponse {
id: number;
url: string;
name: string;
size_bytes: number;
}
export interface ImageReferenceArticleBrief {
article_id: number;
scopes: string[];
}
export interface ImageReferenceDetail {
total: number;
article_body: number;
article_cover: number;
articles: ImageReferenceArticleBrief[];
}
export interface FolderDeletePreview {
image_count: number;
referenced_articles: ImageReferenceArticleBrief[];
}
export interface ImageStorageUsage {
used_bytes: number;
quota_bytes: number;
used_pct: number;
}
export interface ImageUpdateRequest {
name?: string;
folder_id?: number | null;
}
```
- [ ] **Step 2: Commit**
```bash
git add packages/shared-types/src/index.ts
git commit -m "feat(images): add shared TypeScript types for image management"
```
---
## Task 10: Frontend API Client
**Files:**
- Modify: `apps/admin-web/src/lib/api.ts`
- [ ] **Step 1: Add imagesApi**
In `apps/admin-web/src/lib/api.ts`, add the import of new types at the top (inside the existing `import type { ... }` block from `@geo/shared-types`):
Add: `ImageFolder`, `ImageAssetListResponse`, `ImageUploadResponse`, `ImageReferenceDetail`, `FolderDeletePreview`, `ImageStorageUsage`, `ImageUpdateRequest`
Then add the `imagesApi` object after `instantTasksApi`:
```typescript
export const imagesApi = {
listFolders() {
return apiClient.get<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**
```bash
git add apps/admin-web/src/lib/api.ts
git commit -m "feat(images): add frontend API client for image management"
```
---
## Task 11: i18n Messages
**Files:**
- Modify: `apps/admin-web/src/i18n/messages/zh-CN.ts`
- Modify: `apps/admin-web/src/i18n/messages/en-US.ts`
- [ ] **Step 1: Add Chinese translations**
In `apps/admin-web/src/i18n/messages/zh-CN.ts`, add `images` key inside the `nav` object:
```typescript
images: "图片管理",
```
Add the route entry inside `route`:
```typescript
images: {
title: "图片管理",
description: "管理图片素材库,支持文件夹分类和搜索。",
},
```
Add top-level `images` section (at the same level as `nav`, `auth`, etc.):
```typescript
images: {
uploadBtn: "上传图片",
folders: "文件夹",
uncategorized: "未分类",
searchPlaceholder: "搜索图片名...",
newFolder: "新建文件夹",
folderName: "文件夹名称",
rename: "改名",
move: "移动",
deleteImage: "删除",
deleteFolder: "删除文件夹",
forceDelete: "仍然删除",
confirmDeleteTitle: "确认删除",
confirmDeleteImage: "确定要删除这张图片吗?",
confirmDeleteFolder: "确定要删除此文件夹吗?",
imageInUse: "该图片被以下文章引用:",
folderHasRefs: "文件夹中包含被引用的图片",
refArticleBody: "正文引用",
refArticleCover: "封面引用",
storageUsage: "存储用量",
storageUsageDetail: "{used} / {quota}",
quotaExceeded: "存储空间不足",
uploadSuccess: "上传成功",
uploadFailed: "上传失败",
deleteSuccess: "删除成功",
folderCreated: "文件夹已创建",
folderRenamed: "文件夹已重命名",
folderDeleted: "文件夹已删除",
imageRenamed: "图片已重命名",
imageMoved: "图片已移动",
noImages: "暂无图片",
allFolders: "全部",
selectImage: "选择图片",
libraryTab: "本地素材",
},
```
- [ ] **Step 2: Add English translations**
Add equivalent keys to `apps/admin-web/src/i18n/messages/en-US.ts`:
Nav: `images: "Images"`, route: `images: { title: "Images", description: "Manage image assets with folder organization and search." }`
Top-level `images` section:
```typescript
images: {
uploadBtn: "Upload Image",
folders: "Folders",
uncategorized: "Uncategorized",
searchPlaceholder: "Search images...",
newFolder: "New Folder",
folderName: "Folder Name",
rename: "Rename",
move: "Move",
deleteImage: "Delete",
deleteFolder: "Delete Folder",
forceDelete: "Delete Anyway",
confirmDeleteTitle: "Confirm Delete",
confirmDeleteImage: "Are you sure you want to delete this image?",
confirmDeleteFolder: "Are you sure you want to delete this folder?",
imageInUse: "This image is referenced by the following articles:",
folderHasRefs: "Folder contains referenced images",
refArticleBody: "Body reference",
refArticleCover: "Cover reference",
storageUsage: "Storage Usage",
storageUsageDetail: "{used} / {quota}",
quotaExceeded: "Storage quota exceeded",
uploadSuccess: "Upload successful",
uploadFailed: "Upload failed",
deleteSuccess: "Deleted successfully",
folderCreated: "Folder created",
folderRenamed: "Folder renamed",
folderDeleted: "Folder deleted",
imageRenamed: "Image renamed",
imageMoved: "Image moved",
noImages: "No images",
allFolders: "All",
selectImage: "Select Image",
libraryTab: "Image Library",
},
```
- [ ] **Step 3: Commit**
```bash
git add apps/admin-web/src/i18n/messages/zh-CN.ts apps/admin-web/src/i18n/messages/en-US.ts
git commit -m "feat(images): add i18n translations for image management"
```
---
## Task 12: Router + Navigation
**Files:**
- Modify: `apps/admin-web/src/router/index.ts`
- Modify: `apps/admin-web/src/layouts/AppShell.vue`
- [ ] **Step 1: Add route**
In `apps/admin-web/src/router/index.ts`, add a new route inside the children array (after the `knowledge` route):
```typescript
{
path: "images",
name: "images",
component: () => import("@/views/ImagesView.vue"),
meta: {
titleKey: "route.images.title",
descriptionKey: "route.images.description",
navKey: "/images",
},
},
```
- [ ] **Step 2: Add nav item**
In `apps/admin-web/src/layouts/AppShell.vue`, add the `PictureOutlined` icon import at the top:
```typescript
import {
AppstoreOutlined,
BookOutlined,
GlobalOutlined,
PictureOutlined,
RadarChartOutlined,
SearchOutlined,
DownOutlined,
LogoutOutlined,
TranslationOutlined,
} from "@ant-design/icons-vue";
```
In the `contentManagement` nav section, add the images item after knowledge:
```typescript
{
key: "contentManagement",
title: t("nav.contentManagement"),
items: [
{ key: "/knowledge", label: t("nav.knowledge"), icon: BookOutlined },
{ key: "/images", label: t("nav.images"), icon: PictureOutlined },
],
},
```
- [ ] **Step 3: Commit**
```bash
git add apps/admin-web/src/router/index.ts apps/admin-web/src/layouts/AppShell.vue
git commit -m "feat(images): add /images route and navigation item"
```
---
## Task 13: ImagesView Page
**Files:**
- Create: `apps/admin-web/src/views/ImagesView.vue`
- [ ] **Step 1: Create ImagesView**
Create `apps/admin-web/src/views/ImagesView.vue` — a full page with:
- Left sidebar: folder list with "未分类" + user folders + create button
- Right panel: search bar + image grid (4 columns) + pagination
- Hover actions on image cards: rename, move, delete
- Delete confirmation with reference warning
- Storage usage bar
The component uses TanStack Query for data fetching and Ant Design Vue components. Due to plan length, the implementation should follow the spec layout exactly:
```
┌─────────────────────────────────────────────────────┐
│ 图片管理 [+ 上传图片] │
├──────────────┬──────────────────────────────────────┤
│ 文件夹 [+] │ 🔍 搜索图片名... │
│──────────────│──────────────────────────────────────│
│ 📁 未分类(12)│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ 📁 产品图(8) │ │img │ │img │ │img │ │img │ │
│ 📁 博客配图 │ └────┘ └────┘ └────┘ └────┘ │
│ │ │
│ │ 文件名 · 大小 · 日期 [改名] [移动] │
└──────────────┴──────────────────────────────────────┘
```
Key implementation details:
- Use `useQuery` with queryKey `["images", "list", { folder_id, q, page }]` and `["images", "folders"]`
- Use `useMutation` for upload, delete, rename, move, folder CRUD
- Debounced search input (300ms)
- When search query is active, disable folder sidebar selection
- `<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**
```bash
cd apps/admin-web && npm run dev
```
Navigate to `http://localhost:5173/images` and verify:
- Folder sidebar renders
- Image grid renders (empty state if no images)
- Upload works (file is converted to WebP on server)
- Folder CRUD works
- Search works with debounce
- Delete with reference warning works
- [ ] **Step 3: Commit**
```bash
git add apps/admin-web/src/views/ImagesView.vue
git commit -m "feat(images): add ImagesView page with folder sidebar and image grid"
```
---
## Task 14: Image Library Picker Component
**Files:**
- Create: `apps/admin-web/src/components/ImageLibraryPicker.vue`
- [ ] **Step 1: Create ImageLibraryPicker**
Create `apps/admin-web/src/components/ImageLibraryPicker.vue` — a reusable picker component that can be embedded in modals:
Props:
- `singleSelect: boolean` (default true) — for cover picker, only one image can be selected
- `onSelect: (image: ImageAssetItem) => void` — callback when image is selected
Features:
- Folder dropdown filter (uses `imagesApi.listFolders`)
- Search input with 300ms debounce
- Image grid (4 columns, scrollable)
- Click-to-select (highlight border on selected)
- Pagination
- Storage usage bar at bottom
This component is used by:
1. CoverPickerModal "本地素材" tab
2. (Future) Article editor "从图片库选择" flow
The component loads images via `imagesApi.list()` and renders them in a grid. Selected image has a blue border highlight. Storage usage bar at the bottom shows `imagesApi.storageUsage()`.
- [ ] **Step 2: Commit**
```bash
git add apps/admin-web/src/components/ImageLibraryPicker.vue
git commit -m "feat(images): add reusable ImageLibraryPicker component"
```
---
## Task 15: Cover Picker Modal Integration
**Files:**
- Modify: `apps/admin-web/src/components/CoverPickerModal.vue`
- [ ] **Step 1: Replace AI tab with library picker tab**
In `apps/admin-web/src/components/CoverPickerModal.vue`:
1. Import the `ImageLibraryPicker` component:
```typescript
import ImageLibraryPicker from "@/components/ImageLibraryPicker.vue";
```
2. Add a ref for the selected library image:
```typescript
const selectedLibraryImage = ref<{ url: string; assetId: number } | null>(null);
```
3. Replace the `<a-tab-pane key="ai">` block with:
```html
<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>
```
4. Add handlers:
```typescript
function handleLibrarySelect(image: { id: number; url: string; name: string }) {
selectedLibraryImage.value = { url: image.url, assetId: image.id };
}
function confirmLibraryImage() {
if (!selectedLibraryImage.value) return;
if (aspectRatioLocked.value) {
// Baijiahao: route into crop workspace
workingUrl.value = selectedLibraryImage.value.url;
workingFileName.value = "library-cover";
sourceKind.value = "remote";
activeTab.value = "local";
void loadImageMeta(selectedLibraryImage.value.url);
return;
}
// Non-crop platforms: use library image directly
emit("confirmed", {
url: selectedLibraryImage.value.url,
fileName: "library-cover",
});
emit("update:open", false);
}
```
5. Update the i18n key for the tab label — the `t('coverPicker.tabs.ai')` is now replaced with `t('images.libraryTab')`.
- [ ] **Step 2: Test in browser**
Start the dev server and navigate to an article editor. Open the cover picker modal. Verify:
- "AI封面" tab is replaced with "本地素材" tab
- Library tab shows image grid from the library
- Selecting an image and clicking confirm works
- For Baijiahao platforms, selecting routes to the crop workspace
- For other platforms, the library image URL is used directly
- [ ] **Step 3: Commit**
```bash
git add apps/admin-web/src/components/CoverPickerModal.vue
git commit -m "feat(images): replace AI cover tab with image library picker in CoverPickerModal"
```
---
## Task 16: Article Update Contract (Image References)
**Files:**
- Modify: `packages/shared-types/src/index.ts` (UpdateArticleRequest)
- Modify: `server/internal/tenant/app/article_service.go` (Update method)
- Modify: `server/internal/tenant/transport/article_handler.go` (Update handler)
- [ ] **Step 1: Extend UpdateArticleRequest type**
In `packages/shared-types/src/index.ts`, modify `UpdateArticleRequest`:
```typescript
export interface UpdateArticleRequest {
title: string;
markdown_content: string;
platforms?: string[];
cover_asset_url?: string | null;
referenced_image_asset_ids?: number[];
cover_image_asset_id?: number | null;
}
```
- [ ] **Step 2: Handle image references in article update (backend)**
In `server/internal/tenant/app/article_service.go`, in the `Update` method (or the handler that processes article updates), after the existing article update logic, add image reference upsert:
```go
// After the existing article update logic:
if len(input.ReferencedImageAssetIDs) > 0 || input.CoverImageAssetID != nil {
refRepo := repository.NewImageReferenceRepository(s.pool)
// Clear existing body references for this article, then re-insert
_ = refRepo.DeleteByArticleScope(ctx, tenantID, articleID, "article_body")
for _, assetID := range input.ReferencedImageAssetIDs {
_ = refRepo.Upsert(ctx, tenantID, assetID, articleID, "article_body")
}
// Handle cover reference
_ = refRepo.DeleteByArticleScope(ctx, tenantID, articleID, "article_cover")
if input.CoverImageAssetID != nil && *input.CoverImageAssetID > 0 {
_ = refRepo.Upsert(ctx, tenantID, *input.CoverImageAssetID, articleID, "article_cover")
}
}
```
The exact integration point depends on the existing `Update` method signature. The worker should read the current `Update` method and add the reference handling at the end, accepting `referenced_image_asset_ids` and `cover_image_asset_id` from the JSON request body.
- [ ] **Step 3: Commit**
```bash
git add packages/shared-types/src/index.ts server/internal/tenant/app/article_service.go server/internal/tenant/transport/article_handler.go
git commit -m "feat(images): add image reference tracking on article save"
```
---
## Deferred Items (Not in This Plan)
The following items from the spec are deferred to follow-up work:
1. **Article editor toolbar integration** — Adding "从图片库选择" dropdown to the Milkdown editor toolbar requires deeper integration with the editor plugin system. The ImageLibraryPicker component is ready to be embedded; the editor toolbar wiring can be done in a follow-up.
2. **RabbitMQ consumer for finalize/compensation** — The spec describes async compensation consumers for `finalize_upload` and `finalize_delete`. The current implementation handles finalization synchronously in the request thread (mark active after successful PutBytes, mark deleted after successful Delete). The async compensation path via MQ is infrastructure for edge-case recovery and can be added in a follow-up without changing the API contract.
3. **Folder delete with image finalization** — Currently folder delete marks images as `pending_delete` but does not trigger the OSS deletion of each image. A follow-up should add a background job that processes `pending_delete` images.