Files
geo/docs/superpowers/specs/2026-04-15-image-management-design.md
T
root 0a3558fc51 feat(deploy): add containerized deployment configuration and scripts
- Add Dockerfile for frontend and server services
- Add Docker Compose configs (standard and offline mode)
- Add nginx.conf for admin-web service
- Add deploy scripts: package.sh (packaging) and load-and-start.sh (startup)
- Add deploy/config.yaml, .env.example, and prompts.yml configurations
- Add image management design specification doc
2026-04-15 21:27:15 +08:00

505 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Image Management Design
**Date:** 2026-04-15
**Status:** Approved
## Overview
A standalone image asset management system for the geo-rankly admin platform. Supports independent upload, folder organization, fuzzy search, rename, and deletion. Images are reusable from the article editor via a picker modal.
---
## Requirements
| # | Requirement |
|---|---|
| R1 | Images are stored as tenant-scoped assets independent of articles |
| R2 | Users can organize images into single-level folders |
| R3 | All uploaded images are converted to WebP (quality=82) before storage |
| R4 | Image names are user-editable and support fuzzy search |
| R5 | Deletion supports reference warning + explicit force delete; final terminal state is soft-delete metadata + hard-delete OSS file |
| R6 | Article editor gains a "select from library" picker |
| R7 | Tenant isolation follows existing multi-tenant patterns |
| R8 | Storage quota enforced per plan: free=100 MB, regular=1 GB, premium=2 GB |
| R9 | Cover image picker modal: "AI封面" tab replaced with "本地素材" tab (image library) |
| R10 | Deleting a referenced image returns a usage summary; the user may still force delete |
| R11 | Quota enforcement is concurrency-safe on the hot path and does not rely on `SUM()` scans |
| R12 | Upload/delete consistency uses pending states + event-driven compensation, not a periodic full-table sweeper |
---
## Data Model
### `image_folders`
```sql
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);
```
- Single-level only (no nested folders)
- Name is unique per tenant
### `image_assets`
```sql
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, -- user-editable display name
size_bytes BIGINT NOT NULL, -- size after WebP conversion
status VARCHAR(20) NOT NULL DEFAULT 'active', -- pending_upload / active / pending_delete / deleted / upload_failed
created_by BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- terminal soft delete timestamp
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;
```
- `content_type` is always `image/webp` (omitted from table, implied)
- `folder_id = NULL` means root / uncategorized
- Active queries must enforce `status = 'active' AND deleted_at IS NULL`
- `object_key` path: `tenants/{tenant_id}/images/{uuid}.webp`
### `image_asset_references`
Used only for library-backed references so delete warnings are cheap and precise.
```sql
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, -- article_body / article_cover
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);
```
- Only images inserted from the library create reference rows
- Local uploads and arbitrary external URLs do not create reference rows
- `article_body` refs are deduped per article, not per occurrence count
### `tenant_image_storage_usage`
Hot-path storage usage counter. This is the authoritative `used_bytes` source for quota checks.
```sql
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()
);
```
- One row per tenant, updated atomically
- Avoids `SUM(image_assets.size_bytes)` on every upload
- `SUM()` remains a migration/backfill/reconciliation tool only
---
## Storage Quota
Storage quota limit remains in the existing `plans.quota_policy_json` JSONB field.
Hot-path usage is tracked in `tenant_image_storage_usage`.
**Plan configuration (added key):**
```json
{ "article_generation": 100, "image_storage_bytes": 104857600 }
```
| Plan | `image_storage_bytes` |
|---------|----------------------------|
| Free | 104,857,600 (100 MB) |
| Regular | 1,073,741,824 (1 GB) |
| Premium | 2,147,483,648 (2 GB) |
If a legacy plan row does not yet contain `image_storage_bytes`, runtime fallback is `104857600` until the migration/seed backfill is applied.
**Atomic quota reserve on upload:**
```sql
INSERT INTO tenant_image_storage_usage (tenant_id, used_bytes)
VALUES ($1, 0)
ON CONFLICT (tenant_id) DO NOTHING;
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;
```
If no row is returned, reject with HTTP 409 `image_storage_quota_exceeded`.
**Quota release on rollback/delete finalization:**
```sql
UPDATE tenant_image_storage_usage
SET used_bytes = GREATEST(0, used_bytes - $2),
updated_at = NOW()
WHERE tenant_id = $1;
```
**New API endpoint:**
```
GET /api/tenant/images/storage-usage
→ { used_bytes, quota_bytes, used_pct }
```
`GET /api/tenant/images/storage-usage` reads:
1. `used_bytes` from `tenant_image_storage_usage`
2. `quota_bytes` from the active plan
Used to display the storage progress bar in the UI.
---
## Image Upload Pipeline
```
User uploads file (PNG / JPG / GIF / WebP, ≤ 10 MB raw)
Validate file size and MIME type
image.DecodeConfig() with registered decoders
- reject oversized pixel bombs before full decode
image.Decode() → in-memory image.Image
GIF: first frame only (no animated WebP)
webp.Encode(quality=82) → []byte
BEGIN TX
- reserve quota via atomic counter update
- INSERT INTO image_assets (..., status='pending_upload')
COMMIT
Generate object_key: tenants/{id}/images/{uuid}.webp
ObjectStorage.PutBytes(objectKey, content, "image/webp")
Success:
UPDATE image_assets SET status='active', updated_at=NOW()
Failure:
release quota + mark status='upload_failed'
Return { id, url, name, size_bytes }
```
**Decoder registration:** `_ "image/gif"`, `_ "image/jpeg"`, `_ "image/png"`, `_ "golang.org/x/image/webp"`
**Encoder:** `github.com/chai2010/webp` — pure Go, no CGO, quality parameter 0100.
---
## Asset State Machine & Compensation
`image_assets.status` is an explicit operation state machine:
- `pending_upload`: quota reserved, DB row committed, OSS object not yet finalized
- `active`: visible in list/picker/editor
- `pending_delete`: hidden from UI, physical delete in progress
- `deleted`: terminal state, `deleted_at` populated
- `upload_failed`: terminal failure state, hidden from UI
Compensation is event-driven and idempotent, using the existing RabbitMQ infrastructure.
There is no periodic full-table sweeper.
**Upload finalize / compensation:**
1. Request thread creates `pending_upload`
2. Request thread attempts `PutBytes`
3. If request thread can finalize DB immediately, asset becomes `active`
4. If request thread fails after OSS success or OSS failure, it publishes an idempotent `finalize_upload` command with `asset_id`
5. Consumer behavior:
- row `pending_upload` + object exists → mark `active`
- row `pending_upload` + object missing → mark `upload_failed` and release reserved quota
- row already terminal → no-op
**Delete finalize / compensation:**
1. Request thread first checks references
2. If references exist and `force != true`, return 409 with usage summary
3. If delete proceeds, mark asset `pending_delete` so it disappears from active lists immediately
4. Request thread attempts `ObjectStorage.Delete`
5. On success, finalize DB delete (`status='deleted'`, `deleted_at=NOW()`) and release quota
6. If any post-step fails, publish idempotent `finalize_delete` command with `asset_id`
7. Consumer retries only that asset operation; no tenant/global scan is required
This is the same pattern used in large systems for inventory/quota and external side effects:
DB state machine + atomic counter + idempotent async compensation.
---
## API Design
All endpoints under `/api/tenant/images`, protected by existing auth middleware + tenant scope.
### Folders
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/tenant/images/folders` | List all folders with image count |
| POST | `/api/tenant/images/folders` | Create folder `{ name }` |
| PUT | `/api/tenant/images/folders/:id` | Rename folder `{ name }` |
| GET | `/api/tenant/images/folders/:id/delete-preview` | Return image count + reference summary for folder deletion |
| DELETE | `/api/tenant/images/folders/:id` | Delete folder, supports `?force=1`; contained images follow the same delete state machine |
### Images
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/tenant/images` | List images `?folder_id=&q=&page=&page_size=` |
| POST | `/api/tenant/images` | Upload image (multipart, optional `folder_id`) |
| PUT | `/api/tenant/images/:id` | Update `{ name?, folder_id? }` |
| GET | `/api/tenant/images/:id/references` | Return reference summary for delete confirmation |
| DELETE | `/api/tenant/images/:id` | Delete image, supports `?force=1` |
**Search (`?q=`):** uses `ILIKE '%keyword%'`, applied across all folders (folder_id filter is ignored when q is present).
**Delete image sequence:**
1. Query `image_asset_references`
2. If references exist and `force != true` → return HTTP 409 `image_asset_in_use`
3. `UPDATE image_assets SET status = 'pending_delete', updated_at = NOW() WHERE id = :id`
4. `ObjectStorage.Delete(objectKey)` — hard delete from OSS
5. `UPDATE image_assets SET status = 'deleted', deleted_at = NOW(), updated_at = NOW() WHERE id = :id`
6. `UPDATE tenant_image_storage_usage SET used_bytes = used_bytes - size_bytes`
**Delete warning payload:**
```json
{
"code": "image_asset_in_use",
"references": {
"total": 3,
"article_body": 2,
"article_cover": 1,
"articles": [
{ "article_id": 101, "title": "Spring Launch", "scopes": ["article_body"] },
{ "article_id": 204, "title": "Baidu Guide", "scopes": ["article_body", "article_cover"] }
]
}
}
```
- UI shows this summary and a destructive "仍然删除" button
- If the user confirms, client retries with `?force=1`
- Force delete is allowed even if it will break historical body/cover URLs
**Delete folder sequence:**
1. Load all active images in folder
2. Aggregate reference summary
3. If references exist and `force != true` → return HTTP 409 with summary
4. Mark contained assets `pending_delete` and set `folder_id = NULL`
5. Delete the folder row
6. Each image finalizes through the same `ObjectStorage.Delete` + DB finalize path
### Article Integration Contract
To support fast reference warnings without parsing article markdown on every delete:
- Images inserted from the library carry `asset_id` in editor-side metadata, while still rendering with normal `src`
- `PUT /api/tenant/articles/:id` adds optional `referenced_image_asset_ids: number[]`
- `wizard_state_json` keeps `cover_asset_url` as today, plus optional `cover_image_asset_id`
- On every article save, backend upserts `image_asset_references` for:
- `article_body` from `referenced_image_asset_ids`
- `article_cover` from `cover_image_asset_id`
- Direct local upload / external URL remains supported and simply produces no library reference row
---
## Backend Structure
Follows existing layered architecture in `server/internal/tenant/`.
```
server/internal/tenant/
├── app/
│ ├── image_service.go -- ImageService (upload, list, update, delete)
│ └── image_folder_service.go -- ImageFolderService (CRUD)
├── repository/
│ ├── image_repo.go -- DB queries for image_assets
│ ├── image_reference_repo.go -- DB queries for image_asset_references
│ ├── image_usage_repo.go -- atomic quota counter updates
│ └── queries/
│ ├── image.sql -- sqlc queries
│ ├── image_folder.sql -- sqlc queries
│ ├── image_reference.sql -- sqlc queries
│ └── image_usage.sql -- sqlc queries
└── transport/
└── image_handler.go -- HTTP handlers for all image endpoints
```
`ImageService` accepts:
- `objectstorage.Client`
- the existing `AssetHandler.BuildArticleImageURL()` for generating signed URLs
- workspace/plan lookup for `image_storage_bytes`
- an image-asset operation dispatcher backed by the existing RabbitMQ infrastructure for finalize/compensation commands
No new external infrastructure is introduced.
---
## Migration
New migration file: `20260415xxxxxx_create_image_tables.up.sql`
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE image_folders ( ... );
CREATE TABLE image_assets ( ... );
CREATE TABLE image_asset_references ( ... );
CREATE TABLE tenant_image_storage_usage ( ... );
-- indexes as above
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;
```
Existing article images (path `tenants/{id}/articles/{id}/images/...`) are **not migrated**. They continue to be served via the existing `/api/public/assets/:token` mechanism unchanged.
Seed data must also be updated so the existing default `free` plan includes `image_storage_bytes = 104857600`.
---
## Frontend
### New Page: `/images`
- Route added to `router/index.ts` under the "内容管理" nav group (alongside Knowledge)
- Nav label: `nav.images` (i18n key)
**Layout:**
```
┌─────────────────────────────────────────────────────┐
│ 图片管理 [+ 上传图片] │
├──────────────┬──────────────────────────────────────┤
│ 文件夹 [+] │ 🔍 搜索图片名... │
│──────────────│──────────────────────────────────────│
│ 📁 未分类(12)│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ 📁 产品图(8) │ │img │ │img │ │img │ │img │ │
│ 📁 博客配图 │ └────┘ └────┘ └────┘ └────┘ │
│ │ │
│ │ 文件名 · 大小 · 日期 [改名] [移动] │
└──────────────┴──────────────────────────────────────┘
```
- "文件夹" + `[+]` on the same row, `[+]` right-aligned
- Search input at top of grid area, 300ms debounce
- When `?q=` is active: folder sidebar is disabled, search results shown across all folders
- Image grid: fixed 4-column grid with lazy loading
- Hover on image card: show [改名] [移动] [删除] action buttons
- Clicking [删除] first requests `/api/tenant/images/:id/references`; if references exist, show a warning modal with article titles/counts and a destructive "仍然删除" action
### Article Editor Picker
- Existing toolbar image button gets a dropdown: "本地上传" | "从图片库选择"
- "从图片库选择" opens a Modal containing the image list (folder filter + search), click to insert
- Library insert stores `{ src, asset_id }` in editor-side metadata; plain local upload remains `{ src }`
### Cover Image Modal Integration
The existing "选择封面图" modal has two tabs: "正文/本地上传" and "AI封面".
**Change:** Replace the "AI封面" tab with "本地素材" tab.
```text
┌─────────────────────────────────────────┐
│ 选择封面图 ✕ │
├─────────────────────────────────────────┤
│ [正文/本地上传] [本地素材] │ ← "AI封面" → "本地素材"
├─────────────────────────────────────────┤
│ 🔍 搜索... 文件夹: [全部 ▾] │
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │img │ │img │ │img │ │img │ │
│ └────┘ └────┘ └────┘ └────┘ │
│ │
│ [取消] [确认] │
└─────────────────────────────────────────┘
```
- "本地素材" tab reuses the same image list API (`GET /api/tenant/images`)
- Single-select mode: clicking an image highlights it
- If current primary platform requires crop (currently Baijiahao only), selecting a library image routes into the existing crop workspace before confirmation
- If no crop is required, "确认" uses the original library image URL directly and stores `cover_image_asset_id`
- Folder dropdown filter + search input for discovery
- Storage usage bar shown at bottom of the tab
- Result:
- Baijiahao: library image acts as crop source, final cover is the cropped export
- Other platforms: use original library asset without re-upload/re-encode
---
## Constraints & Decisions
| Decision | Rationale |
|----------|-----------|
| Single-level folders | Avoids recursive tree complexity; sufficient for this use case |
| WebP quality=82 | Good perceptual quality, ~3050% smaller than JPEG at equivalent quality |
| GIF → static WebP (first frame) | Animated WebP adds significant complexity, deferred |
| Force delete is allowed after reference warning | Users may intentionally trade historical render integrity for storage recovery |
| `image_asset_references` is explicit | Delete warning stays cheap and precise; no markdown scan on delete |
| Atomic tenant counter for quota | High-concurrency safe and O(1) on hot path |
| Pending states + MQ compensation | External side effects stay recoverable without a periodic full-table scan |
| Delete hides first, finalizes second | UI reacts immediately while compensation remains idempotent |
| Baijiahao only enters cropper | Preserves current mandatory 16:9 cover behavior without adding unnecessary steps for other platforms |
| WebP decode uses `golang.org/x/image/webp` | Standard, simple, no CGO, works with `image.Decode()` after decoder registration |
| Tenant-aware composite foreign keys | DB enforces folder/image tenant isolation without adding a complex model |
| `ILIKE` for search | Dataset is tenant-scoped and small; trigram index added as optimization |
| No historical image migration | Avoids risky bulk migration; old paths still served correctly |