27389164b0
- 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.
2542 lines
76 KiB
Markdown
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("aBytes); err != nil {
|
|
quotaBytes = 104857600
|
|
}
|
|
|
|
usedPct := float64(0)
|
|
if quotaBytes > 0 {
|
|
usedPct = float64(usage.UsedBytes) / float64(quotaBytes) * 100
|
|
}
|
|
|
|
return &StorageUsageResponse{
|
|
UsedBytes: usage.UsedBytes,
|
|
QuotaBytes: quotaBytes,
|
|
UsedPct: usedPct,
|
|
}, nil
|
|
}
|
|
|
|
func deriveImageName(fileName string) string {
|
|
name := strings.TrimSpace(fileName)
|
|
if name == "" {
|
|
return "untitled"
|
|
}
|
|
ext := filepath.Ext(name)
|
|
if ext != "" {
|
|
name = strings.TrimSuffix(name, ext)
|
|
}
|
|
if len(name) > 200 {
|
|
name = name[:200]
|
|
}
|
|
return name
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Install Go dependency**
|
|
|
|
```bash
|
|
cd server && go get github.com/chai2010/webp && go get golang.org/x/image/webp
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compilation**
|
|
|
|
```bash
|
|
cd server && go build ./...
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add server/internal/tenant/app/image_service.go server/go.mod server/go.sum
|
|
git commit -m "feat(images): add ImageService with WebP upload pipeline, delete, and quota management"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: HTTP Handler
|
|
|
|
**Files:**
|
|
- Create: `server/internal/tenant/transport/image_handler.go`
|
|
|
|
- [ ] **Step 1: Create ImageHandler**
|
|
|
|
Create `server/internal/tenant/transport/image_handler.go`:
|
|
|
|
```go
|
|
package transport
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
|
)
|
|
|
|
type ImageHandler struct {
|
|
imageSvc *app.ImageService
|
|
folderSvc *app.ImageFolderService
|
|
assets *AssetHandler
|
|
}
|
|
|
|
func NewImageHandler(a *bootstrap.App) *ImageHandler {
|
|
return &ImageHandler{
|
|
imageSvc: app.NewImageService(a.DB, a.ObjectStorage, a.Logger),
|
|
folderSvc: app.NewImageFolderService(a.DB),
|
|
assets: NewAssetHandler(a),
|
|
}
|
|
}
|
|
|
|
// --- Folders ---
|
|
|
|
func (h *ImageHandler) ListFolders(c *gin.Context) {
|
|
folders, err := h.folderSvc.List(c.Request.Context())
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, folders)
|
|
}
|
|
|
|
func (h *ImageHandler) CreateFolder(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body"))
|
|
return
|
|
}
|
|
id, err := h.folderSvc.Create(c.Request.Context(), body.Name)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.SuccessWithStatus(c, http.StatusCreated, gin.H{"id": id})
|
|
}
|
|
|
|
func (h *ImageHandler) UpdateFolder(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id"))
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body"))
|
|
return
|
|
}
|
|
if err := h.folderSvc.Update(c.Request.Context(), id, body.Name); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
func (h *ImageHandler) FolderDeletePreview(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id"))
|
|
return
|
|
}
|
|
preview, err := h.folderSvc.DeletePreview(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, preview)
|
|
}
|
|
|
|
func (h *ImageHandler) DeleteFolder(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid folder id"))
|
|
return
|
|
}
|
|
force := c.Query("force") == "1"
|
|
if err := h.folderSvc.Delete(c.Request.Context(), id, force); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
// --- Images ---
|
|
|
|
func (h *ImageHandler) ListImages(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
var folderID *int64
|
|
if fid := c.Query("folder_id"); fid != "" {
|
|
if fid == "null" || fid == "uncategorized" {
|
|
zero := int64(0)
|
|
folderID = &zero
|
|
} else {
|
|
v, err := strconv.ParseInt(fid, 10, 64)
|
|
if err == nil {
|
|
folderID = &v
|
|
}
|
|
}
|
|
}
|
|
|
|
var query *string
|
|
if q := c.Query("q"); q != "" {
|
|
query = &q
|
|
}
|
|
|
|
result, err := h.imageSvc.List(c.Request.Context(), folderID, query, page, pageSize, h.assets.BuildArticleImageURL)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, result)
|
|
}
|
|
|
|
func (h *ImageHandler) UploadImage(c *gin.Context) {
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "missing_file", "file is required"))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "read_file_failed", "failed to read file"))
|
|
return
|
|
}
|
|
|
|
var folderID *int64
|
|
if fid := c.PostForm("folder_id"); fid != "" {
|
|
v, err := strconv.ParseInt(fid, 10, 64)
|
|
if err == nil {
|
|
folderID = &v
|
|
}
|
|
}
|
|
|
|
result, err := h.imageSvc.Upload(c.Request.Context(), header.Filename, content, folderID, h.assets.BuildArticleImageURL)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.SuccessWithStatus(c, http.StatusCreated, result)
|
|
}
|
|
|
|
func (h *ImageHandler) UpdateImage(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id"))
|
|
return
|
|
}
|
|
|
|
var body app.ImageUpdateRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_request", "invalid request body"))
|
|
return
|
|
}
|
|
|
|
if err := h.imageSvc.Update(c.Request.Context(), id, body); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
func (h *ImageHandler) GetImageReferences(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id"))
|
|
return
|
|
}
|
|
|
|
refs, err := h.imageSvc.GetReferences(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, refs)
|
|
}
|
|
|
|
func (h *ImageHandler) DeleteImage(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40000, "invalid_id", "invalid image id"))
|
|
return
|
|
}
|
|
force := c.Query("force") == "1"
|
|
if err := h.imageSvc.Delete(c.Request.Context(), id, force); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
func (h *ImageHandler) StorageUsage(c *gin.Context) {
|
|
usage, err := h.imageSvc.GetStorageUsage(c.Request.Context())
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, usage)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify compilation**
|
|
|
|
```bash
|
|
cd server && go build ./...
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/internal/tenant/transport/image_handler.go
|
|
git commit -m "feat(images): add HTTP handler for image management endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Route Registration
|
|
|
|
**Files:**
|
|
- Modify: `server/internal/tenant/transport/router.go`
|
|
|
|
- [ ] **Step 1: Add image routes**
|
|
|
|
In `server/internal/tenant/transport/router.go`, add the following block after the `instantTasks` routes (before the closing `}`):
|
|
|
|
```go
|
|
// Image Management
|
|
images := protected.Group("/tenant/images")
|
|
imgHandler := NewImageHandler(app)
|
|
images.GET("", imgHandler.ListImages)
|
|
images.POST("", imgHandler.UploadImage)
|
|
images.PUT("/:id", imgHandler.UpdateImage)
|
|
images.GET("/:id/references", imgHandler.GetImageReferences)
|
|
images.DELETE("/:id", imgHandler.DeleteImage)
|
|
images.GET("/storage-usage", imgHandler.StorageUsage)
|
|
|
|
imageFolders := protected.Group("/tenant/images/folders")
|
|
imageFolders.GET("", imgHandler.ListFolders)
|
|
imageFolders.POST("", imgHandler.CreateFolder)
|
|
imageFolders.PUT("/:id", imgHandler.UpdateFolder)
|
|
imageFolders.GET("/:id/delete-preview", imgHandler.FolderDeletePreview)
|
|
imageFolders.DELETE("/:id", imgHandler.DeleteFolder)
|
|
```
|
|
|
|
- [ ] **Step 2: Verify compilation**
|
|
|
|
```bash
|
|
cd server && go build ./...
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/internal/tenant/transport/router.go
|
|
git commit -m "feat(images): register image management API routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Shared TypeScript Types
|
|
|
|
**Files:**
|
|
- Modify: `packages/shared-types/src/index.ts`
|
|
|
|
- [ ] **Step 1: Add image management types**
|
|
|
|
Append to `packages/shared-types/src/index.ts`:
|
|
|
|
```typescript
|
|
// --- Image Management ---
|
|
|
|
export interface ImageFolder {
|
|
id: number;
|
|
name: string;
|
|
image_count: number;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ImageAssetItem {
|
|
id: number;
|
|
folder_id: number | null;
|
|
name: string;
|
|
url: string;
|
|
size_bytes: number;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ImageAssetListResponse {
|
|
items: ImageAssetItem[];
|
|
total: number;
|
|
}
|
|
|
|
export interface ImageUploadResponse {
|
|
id: number;
|
|
url: string;
|
|
name: string;
|
|
size_bytes: number;
|
|
}
|
|
|
|
export interface ImageReferenceArticleBrief {
|
|
article_id: number;
|
|
scopes: string[];
|
|
}
|
|
|
|
export interface ImageReferenceDetail {
|
|
total: number;
|
|
article_body: number;
|
|
article_cover: number;
|
|
articles: ImageReferenceArticleBrief[];
|
|
}
|
|
|
|
export interface FolderDeletePreview {
|
|
image_count: number;
|
|
referenced_articles: ImageReferenceArticleBrief[];
|
|
}
|
|
|
|
export interface ImageStorageUsage {
|
|
used_bytes: number;
|
|
quota_bytes: number;
|
|
used_pct: number;
|
|
}
|
|
|
|
export interface ImageUpdateRequest {
|
|
name?: string;
|
|
folder_id?: number | null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/shared-types/src/index.ts
|
|
git commit -m "feat(images): add shared TypeScript types for image management"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend API Client
|
|
|
|
**Files:**
|
|
- Modify: `apps/admin-web/src/lib/api.ts`
|
|
|
|
- [ ] **Step 1: Add imagesApi**
|
|
|
|
In `apps/admin-web/src/lib/api.ts`, add the import of new types at the top (inside the existing `import type { ... }` block from `@geo/shared-types`):
|
|
|
|
Add: `ImageFolder`, `ImageAssetListResponse`, `ImageUploadResponse`, `ImageReferenceDetail`, `FolderDeletePreview`, `ImageStorageUsage`, `ImageUpdateRequest`
|
|
|
|
Then add the `imagesApi` object after `instantTasksApi`:
|
|
|
|
```typescript
|
|
export const imagesApi = {
|
|
listFolders() {
|
|
return apiClient.get<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.
|