505 lines
21 KiB
Markdown
505 lines
21 KiB
Markdown
|
|
# 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 0–100.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 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, ~30–50% 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 |
|