Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87db74fb89 | |||
| edf7939963 | |||
| f635359216 | |||
| e277754588 | |||
| 009082d0f4 | |||
| c2523fe349 | |||
| 70d9d5b92a |
@@ -62,6 +62,14 @@ LOG_LEVEL=log
|
|||||||
# Redis endpoint used by BullMQ and shared authentication rate limits.
|
# Redis endpoint used by BullMQ and shared authentication rate limits.
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# S3-compatible object storage. Local development falls back to MinIO root credentials above;
|
||||||
|
# production must set dedicated, least-privileged OBJECT_STORAGE_ACCESS_KEY / SECRET_KEY values.
|
||||||
|
OBJECT_STORAGE_ENDPOINT=http://localhost:9000
|
||||||
|
OBJECT_STORAGE_BUCKET=atlas-assets
|
||||||
|
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||||
|
# OBJECT_STORAGE_ACCESS_KEY=
|
||||||
|
# OBJECT_STORAGE_SECRET_KEY=
|
||||||
|
|
||||||
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
|
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
|
||||||
MINIO_ROOT_USER=atlas_minio_local
|
MINIO_ROOT_USER=atlas_minio_local
|
||||||
# Local MinIO root password; replace outside disposable local development.
|
# Local MinIO root password; replace outside disposable local development.
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ Atlas Seller Kit 是面向多用户、多门店、多平台与多国家市场的
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
仅完成产品与架构设计,尚未开始应用代码开发。开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
项目已完成 monorepo 与本地基础设施初始化,并实现认证、个人工作空间、平台目录/上下文和门店管理 HTTP API。Web 已提供登录/注册、主布局、页头平台选择器和接入真实 API 的店铺管理页;其余业务页面仍为占位。门店凭据验证已接入 Outbox Dispatcher 与 Worker;仓库同步仍待实现。
|
||||||
|
|
||||||
|
开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
||||||
|
|
||||||
## 设计目标
|
## 设计目标
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
"@atlas/database": "workspace:*",
|
"@atlas/database": "workspace:*",
|
||||||
|
"@aws-sdk/client-s3": "3.1015.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "3.1015.0",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
"@nestjs/core": "12.0.1",
|
"@nestjs/core": "12.0.1",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { AppConfigModule } from './config/app-config.module.js';
|
|||||||
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
||||||
import { SecurityModule } from './security/security.module.js';
|
import { SecurityModule } from './security/security.module.js';
|
||||||
import { StoresModule } from './stores/stores.module.js';
|
import { StoresModule } from './stores/stores.module.js';
|
||||||
|
import { WatermarksModule } from './watermarks/watermarks.module.js';
|
||||||
|
import { MediaModule } from './media/media.module.js';
|
||||||
|
import { CollectionModule } from './collection/collection.module.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
class HealthController {
|
class HealthController {
|
||||||
@@ -41,6 +44,9 @@ class HealthController {
|
|||||||
SecurityModule,
|
SecurityModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
StoresModule,
|
StoresModule,
|
||||||
|
WatermarksModule,
|
||||||
|
MediaModule,
|
||||||
|
CollectionModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Query, Req, Res } from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { bulkDeleteCollectionItemsRequestSchema, clearCollectionItemsRequestSchema, createCollectionItemRequestSchema, listCollectionItemsQuerySchema, retryCollectionAssetsRequestSchema, updateCollectionItemRequestSchema, type Workspace } from '@atlas/contracts';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import { CollectionService } from './collection.service.js';
|
||||||
|
|
||||||
|
type Response = { status: (value: number) => Response };
|
||||||
|
@Controller('collection-items')
|
||||||
|
export class CollectionController {
|
||||||
|
public constructor(@Inject(CollectionService) private readonly collection: CollectionService) {}
|
||||||
|
@Get() async list(@Query() query: unknown, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
if (!platformId) throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
||||||
|
return this.collection.list(workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query));
|
||||||
|
}
|
||||||
|
@Post() async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.create(workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Patch(':id') async update(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Headers('if-match') match: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return (await this.collection.update(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), revision(match), parse(updateCollectionItemRequestSchema, body))).body;
|
||||||
|
}
|
||||||
|
@Post('bulk-delete') async bulkDelete(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.bulkDelete(workspace.id, request.auth.userId, requireKey(key), parse(bulkDeleteCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Post('clear') async clear(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.clear(workspace.id, request.auth.userId, requireKey(key), parse(clearCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Post(':id/asset-retries') async retry(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.retryAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(retryCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) { const result = schema.safeParse(input); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((x) => ({ field: x.path.join('.'), code: 'INVALID_FIELD', message: x.message }))); }
|
||||||
|
function requireUuid(field: string, value: string) { if (z.uuid().safeParse(value).success) return value; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]); }
|
||||||
|
function requireKey(value: string | undefined) { if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key'); return requireUuid('Idempotency-Key', value); }
|
||||||
|
function revision(value: string | undefined) { const valueMatch = value?.match(/^"([1-9]\d*)"$/); if (!valueMatch) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); return Number(valueMatch[1]); }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { CollectionController } from './collection.controller.js';
|
||||||
|
import { CollectionService } from './collection.service.js';
|
||||||
|
|
||||||
|
@Module({ controllers: [CollectionController], providers: [IdempotencyService, CollectionService] })
|
||||||
|
export class CollectionModule {}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { CreateCollectionItemRequest, ListCollectionItemsQuery, UpdateCollectionItemRequest } from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type Row = { id: string; product_id: string; source_kind: 'platform' | 'manual'; platform_code: string | null; source_url: string | null; source_title: string | null; source_min_price: string | null; source_max_price: string | null; source_currency_code: string | null; note: string | null; status: string; created_at: Date | string; updated_at: Date | string; revision: number; variant_count: string; transfer_status: string | null };
|
||||||
|
@Injectable()
|
||||||
|
export class CollectionService {
|
||||||
|
public constructor(@Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
async list(workspaceId: string, platformId: string, query: ListCollectionItemsQuery) {
|
||||||
|
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await this.requireHeaderPlatform(tx, platformId);
|
||||||
|
const rows = await tx.query<Row>(`${selectRow}
|
||||||
|
WHERE c.deleted_at IS NULL AND ($1::text IS NULL OR c.source_title ILIKE '%' || $1 || '%')
|
||||||
|
AND ($2::text IS NULL OR p.code = $2) AND ($3::text IS NULL OR $3 = 'not_listed')
|
||||||
|
AND ($4::timestamptz IS NULL OR (c.updated_at, c.id) < ($4, $5::uuid))
|
||||||
|
${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`, [query.q ?? null, query.source_platform_code ?? null, query.listing_status ?? null, cursor?.at ?? null, cursor?.id ?? null, query.limit + 1]);
|
||||||
|
const items = rows.rows.slice(0, query.limit).map(toItem); const last = items.at(-1);
|
||||||
|
const count = await tx.query<{ count: string; collection_item_limit: number }>('SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL');
|
||||||
|
return { data: items, meta: { has_more: rows.rows.length > query.limit, next_cursor: rows.rows.length > query.limit && last ? encodeCursor(last.updated_at, last.id) : null, active_count: Number(count.rows[0]?.count ?? 0), collection_limit: count.rows[0]?.collection_item_limit ?? 1000 } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async create(workspaceId: string, userId: string, key: string, input: CreateCollectionItemRequest) {
|
||||||
|
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/collection-items', key, request: input }, async (tx) => {
|
||||||
|
const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`, [randomUUID(), input.title ?? null, input.note ?? null]);
|
||||||
|
const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, input.title ?? null]);
|
||||||
|
await tx.query(`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`, [product.rows[0].id]);
|
||||||
|
return { status: 201, body: { data: { collection_item: await this.requireItem(tx, item.rows[0].id), product_id: product.rows[0].id, editor_entry_path: `/collection-items/${item.rows[0].id}/edit` } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) {
|
||||||
|
return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => {
|
||||||
|
const result = await tx.query(`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, [input.note, id, expected]);
|
||||||
|
if (!result.rowCount) await this.versionConflict(tx, id, expected);
|
||||||
|
return { status: 200, body: { data: await this.requireItem(tx, id) } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async bulkDelete(workspaceId: string, userId: string, key: string, ids: { ids: string[] }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/bulk-delete', ids, false); }
|
||||||
|
async clear(workspaceId: string, userId: string, key: string, input: { confirm: true; expected_active_count: number }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/clear', input, true); }
|
||||||
|
async retryAssets(workspaceId: string, userId: string, id: string, key: string, input: { transfer_ids: string[] }) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-retries`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'queued', error_payload = NULL, updated_at = now() WHERE collection_item_id = $1 AND id = ANY($2::uuid[]) AND status IN ('failed', 'cancelled')`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集记录中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id }); return { status: 202, body: { data: { operation } } }; }); }
|
||||||
|
private async removeMany(workspaceId: string, userId: string, key: string, path: string, input: unknown, clear: boolean) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path, key, request: input }, async (tx) => { const requested = input as { ids?: string[]; expected_active_count?: number }; const count = await tx.query<{ count: string }>('SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL'); if (clear && Number(count.rows[0].count) !== requested.expected_active_count) throw new ApiError(409, 'COLLECTION_COUNT_CHANGED', '采集箱数量已变化,请重新确认'); const deleted = await tx.query<{ id: string }>(clear ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, clear ? [] : [requested.ids]); if (!clear && deleted.rows.length !== requested.ids?.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '部分采集记录不存在'); const operation = await this.operation(tx, userId, 'collection_cleanup', deleted.rows[0]?.id ?? null); await this.event(tx, operation.id, 'collection.cleanup.requested', { operation_id: operation.id, collection_item_ids: deleted.rows.map((row) => row.id) }); return { status: 202, body: { data: { deleted_count: deleted.rows.length, operation } } }; }); }
|
||||||
|
private async operation(tx: DatabaseTransaction, userId: string, kind: string, resourceId: string | null) { const row = await tx.query<{ id: string; status: string; created_at: Date | string }>(`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, [kind, resourceId, userId]); return { id: row.rows[0].id, kind, status: row.rows[0].status, created_at: iso(row.rows[0].created_at), completed_at: null, error: null }; }
|
||||||
|
private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); }
|
||||||
|
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); }
|
||||||
|
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); }
|
||||||
|
private async versionConflict(tx: DatabaseTransaction, id: string, expected: number) { const row = await tx.query<{ revision: number }>('SELECT revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); if (row.rows[0].revision !== expected) throw new ApiError(409, 'VERSION_CONFLICT', '采集记录已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision }]); }
|
||||||
|
}
|
||||||
|
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id`;
|
||||||
|
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
|
||||||
|
function toItem(row: Row) { return { id: row.id, product_id: row.product_id, source: { kind: row.source_kind, platform_code: row.platform_code, url: row.source_url }, title: row.source_title, thumbnail: { asset_id: null, preview_url: null, status: row.transfer_status ?? 'none' }, source_price: { min: row.source_min_price, max: row.source_max_price, currency_code: row.source_currency_code }, variant_summary: { count: Number(row.variant_count), needs_review: row.status === 'needs_review' }, note: row.note, collection_status: row.status, listing_summary: { status: 'not_listed' as const, detail: '等待首次上架', last_error: null }, created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; }
|
||||||
|
function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; }
|
||||||
|
function encodeCursor(at: string, id: string) { return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); }
|
||||||
|
function decodeCursor(cursor: string): { at: string; id: string } { try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { at?: string; id?: string }; if (!value.at || !value.id || Number.isNaN(Date.parse(value.at))) throw new Error(); return { at: value.at, id: value.id }; } catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } }
|
||||||
@@ -28,6 +28,13 @@ const environmentSchema = z.object({
|
|||||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||||
DATABASE_URL: z.url(),
|
DATABASE_URL: z.url(),
|
||||||
REDIS_URL: z.url(),
|
REDIS_URL: z.url(),
|
||||||
|
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||||
|
OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'),
|
||||||
|
OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
|
||||||
|
OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(),
|
||||||
|
OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(),
|
||||||
|
MINIO_ROOT_USER: z.string().min(3).optional(),
|
||||||
|
MINIO_ROOT_PASSWORD: z.string().min(8).optional(),
|
||||||
AUTH_JWT_SECRET: z.string().min(32),
|
AUTH_JWT_SECRET: z.string().min(32),
|
||||||
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
||||||
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
|
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
|
||||||
@@ -94,6 +101,9 @@ export type AppConfig = Omit<
|
|||||||
> & {
|
> & {
|
||||||
trustedProxyCidrs: string[];
|
trustedProxyCidrs: string[];
|
||||||
swaggerEnabled: boolean;
|
swaggerEnabled: boolean;
|
||||||
|
objectStorageAccessKey: string;
|
||||||
|
objectStorageSecretKey: string;
|
||||||
|
objectStorageForcePathStyle: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function validateEnvironment(
|
function validateEnvironment(
|
||||||
@@ -108,6 +118,16 @@ function validateEnvironment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAppConfig(environment: Environment): AppConfig {
|
function createAppConfig(environment: Environment): AppConfig {
|
||||||
|
const objectStorageAccessKey =
|
||||||
|
environment.OBJECT_STORAGE_ACCESS_KEY ?? environment.MINIO_ROOT_USER;
|
||||||
|
const objectStorageSecretKey =
|
||||||
|
environment.OBJECT_STORAGE_SECRET_KEY ??
|
||||||
|
environment.MINIO_ROOT_PASSWORD;
|
||||||
|
if (!objectStorageAccessKey || !objectStorageSecretKey) {
|
||||||
|
throw new Error(
|
||||||
|
'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...environment,
|
...environment,
|
||||||
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||||
@@ -115,6 +135,10 @@ function createAppConfig(environment: Environment): AppConfig {
|
|||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||||
|
objectStorageAccessKey,
|
||||||
|
objectStorageSecretKey,
|
||||||
|
objectStorageForcePathStyle:
|
||||||
|
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
completeMediaUploadIntentRequestSchema,
|
||||||
|
createMediaAccessUrlRequestSchema,
|
||||||
|
createMediaUploadIntentRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
@ApiTags('Media')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class MediaController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(MediaService) private readonly media: MediaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('media/upload-intents')
|
||||||
|
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
|
||||||
|
public async createIntent(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.media.createIntent(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
idempotencyKey(key),
|
||||||
|
parse(createMediaUploadIntentRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('media/upload-intents/:uploadId/complete')
|
||||||
|
@ApiCreatedResponse({ description: '已完成对象安全校验并创建素材' })
|
||||||
|
public async complete(
|
||||||
|
@Param('uploadId') uploadId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.media.completeIntent(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
uuid('upload_id', uploadId),
|
||||||
|
idempotencyKey(key),
|
||||||
|
parse(completeMediaUploadIntentRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('media-assets/:assetId/access-urls')
|
||||||
|
@ApiOkResponse({ description: '已签发短时预览 URL' })
|
||||||
|
public async accessUrl(
|
||||||
|
@Param('assetId') assetId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
parse(createMediaAccessUrlRequestSchema, body);
|
||||||
|
return {
|
||||||
|
data: await this.media.accessUrl(
|
||||||
|
workspace.id,
|
||||||
|
uuid('asset_id', assetId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<
|
||||||
|
T extends {
|
||||||
|
safeParse: (input: unknown) => {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
>(schema: T, input: unknown) {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (result.success)
|
||||||
|
return result.data as ReturnType<T['safeParse']> extends {
|
||||||
|
data: infer Data;
|
||||||
|
}
|
||||||
|
? Data
|
||||||
|
: never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function uuid(field: string, value: string) {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function idempotencyKey(value: string | undefined) {
|
||||||
|
if (!value)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
return uuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { MediaController } from './media.controller.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [IdempotencyService, MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
CreateBucketCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CompleteMediaUploadIntentRequest,
|
||||||
|
CreateMediaUploadIntentRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type IntentRow = {
|
||||||
|
id: string;
|
||||||
|
storage_provider: string;
|
||||||
|
bucket: string;
|
||||||
|
object_key: string;
|
||||||
|
expected_content_type: string;
|
||||||
|
expected_byte_size: string;
|
||||||
|
expected_checksum_sha256: string;
|
||||||
|
status: 'pending' | 'ready' | 'failed' | 'expired';
|
||||||
|
expires_at: Date | string;
|
||||||
|
asset_id: string | null;
|
||||||
|
};
|
||||||
|
type AssetRow = {
|
||||||
|
id: string;
|
||||||
|
content_type: string;
|
||||||
|
byte_size: string;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
created_at: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
private readonly s3: S3Client;
|
||||||
|
private bucketReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {
|
||||||
|
this.s3 = new S3Client({
|
||||||
|
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||||
|
region: 'us-east-1',
|
||||||
|
forcePathStyle: config.objectStorageForcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.objectStorageAccessKey,
|
||||||
|
secretAccessKey: config.objectStorageSecretKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createIntent(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
input: CreateMediaUploadIntentRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/media/upload-intents',
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
await this.ensureBucket();
|
||||||
|
const id = randomUUID();
|
||||||
|
const objectKey = `uploads/${workspaceId}/watermark-images/${id}`;
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.media_upload_intents
|
||||||
|
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||||
|
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
|
||||||
|
$2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
objectKey,
|
||||||
|
input.content_type,
|
||||||
|
input.byte_size,
|
||||||
|
input.checksum_sha256,
|
||||||
|
expiresAt,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const url = await getSignedUrl(
|
||||||
|
this.s3,
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
Key: objectKey,
|
||||||
|
ContentType: input.content_type,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 600 },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
upload_id: id,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
upload: {
|
||||||
|
method: 'PUT',
|
||||||
|
url,
|
||||||
|
headers: { 'content-type': input.content_type },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeIntent(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
intentId: string,
|
||||||
|
key: string,
|
||||||
|
input: CompleteMediaUploadIntentRequest,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/media/upload-intents/${intentId}/complete`,
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const intent = await this.lockIntent(tx, intentId);
|
||||||
|
if (intent.status === 'ready' && intent.asset_id) {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
asset: await this.requireAsset(
|
||||||
|
tx,
|
||||||
|
intent.asset_id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent.status === 'failed') {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'该上传意图的文件未通过安全校验,请重新上传',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (intent.status === 'expired') {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_EXPIRED',
|
||||||
|
'上传意图已过期,请重新发起上传',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Date(intent.expires_at).getTime() <= Date.now()) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_EXPIRED',
|
||||||
|
'上传意图已过期,请重新发起上传',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.checksum_sha256 !==
|
||||||
|
intent.expected_checksum_sha256
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_CHECKSUM_MISMATCH',
|
||||||
|
'完成请求的文件摘要与上传意图不一致',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inspected = await this.inspectObject(intent);
|
||||||
|
if (
|
||||||
|
inspected.byteSize !==
|
||||||
|
Number(intent.expected_byte_size) ||
|
||||||
|
inspected.checksum !==
|
||||||
|
intent.expected_checksum_sha256 ||
|
||||||
|
inspected.contentType !== intent.expected_content_type
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件未通过安全校验',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1`,
|
||||||
|
[inspected.checksum],
|
||||||
|
);
|
||||||
|
const assetId = existing.rows[0]?.id ?? randomUUID();
|
||||||
|
if (!existing.rows[0]) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
|
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size,
|
||||||
|
checksum_sha256, width_px, height_px)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
intent.bucket,
|
||||||
|
intent.object_key,
|
||||||
|
inspected.contentType,
|
||||||
|
inspected.byteSize,
|
||||||
|
inspected.checksum,
|
||||||
|
inspected.width,
|
||||||
|
inspected.height,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.media_upload_intents SET status = 'ready', asset_id = $2,
|
||||||
|
completed_at = now(), error_code = NULL, error_message = NULL WHERE id = $1`,
|
||||||
|
[intentId, assetId],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
asset: await this.requireAsset(tx, assetId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const code = apiErrorCode(error);
|
||||||
|
if (
|
||||||
|
code === 'UPLOAD_EXPIRED' ||
|
||||||
|
code === 'UPLOAD_OBJECT_MISSING' ||
|
||||||
|
code === 'UPLOAD_VALIDATION_FAILED'
|
||||||
|
) {
|
||||||
|
await this.persistTerminalIntentStatus(
|
||||||
|
workspaceId,
|
||||||
|
intentId,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async accessUrl(workspaceId: string, assetId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const asset = await tx.query<{
|
||||||
|
bucket: string;
|
||||||
|
object_key: string;
|
||||||
|
}>(
|
||||||
|
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!asset.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||||
|
const url = await getSignedUrl(
|
||||||
|
this.s3,
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: asset.rows[0].bucket,
|
||||||
|
Key: asset.rows[0].object_key,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 600 },
|
||||||
|
);
|
||||||
|
return { url, expires_at: expiresAt.toISOString() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockIntent(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
intentId: string,
|
||||||
|
): Promise<IntentRow> {
|
||||||
|
const rows = await tx.query<IntentRow>(
|
||||||
|
`SELECT id, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
|
||||||
|
expected_checksum_sha256, status, expires_at, asset_id
|
||||||
|
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
|
||||||
|
[intentId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '上传意图不存在');
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAsset(tx: DatabaseTransaction, assetId: string) {
|
||||||
|
const rows = await tx.query<AssetRow>(
|
||||||
|
`SELECT id, content_type, byte_size, width_px, height_px, created_at
|
||||||
|
FROM atlas.media_assets WHERE id = $1`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||||
|
const asset = rows.rows[0];
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
content_type: asset.content_type,
|
||||||
|
byte_size: Number(asset.byte_size),
|
||||||
|
width_px: asset.width_px,
|
||||||
|
height_px: asset.height_px,
|
||||||
|
created_at:
|
||||||
|
asset.created_at instanceof Date
|
||||||
|
? asset.created_at.toISOString()
|
||||||
|
: asset.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inspectObject(intent: IntentRow) {
|
||||||
|
let head;
|
||||||
|
try {
|
||||||
|
head = await this.s3.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: intent.bucket,
|
||||||
|
Key: intent.object_key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_OBJECT_MISSING',
|
||||||
|
'尚未找到已上传文件',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!head.ContentLength || head.ContentLength > 5 * 1024 * 1024) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件大小不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const declaredContentType = head.ContentType?.split(
|
||||||
|
';',
|
||||||
|
1,
|
||||||
|
)[0]?.toLowerCase();
|
||||||
|
if (declaredContentType !== intent.expected_content_type) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件声明类型不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const object = await this.s3.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: intent.bucket,
|
||||||
|
Key: intent.object_key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!object.Body)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_OBJECT_MISSING',
|
||||||
|
'尚未找到已上传文件',
|
||||||
|
);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let byteSize = 0;
|
||||||
|
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
byteSize += chunk.byteLength;
|
||||||
|
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
||||||
|
if (byteSize > 5 * 1024 * 1024) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件大小不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const bytes = Buffer.concat(chunks, byteSize);
|
||||||
|
const detected = detectImage(bytes);
|
||||||
|
if (
|
||||||
|
!detected ||
|
||||||
|
detected.contentType !== intent.expected_content_type
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件类型或内容不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contentType: detected.contentType,
|
||||||
|
width: detected.width,
|
||||||
|
height: detected.height,
|
||||||
|
byteSize,
|
||||||
|
checksum: createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBucket() {
|
||||||
|
if (!this.bucketReady)
|
||||||
|
this.bucketReady = (async () => {
|
||||||
|
try {
|
||||||
|
await this.s3.send(
|
||||||
|
new HeadBucketCommand({
|
||||||
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
await this.s3.send(
|
||||||
|
new CreateBucketCommand({
|
||||||
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.bucketReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistTerminalIntentStatus(
|
||||||
|
workspaceId: string,
|
||||||
|
intentId: string,
|
||||||
|
code:
|
||||||
|
| 'UPLOAD_EXPIRED'
|
||||||
|
| 'UPLOAD_OBJECT_MISSING'
|
||||||
|
| 'UPLOAD_VALIDATION_FAILED',
|
||||||
|
) {
|
||||||
|
await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const expired = code === 'UPLOAD_EXPIRED';
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.media_upload_intents
|
||||||
|
SET status = $2, error_code = $3, error_message = $4, completed_at = now()
|
||||||
|
WHERE id = $1 AND status = 'pending'`,
|
||||||
|
[
|
||||||
|
intentId,
|
||||||
|
expired ? 'expired' : 'failed',
|
||||||
|
code,
|
||||||
|
expired ? '上传意图已过期' : '上传文件未通过安全校验',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorCode(error: unknown): string | undefined {
|
||||||
|
if (!(error instanceof ApiError)) return undefined;
|
||||||
|
const response = error.getResponse();
|
||||||
|
if (typeof response !== 'object' || !response || !('error' in response))
|
||||||
|
return undefined;
|
||||||
|
const apiError = response.error;
|
||||||
|
return typeof apiError === 'object' &&
|
||||||
|
apiError &&
|
||||||
|
'code' in apiError &&
|
||||||
|
typeof apiError.code === 'string'
|
||||||
|
? apiError.code
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImage(bytes: Buffer): {
|
||||||
|
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null {
|
||||||
|
if (
|
||||||
|
bytes.length >= 24 &&
|
||||||
|
bytes
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/png',
|
||||||
|
bytes.readUInt32BE(16),
|
||||||
|
bytes.readUInt32BE(20),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes.subarray(0, 4).toString() === 'RIFF' &&
|
||||||
|
bytes.subarray(8, 12).toString() === 'WEBP'
|
||||||
|
) {
|
||||||
|
const type = bytes.subarray(12, 16).toString();
|
||||||
|
if (type === 'VP8X' && bytes.length >= 30) {
|
||||||
|
return dimensions(
|
||||||
|
'image/webp',
|
||||||
|
1 + bytes.readUIntLE(24, 3),
|
||||||
|
1 + bytes.readUIntLE(27, 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type === 'VP8 ' &&
|
||||||
|
bytes.length >= 30 &&
|
||||||
|
bytes[23] === 0x9d &&
|
||||||
|
bytes[24] === 0x01 &&
|
||||||
|
bytes[25] === 0x2a
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/webp',
|
||||||
|
bytes.readUInt16LE(26) & 0x3fff,
|
||||||
|
bytes.readUInt16LE(28) & 0x3fff,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||||
|
for (let offset = 2; offset + 9 < bytes.length;) {
|
||||||
|
if (bytes[offset] !== 0xff) return null;
|
||||||
|
const marker = bytes[offset + 1];
|
||||||
|
const length = bytes.readUInt16BE(offset + 2);
|
||||||
|
if (length < 2 || offset + 2 + length > bytes.length) return null;
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
|
||||||
|
0xcd, 0xce, 0xcf,
|
||||||
|
].includes(marker)
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/jpeg',
|
||||||
|
bytes.readUInt16BE(offset + 7),
|
||||||
|
bytes.readUInt16BE(offset + 5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
offset += 2 + length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function dimensions(
|
||||||
|
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
return width >= 1 && height >= 1 && width <= 8192 && height <= 8192
|
||||||
|
? { contentType, width, height }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export class PlatformsService {
|
|||||||
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
||||||
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||||
WHERE p.status = 'active'
|
WHERE p.status = 'active'
|
||||||
|
AND coalesce((p.capabilities->>'can_be_header_context')::boolean, false)
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||||
);
|
);
|
||||||
@@ -41,6 +42,10 @@ export class PlatformsService {
|
|||||||
platform.capabilities,
|
platform.capabilities,
|
||||||
'default_locale',
|
'default_locale',
|
||||||
),
|
),
|
||||||
|
default_currency_code: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_currency_code',
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
createDecipheriv,
|
||||||
createCipheriv,
|
createCipheriv,
|
||||||
createHash,
|
createHash,
|
||||||
createHmac,
|
createHmac,
|
||||||
@@ -35,3 +36,23 @@ export function encryptStoreSecret(
|
|||||||
.digest('base64url'),
|
.digest('base64url'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅供受信任的后台 Worker 使用;调用方不得记录返回值。 */
|
||||||
|
export function decryptStoreSecret(
|
||||||
|
ciphertextWithTag: Buffer,
|
||||||
|
nonce: Buffer,
|
||||||
|
keyMaterial: string,
|
||||||
|
): string {
|
||||||
|
const authenticationTagLength = 16;
|
||||||
|
if (ciphertextWithTag.length <= authenticationTagLength) {
|
||||||
|
throw new Error('Invalid encrypted store secret');
|
||||||
|
}
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const ciphertext = ciphertextWithTag.subarray(0, -authenticationTagLength);
|
||||||
|
const tag = ciphertextWithTag.subarray(-authenticationTagLength);
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -470,6 +470,19 @@ export class StoresService {
|
|||||||
'门店凭据尚未验证通过,无法同步仓库',
|
'门店凭据尚未验证通过,无法同步仓库',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (kind === 'store_credential_verification') {
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.stores SET status = 'verifying', credential_status = 'unknown',
|
||||||
|
last_error_code = NULL, last_error_message = NULL, last_error_at = NULL,
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.store_secrets SET status = 'unknown', updated_at = now()
|
||||||
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
}
|
||||||
const operation = await this.createOperation(
|
const operation = await this.createOperation(
|
||||||
transaction,
|
transaction,
|
||||||
userId,
|
userId,
|
||||||
@@ -605,6 +618,20 @@ export class StoresService {
|
|||||||
[kind, storeId, userId],
|
[kind, storeId, userId],
|
||||||
);
|
);
|
||||||
const operation = toOperation(created.rows[0]);
|
const operation = toOperation(created.rows[0]);
|
||||||
|
const payload: { store_id: string; operation_id: string; secret_id?: string } = {
|
||||||
|
store_id: storeId,
|
||||||
|
operation_id: operation.id,
|
||||||
|
};
|
||||||
|
// 验证任务绑定创建当刻的密钥行;密钥后来被替换时,旧任务不得验证或覆盖新状态。
|
||||||
|
if (kind === 'store_credential_verification') {
|
||||||
|
const secret = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.store_secrets
|
||||||
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (!secret.rows[0]) throw new Error('Current store secret is missing');
|
||||||
|
payload.secret_id = secret.rows[0].id;
|
||||||
|
}
|
||||||
await transaction.query(
|
await transaction.query(
|
||||||
`INSERT INTO atlas.outbox_events
|
`INSERT INTO atlas.outbox_events
|
||||||
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
@@ -612,7 +639,7 @@ export class StoresService {
|
|||||||
[
|
[
|
||||||
storeId,
|
storeId,
|
||||||
`${kind}.requested`,
|
`${kind}.requested`,
|
||||||
{ store_id: storeId, operation_id: operation.id },
|
payload,
|
||||||
operation.id,
|
operation.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, Patch, Post, Put, Query, Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiCreatedResponse, ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
bindStoreWatermarkRequestSchema, createWatermarkTemplateRequestSchema,
|
||||||
|
listWatermarkTemplatesQuerySchema, updateWatermarkTemplateRequestSchema, type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import { WatermarksService } from './watermarks.service.js';
|
||||||
|
|
||||||
|
@ApiTags('Watermarks')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class WatermarksController {
|
||||||
|
public constructor(@Inject(WatermarksService) private readonly watermarks: WatermarksService) {}
|
||||||
|
|
||||||
|
@Get('watermark-fonts')
|
||||||
|
@ApiOperation({ summary: '读取当前渲染服务可用字体' })
|
||||||
|
public async fonts() { return { data: await this.watermarks.fonts() }; }
|
||||||
|
|
||||||
|
@Get('watermark-templates')
|
||||||
|
@ApiOperation({ summary: '分页读取当前工作空间活动水印模板' })
|
||||||
|
public async list(@Query() query: unknown, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('watermark-templates/:templateId')
|
||||||
|
@ApiOperation({ summary: '读取水印模板当前版本' })
|
||||||
|
public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return { data: await this.watermarks.get(workspace.id, uuid('template_id', templateId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('watermark-templates')
|
||||||
|
@ApiCreatedResponse({ description: '已创建水印模板及首个渲染版本' })
|
||||||
|
public async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return (await this.watermarks.create(workspace.id, request.auth.userId, idempotencyKey(key),
|
||||||
|
parse(createWatermarkTemplateRequestSchema, body))).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('watermark-templates/:templateId')
|
||||||
|
@ApiOkResponse({ description: '已更新水印模板' })
|
||||||
|
public async update(@Param('templateId') templateId: string, @Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined, @Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return (await this.watermarks.update(workspace.id, request.auth.userId, uuid('template_id', templateId),
|
||||||
|
idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('watermark-templates/:templateId')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' })
|
||||||
|
public async remove(@Param('templateId') templateId: string, @Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
await this.watermarks.remove(workspace.id, request.auth.userId, uuid('template_id', templateId), idempotencyKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('stores/:storeId/watermark-binding')
|
||||||
|
@ApiOkResponse({ description: '已更新店铺推荐水印' })
|
||||||
|
public async bindStore(@Param('storeId') storeId: string, @Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace) {
|
||||||
|
const input = parse(bindStoreWatermarkRequestSchema, body) as { template_id: string | null };
|
||||||
|
return (await this.watermarks.bindStore(workspace.id, request.auth.userId, uuid('store_id', storeId),
|
||||||
|
idempotencyKey(key), input.template_id)).body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (result.success) return result.data as ReturnType<T['safeParse']> extends { data: infer Data } ? Data : never;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({ field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message })));
|
||||||
|
}
|
||||||
|
function uuid(field: string, value: string) {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]);
|
||||||
|
}
|
||||||
|
function idempotencyKey(value: string | undefined) {
|
||||||
|
if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key');
|
||||||
|
return uuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
function revision(value: string | undefined) {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match');
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { WatermarksController } from './watermarks.controller.js';
|
||||||
|
import { WatermarksService } from './watermarks.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WatermarksController],
|
||||||
|
providers: [IdempotencyService, WatermarksService],
|
||||||
|
})
|
||||||
|
export class WatermarksModule {}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CreateWatermarkTemplateRequest,
|
||||||
|
ListWatermarkTemplatesQuery,
|
||||||
|
UpdateWatermarkTemplateRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type TemplateRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'text' | 'image';
|
||||||
|
current_version: number;
|
||||||
|
created_at: Date | string;
|
||||||
|
updated_at: Date | string;
|
||||||
|
revision: number;
|
||||||
|
text_content: string | null;
|
||||||
|
image_asset_id: string | null;
|
||||||
|
font_code: string | null;
|
||||||
|
font_size_px: number | null;
|
||||||
|
opacity: string;
|
||||||
|
scale_ratio: string | null;
|
||||||
|
position: string;
|
||||||
|
};
|
||||||
|
type TemplateConfig = Record<string, unknown>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WatermarksService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async fonts() {
|
||||||
|
const fonts = await this.database.query<{
|
||||||
|
code: string;
|
||||||
|
display_name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT code, display_name FROM atlas.watermark_fonts
|
||||||
|
WHERE status = 'active' ORDER BY display_name, code`,
|
||||||
|
);
|
||||||
|
return fonts.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(workspaceId: string, query: ListWatermarkTemplatesQuery) {
|
||||||
|
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const rows = await tx.query<TemplateRow>(
|
||||||
|
`${templateSelect}
|
||||||
|
WHERE template.status = 'active' AND template.deleted_at IS NULL
|
||||||
|
AND ($1::timestamptz IS NULL OR (template.created_at, template.id) < ($1::timestamptz, $2::uuid))
|
||||||
|
ORDER BY template.created_at DESC, template.id DESC LIMIT $3`,
|
||||||
|
[
|
||||||
|
cursor?.createdAt ?? null,
|
||||||
|
cursor?.id ?? null,
|
||||||
|
query.limit + 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const items = rows.rows.slice(0, query.limit).map(toTemplate);
|
||||||
|
const last = items.at(-1);
|
||||||
|
return {
|
||||||
|
data: items,
|
||||||
|
meta: {
|
||||||
|
has_more: rows.rows.length > query.limit,
|
||||||
|
next_cursor:
|
||||||
|
rows.rows.length > query.limit && last
|
||||||
|
? encodeCursor(last.created_at, last.id)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(workspaceId: string, templateId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
|
this.requireTemplate(tx, templateId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
input: CreateWatermarkTemplateRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/watermark-templates',
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
await this.validateConfig(tx, input.kind, input.config);
|
||||||
|
const inserted = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||||
|
[input.name, input.kind],
|
||||||
|
);
|
||||||
|
await this.insertVersion(
|
||||||
|
tx,
|
||||||
|
inserted.rows[0].id,
|
||||||
|
1,
|
||||||
|
input.kind,
|
||||||
|
input.config,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: await this.requireTemplate(
|
||||||
|
tx,
|
||||||
|
inserted.rows[0].id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
templateId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
revision: number,
|
||||||
|
input: UpdateWatermarkTemplateRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PATCH',
|
||||||
|
path: `/api/watermark-templates/${templateId}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { if_match: revision, ...input },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const current = await this.lockTemplate(tx, templateId);
|
||||||
|
if (current.revision !== revision)
|
||||||
|
throw versionConflict(current.revision);
|
||||||
|
const nextKind = input.kind ?? current.kind;
|
||||||
|
if (
|
||||||
|
input.kind &&
|
||||||
|
input.kind !== current.kind &&
|
||||||
|
!input.config
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'切换水印类型时必须提交新类型完整配置',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
|
||||||
|
const nextConfig = normalizeConfig(nextKind, {
|
||||||
|
...configFromRow(current),
|
||||||
|
...(input.config ?? {}),
|
||||||
|
});
|
||||||
|
await this.validateConfig(tx, nextKind, nextConfig);
|
||||||
|
const renderingChanged =
|
||||||
|
input.config !== undefined || nextKind !== current.kind;
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.watermark_templates SET name = COALESCE($1, name), kind = $2,
|
||||||
|
current_version = current_version + CASE WHEN $3 THEN 1 ELSE 0 END,
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $4`,
|
||||||
|
[
|
||||||
|
input.name ?? null,
|
||||||
|
nextKind,
|
||||||
|
renderingChanged,
|
||||||
|
templateId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (renderingChanged) {
|
||||||
|
await this.insertVersion(
|
||||||
|
tx,
|
||||||
|
templateId,
|
||||||
|
current.current_version + 1,
|
||||||
|
nextKind,
|
||||||
|
nextConfig,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { data: await this.requireTemplate(tx, templateId) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async remove(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
templateId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/api/watermark-templates/${templateId}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
await this.lockTemplate(tx, templateId);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_watermark_bindings SET unbound_at = now()
|
||||||
|
WHERE template_id = $1 AND unbound_at IS NULL`,
|
||||||
|
[templateId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(),
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||||
|
[templateId],
|
||||||
|
);
|
||||||
|
return { status: 204, body: null };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async bindStore(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
templateId: string | null,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/api/stores/${storeId}/watermark-binding`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { template_id: templateId },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const store = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (!store.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '门店不存在');
|
||||||
|
if (templateId) await this.requireTemplate(tx, templateId);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_watermark_bindings SET unbound_at = now()
|
||||||
|
WHERE store_id = $1 AND unbound_at IS NULL`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (templateId) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_watermark_bindings
|
||||||
|
(workspace_id, store_id, template_id, bound_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||||||
|
[storeId, templateId, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = templateId
|
||||||
|
? await this.requireTemplate(tx, templateId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
watermark_template: result
|
||||||
|
? { id: result.id, name: result.name }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockTemplate(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
id: string,
|
||||||
|
): Promise<TemplateRow> {
|
||||||
|
const rows = await tx.query<TemplateRow>(
|
||||||
|
`${templateSelect} WHERE template.id = $1
|
||||||
|
AND template.status = 'active' AND template.deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireTemplate(tx: DatabaseTransaction, id: string) {
|
||||||
|
const rows = await tx.query<TemplateRow>(
|
||||||
|
`${templateSelect} WHERE template.id = $1
|
||||||
|
AND template.status = 'active' AND template.deleted_at IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
|
||||||
|
return toTemplate(rows.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateConfig(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
kind: 'text' | 'image',
|
||||||
|
config: TemplateConfig,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof config.opacity !== 'string' ||
|
||||||
|
typeof config.position !== 'string'
|
||||||
|
) {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
|
||||||
|
}
|
||||||
|
if (kind === 'text') {
|
||||||
|
const fontCode = config.font_code;
|
||||||
|
if (
|
||||||
|
typeof fontCode !== 'string' ||
|
||||||
|
typeof config.text_content !== 'string' ||
|
||||||
|
typeof config.font_size_px !== 'number'
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'文字水印配置不完整',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const font = await tx.query(
|
||||||
|
`SELECT code FROM atlas.watermark_fonts WHERE code = $1 AND status = 'active'`,
|
||||||
|
[fontCode],
|
||||||
|
);
|
||||||
|
if (!font.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'WATERMARK_FONT_UNAVAILABLE',
|
||||||
|
'所选字体不可用',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assetId = config.image_asset_id;
|
||||||
|
if (
|
||||||
|
typeof assetId !== 'string' ||
|
||||||
|
typeof config.scale_ratio !== 'string'
|
||||||
|
) {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
|
||||||
|
}
|
||||||
|
const asset = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE id = $1
|
||||||
|
AND content_type IN ('image/png', 'image/jpeg', 'image/webp')`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!asset.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'WATERMARK_IMAGE_UNAVAILABLE',
|
||||||
|
'水印图片不存在或不属于当前工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertVersion(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
templateId: string,
|
||||||
|
version: number,
|
||||||
|
kind: 'text' | 'image',
|
||||||
|
config: TemplateConfig,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.watermark_template_versions
|
||||||
|
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
templateId,
|
||||||
|
version,
|
||||||
|
kind,
|
||||||
|
config.text_content ?? null,
|
||||||
|
config.image_asset_id ?? null,
|
||||||
|
config.font_code ?? null,
|
||||||
|
config.font_size_px ?? null,
|
||||||
|
config.opacity,
|
||||||
|
config.scale_ratio ?? null,
|
||||||
|
config.position,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version,
|
||||||
|
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
|
||||||
|
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
|
||||||
|
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
|
||||||
|
ON version.template_id = template.id AND version.version = template.current_version`;
|
||||||
|
|
||||||
|
function toTemplate(row: TemplateRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
kind: row.kind,
|
||||||
|
current_version: row.current_version,
|
||||||
|
config: configFromRow(row),
|
||||||
|
created_at: iso(row.created_at),
|
||||||
|
updated_at: iso(row.updated_at),
|
||||||
|
revision: row.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function configFromRow(row: TemplateRow): TemplateConfig {
|
||||||
|
return row.kind === 'text'
|
||||||
|
? {
|
||||||
|
text_content: row.text_content,
|
||||||
|
font_code: row.font_code,
|
||||||
|
font_size_px: row.font_size_px,
|
||||||
|
opacity: row.opacity,
|
||||||
|
position: row.position,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
image_asset_id: row.image_asset_id,
|
||||||
|
scale_ratio: row.scale_ratio,
|
||||||
|
opacity: row.opacity,
|
||||||
|
position: row.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function normalizeConfig(
|
||||||
|
kind: 'text' | 'image',
|
||||||
|
config: TemplateConfig,
|
||||||
|
): TemplateConfig {
|
||||||
|
return kind === 'text'
|
||||||
|
? {
|
||||||
|
text_content: config.text_content,
|
||||||
|
font_code: config.font_code,
|
||||||
|
font_size_px: config.font_size_px,
|
||||||
|
opacity: config.opacity,
|
||||||
|
position: config.position,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
image_asset_id: config.image_asset_id,
|
||||||
|
scale_ratio: config.scale_ratio,
|
||||||
|
opacity: config.opacity,
|
||||||
|
position: config.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function iso(value: Date | string) {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
function versionConflict(currentRevision: number) {
|
||||||
|
return new ApiError(409, 'VERSION_CONFLICT', '水印模板已被其他操作更新', [
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${currentRevision}`,
|
||||||
|
current_revision: currentRevision,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function encodeCursor(createdAt: string, id: string) {
|
||||||
|
return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url');
|
||||||
|
}
|
||||||
|
function decodeCursor(cursor: string): { createdAt: string; id: string } {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
) as { createdAt?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
typeof value.createdAt !== 'string' ||
|
||||||
|
Number.isNaN(Date.parse(value.createdAt)) ||
|
||||||
|
typeof value.id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(value.id)
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
|
return { createdAt: value.createdAt, id: value.id };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication; let database: Database; let baseUrl: string; let userId = ''; let workspaceId = ''; let token = ''; let platformId = '';
|
||||||
|
async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init.headers } }); return { response, body: response.status === 204 ? undefined : await response.json() as any }; }
|
||||||
|
function headers(extra: Record<string, string> = {}) { return { Authorization: `Bearer ${token}`, ...extra }; }
|
||||||
|
describe('采集箱 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false', AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100' }); const application = await createApplication(); app = application.app; database = app.get<Database>(DATABASE); await app.listen(0, '127.0.0.1'); baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; const registration = await request('/api/auth/register', { method: 'POST', body: JSON.stringify({ email: `collection-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123' }) }); assert.equal(registration.response.status, 201); token = registration.body.data.access_token; userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; const platforms = await request('/api/platforms', { headers: headers() }); platformId = platforms.body.data[0].id; });
|
||||||
|
afterAll(async () => { await database.workspaceTransaction(workspaceId, async (tx) => { await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); }); await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); await app.close(); });
|
||||||
|
it('创建、查询、乐观锁更新与软删除采集草稿', async () => { const key = randomUUID(); const created = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.equal(created.response.status, 201); const item = created.body.data.collection_item; assert.equal(item.variant_summary.count, 1); const replay = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.deepEqual(replay.body, created.body); const listed = await request('/api/collection-items', { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }); assert.equal(listed.response.status, 200); assert.equal(listed.body.data[0].id, item.id); const updated = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '已修改' }) }); assert.equal(updated.response.status, 200); const stale = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '过期写入' }) }); assert.equal(stale.response.status, 409); const removed = await request('/api/collection-items/bulk-delete', { method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: JSON.stringify({ ids: [item.id] }) }); assert.equal(removed.response.status, 202); assert.equal(removed.body.data.deleted_count, 1); });
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
"antd": "6.6.2",
|
"antd": "6.6.2",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-router-dom": "7.18.3"
|
"react-router-dom": "7.18.3"
|
||||||
|
|||||||
@@ -86,3 +86,90 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 水印管理 ── */
|
||||||
|
.watermarks-load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3x3 水印位置九宫格 */
|
||||||
|
.position-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 44px);
|
||||||
|
grid-auto-rows: 44px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-cell {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-cell:hover {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-cell.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 水印预览:模拟透明底图的棋盘格 */
|
||||||
|
.watermark-preview {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-image: conic-gradient(#ececec 0 25%, #ffffff 0 50%, #ececec 0 75%, #ffffff 0);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 水印图片上传框 ── */
|
||||||
|
.watermark-image-box {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-image-box:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-image-box img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-image-uploading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import type { WatermarkPosition } from '@atlas/contracts';
|
||||||
|
import type { WatermarkTemplateConfig } from '@atlas/contracts';
|
||||||
|
|
||||||
|
/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */
|
||||||
|
export const WATERMARK_POSITION_OPTIONS: Array<{
|
||||||
|
value: WatermarkPosition;
|
||||||
|
label: string;
|
||||||
|
row: 0 | 1 | 2;
|
||||||
|
col: 0 | 1 | 2;
|
||||||
|
}> = [
|
||||||
|
{ value: 'top_left', label: '左上角', row: 0, col: 0 },
|
||||||
|
{ value: 'top_center', label: '上居中', row: 0, col: 1 },
|
||||||
|
{ value: 'top_right', label: '右上角', row: 0, col: 2 },
|
||||||
|
{ value: 'middle_left', label: '左居中', row: 1, col: 0 },
|
||||||
|
{ value: 'center', label: '正居中', row: 1, col: 1 },
|
||||||
|
{ value: 'middle_right', label: '右居中', row: 1, col: 2 },
|
||||||
|
{ value: 'bottom_left', label: '左下角', row: 2, col: 0 },
|
||||||
|
{ value: 'bottom_center', label: '下居中', row: 2, col: 1 },
|
||||||
|
{ value: 'bottom_right', label: '右下角', row: 2, col: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const watermarkPositionLabel = (position: string): string =>
|
||||||
|
WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position;
|
||||||
|
|
||||||
|
const POSITION_FLEX: Record<
|
||||||
|
WatermarkPosition,
|
||||||
|
Pick<CSSProperties, 'justifyContent' | 'alignItems'>
|
||||||
|
> = {
|
||||||
|
top_left: { justifyContent: 'flex-start', alignItems: 'flex-start' },
|
||||||
|
top_center: { justifyContent: 'center', alignItems: 'flex-start' },
|
||||||
|
top_right: { justifyContent: 'flex-end', alignItems: 'flex-start' },
|
||||||
|
middle_left: { justifyContent: 'flex-start', alignItems: 'center' },
|
||||||
|
center: { justifyContent: 'center', alignItems: 'center' },
|
||||||
|
middle_right: { justifyContent: 'flex-end', alignItems: 'center' },
|
||||||
|
bottom_left: { justifyContent: 'flex-start', alignItems: 'flex-end' },
|
||||||
|
bottom_center: { justifyContent: 'center', alignItems: 'flex-end' },
|
||||||
|
bottom_right: { justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WatermarkPreviewProps {
|
||||||
|
/** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */
|
||||||
|
config: Partial<WatermarkTemplateConfig>;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1),
|
||||||
|
* 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。
|
||||||
|
*/
|
||||||
|
const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => {
|
||||||
|
const [imageBroken, setImageBroken] = useState(false);
|
||||||
|
const position = config.position ?? 'bottom_right';
|
||||||
|
const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right;
|
||||||
|
const previewUrl = config.image_asset?.preview_url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="watermark-preview"
|
||||||
|
style={{ width, height, ...flex, opacity: Number(config.opacity ?? '1') }}
|
||||||
|
>
|
||||||
|
{config.image_asset ? (
|
||||||
|
imageBroken || !previewUrl ? (
|
||||||
|
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="水印图片"
|
||||||
|
style={{
|
||||||
|
// scale_ratio 以底图短边为基准的视觉近似:1.0 ≈ 底图宽度的 60%
|
||||||
|
width: Math.round(Number(config.scale_ratio ?? '1') * width * 0.6),
|
||||||
|
maxWidth: '80%',
|
||||||
|
}}
|
||||||
|
onError={() => setImageBroken(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: config.font_size_px ?? 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#111827',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textShadow: '0 0 2px rgba(255,255,255,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.text_content || '文字水印'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WatermarkPreview;
|
||||||
@@ -5,12 +5,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background:
|
background:
|
||||||
<<<<<<< HEAD
|
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%);
|
||||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
|
||||||
======= radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
|
||||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
|
||||||
>>>>>>> feat-0.2.0 var(--bg-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-panel {
|
.auth-panel {
|
||||||
|
|||||||
@@ -8,13 +8,7 @@ import { usePlatform } from '@/services/platform';
|
|||||||
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
||||||
*/
|
*/
|
||||||
const PlatformSelector = () => {
|
const PlatformSelector = () => {
|
||||||
const {
|
const { platforms, currentPlatform, selectPlatform, context, contextLoading } = usePlatform();
|
||||||
platforms,
|
|
||||||
currentPlatform,
|
|
||||||
selectPlatform,
|
|
||||||
context,
|
|
||||||
contextLoading,
|
|
||||||
} = usePlatform();
|
|
||||||
|
|
||||||
if (platforms.length === 0) return null;
|
if (platforms.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { App as AntdApp, Button, Card, Form, Input, Modal, Progress, Select, Space, Table, Tag, Typography } from 'antd';
|
||||||
|
import type { TableProps } from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import type { CollectionItem } from '@atlas/contracts';
|
||||||
|
import { collectionApi } from '@/services/collection';
|
||||||
|
import { apiErrorMessage } from '@/services/api';
|
||||||
|
import { usePlatform } from '@/services/platform';
|
||||||
|
|
||||||
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
|
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
|
||||||
const CollectionPage = () => (
|
export default function CollectionPage() {
|
||||||
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
|
const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const [items, setItems] = useState<CollectionItem[]>([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState<string[]>([]); const [q, setQ] = useState(''); const [source, setSource] = useState<string>(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>();
|
||||||
);
|
const load = useCallback(async () => { if (!currentPlatform) return; setLoading(true); try { const result = await collectionApi.list(currentPlatform.id, { ...(q.trim() ? { q: q.trim() } : {}), ...(source ? { source_platform_code: source as 'OZON' } : {}) }); setItems(result.data); setMeta(result.meta); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } }, [currentPlatform, message, q, source]);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
export default CollectionPage;
|
const create = async (values: { title?: string; note?: string }) => { try { await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); await load(); } catch (error) { message.error(apiErrorMessage(error)); } };
|
||||||
|
const remove = (ids: string[], all = false) => modal.confirm({ title: all ? '清空采集箱' : '删除采集商品', content: all ? `将清空当前 ${meta.active_count} 条采集草稿,不会删除或下架任何平台商品。确定继续吗?` : '仅删除 Atlas 内的采集草稿,不会删除或下架任何平台商品。确定继续吗?', okText: all ? '清空' : '删除', okButtonProps: { danger: true }, onOk: async () => { try { const result = all ? await collectionApi.clear(meta.active_count) : await collectionApi.bulkDelete(ids); message.success(`已删除 ${result.deleted_count} 条采集草稿`); setSelected([]); await load(); } catch (error) { message.error(apiErrorMessage(error)); } } });
|
||||||
|
const columns: TableProps<CollectionItem>['columns'] = [
|
||||||
|
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => message.info('商品编辑将在下一任务提供')}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
|
||||||
|
];
|
||||||
|
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
|
||||||
|
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} />
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /><Select allowClear placeholder="采集来源" value={source} onChange={setSource} style={{ width: 140 }} options={[{ value: 'OZON', label: 'Ozon' }, { value: '1688', label: '1688' }, { value: 'TAOBAO', label: '淘宝' }, { value: 'TMALL', label: '天猫' }]} /><Button type="primary" onClick={() => void load()}>查询</Button></Space>
|
||||||
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => setSelected(keys.map(String)) }} locale={{ emptyText: '暂无采集商品,点击右上角手动创建商品' }} />
|
||||||
|
<Modal title="手动创建商品" open={createOpen} footer={null} onCancel={() => setCreateOpen(false)} destroyOnHidden><Form form={form} layout="vertical" onFinish={create}><Form.Item name="title" label="商品名称"><Input maxLength={300} placeholder="可稍后在商品编辑中完善" /></Form.Item><Form.Item name="note" label="备注"><Input.TextArea maxLength={2000} /></Form.Item><Space><Button type="primary" htmlType="submit">创建草稿</Button><Button onClick={() => setCreateOpen(false)}>取消</Button></Space></Form></Modal>
|
||||||
|
</Card>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
App as AntdApp,
|
App as AntdApp,
|
||||||
Button,
|
Button,
|
||||||
@@ -24,12 +24,13 @@ import {
|
|||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
WarningFilled,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { Store } from '@atlas/contracts';
|
import type { Store, WatermarkTemplate } from '@atlas/contracts';
|
||||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||||
import { storesApi } from '@/services/stores';
|
import { storesApi } from '@/services/stores';
|
||||||
|
import { watermarksApi } from '@/services/watermarks';
|
||||||
|
import { operationsApi, OPERATION_TERMINAL_STATUSES } from '@/services/operations';
|
||||||
import { usePlatform } from '@/services/platform';
|
import { usePlatform } from '@/services/platform';
|
||||||
import './ShopsPage.css';
|
import './ShopsPage.css';
|
||||||
|
|
||||||
@@ -42,20 +43,62 @@ const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
|||||||
deleted: { color: 'default', text: '已删除' },
|
deleted: { color: 'default', text: '已删除' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const CREDENTIAL_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
interface StoreStatusView {
|
||||||
unknown: { color: 'default', text: '未验证' },
|
color: string;
|
||||||
valid: { color: 'green', text: '凭据有效' },
|
text: string;
|
||||||
invalid: { color: 'red', text: '凭据无效' },
|
/** 状态附注,如「等待平台验证结果」或凭据验证的脱敏原因 */
|
||||||
expired: { color: 'red', text: '已过期' },
|
note?: string;
|
||||||
rotation_required: { color: 'gold', text: '待轮换' },
|
}
|
||||||
};
|
|
||||||
|
/**
|
||||||
|
* 把门店生命周期状态(status)与凭据状态(credential_status)派生为单一展示状态,
|
||||||
|
* 避免两个内部状态并列渲染造成「验证中 + 未验证」的误导:
|
||||||
|
* - verifying(或凭据仍 unknown,Atlas 尚未拿到验证结果):只显示「验证中」并附注
|
||||||
|
* - active 且凭据 valid:正常
|
||||||
|
* - 凭据验证失败:401 → 凭据无效;403 → 访问被拒绝(提示检查 Client ID/API Key 归属与权限)
|
||||||
|
* - 同步异常:直接展示 last_error.message——HTTP 400/404/422、超时/429/5xx 等
|
||||||
|
* 验证请求失败不代表 API Key 无效(OZON_VERIFICATION_UNAVAILABLE 的消息即「暂时无法验证」)
|
||||||
|
* 未穷举的组合按凭据状态(过期/待轮换)或生命周期兜底。
|
||||||
|
*/
|
||||||
|
function deriveStoreStatusView(store: Store): StoreStatusView {
|
||||||
|
const { status, credential_status: credential, last_error } = store;
|
||||||
|
if (status === 'verifying' || credential === 'unknown') {
|
||||||
|
return { color: 'gold', text: '验证中', note: '等待平台验证结果' };
|
||||||
|
}
|
||||||
|
if (status === 'credential_invalid' || credential === 'invalid') {
|
||||||
|
if (last_error?.code === 'OZON_CREDENTIAL_FORBIDDEN') {
|
||||||
|
return {
|
||||||
|
color: 'red',
|
||||||
|
text: '访问被拒绝',
|
||||||
|
note: '请检查 Client ID、API Key 是否同账户,以及密钥是否有读取权限',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { color: 'red', text: '凭据无效' };
|
||||||
|
}
|
||||||
|
if (status === 'sync_error') {
|
||||||
|
return {
|
||||||
|
color: 'orange',
|
||||||
|
text: '同步异常',
|
||||||
|
note: last_error?.message ?? '同步过程出现异常,可重新发起仓库同步或凭据验证',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === 'active' && credential === 'valid') {
|
||||||
|
return { color: 'green', text: '正常' };
|
||||||
|
}
|
||||||
|
if (credential === 'expired') {
|
||||||
|
return { color: 'red', text: '凭据已过期' };
|
||||||
|
}
|
||||||
|
if (credential === 'rotation_required') {
|
||||||
|
return { color: 'gold', text: '凭据待轮换' };
|
||||||
|
}
|
||||||
|
const fallback = STORE_STATUS_MAP[status];
|
||||||
|
return fallback ?? { color: 'default', text: status };
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string | null): string {
|
function formatDateTime(value: string | null): string {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime())
|
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false });
|
||||||
? '—'
|
|
||||||
: date.toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateFormValues {
|
interface CreateFormValues {
|
||||||
@@ -72,6 +115,7 @@ interface EditFormValues {
|
|||||||
currency_code: string;
|
currency_code: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
api_key?: string;
|
api_key?: string;
|
||||||
|
watermark_template_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +147,10 @@ const ShopsPage = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editForm] = Form.useForm<EditFormValues>();
|
const [editForm] = Form.useForm<EditFormValues>();
|
||||||
|
|
||||||
|
// 水印模板选项(工作空间全量活动模板,量小一次拉取)
|
||||||
|
const [watermarkTemplates, setWatermarkTemplates] = useState<WatermarkTemplate[]>([]);
|
||||||
|
const bindingBeforeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const platformId = currentPlatform?.id ?? null;
|
const platformId = currentPlatform?.id ?? null;
|
||||||
const currencyOptions = useMemo(
|
const currencyOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -148,6 +196,14 @@ const ShopsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 水印模板选项与平台无关(工作空间全量),挂载后拉取一次
|
||||||
|
useEffect(() => {
|
||||||
|
void watermarksApi
|
||||||
|
.list()
|
||||||
|
.then((result) => setWatermarkTemplates(result.data))
|
||||||
|
.catch(() => setWatermarkTemplates([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
/** 任何变更后同时刷新列表与页头默认门店摘要 */
|
/** 任何变更后同时刷新列表与页头默认门店摘要 */
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
if (!platformId) return;
|
if (!platformId) return;
|
||||||
@@ -155,9 +211,45 @@ const ShopsPage = () => {
|
|||||||
await refreshContext();
|
await refreshContext();
|
||||||
}, [platformId, loadStores, refreshContext]);
|
}, [platformId, loadStores, refreshContext]);
|
||||||
|
|
||||||
|
// 组件卸载后停止轮询
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询异步验证/同步操作至终态,随后刷新列表。
|
||||||
|
* 页面最终状态以刷新后的店铺资源为准(成败原因看店铺 last_error),不以 operation 为准;
|
||||||
|
* 超时未终态则停止轮询并刷新一次,用户可手动刷新继续观察。
|
||||||
|
*/
|
||||||
|
const pollOperation = useCallback(
|
||||||
|
async (operationId: string) => {
|
||||||
|
const intervalMs = 2500;
|
||||||
|
const maxAttempts = 40;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
try {
|
||||||
|
const operation = await operationsApi.get(operationId);
|
||||||
|
if (OPERATION_TERMINAL_STATUSES.has(operation.status)) break;
|
||||||
|
} catch {
|
||||||
|
// 单次查询失败(网络抖动等)忽略,继续下一轮
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mountedRef.current) await refreshAll();
|
||||||
|
},
|
||||||
|
[refreshAll]
|
||||||
|
);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({ is_default: stores.length === 0 });
|
createForm.setFieldsValue({
|
||||||
|
is_default: stores.length === 0,
|
||||||
|
currency_code: currentPlatform?.default_currency_code ?? currencyOptions[0]?.value,
|
||||||
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +257,7 @@ const ShopsPage = () => {
|
|||||||
if (!currentPlatform) return;
|
if (!currentPlatform) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await storesApi.create({
|
const result = await storesApi.create({
|
||||||
platform_id: currentPlatform.id,
|
platform_id: currentPlatform.id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
client_id: values.client_id.trim(),
|
client_id: values.client_id.trim(),
|
||||||
@@ -174,9 +266,11 @@ const ShopsPage = () => {
|
|||||||
is_default: values.is_default,
|
is_default: values.is_default,
|
||||||
note: values.note?.trim() ? values.note.trim() : null,
|
note: values.note?.trim() ? values.note.trim() : null,
|
||||||
});
|
});
|
||||||
message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
|
message.success('店铺已创建,正在验证凭据,结果将自动刷新');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
// 轮询验证操作至终态后自动刷新,无需手动刷新页面
|
||||||
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(apiErrorMessage(err));
|
message.error(apiErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,19 +280,25 @@ const ShopsPage = () => {
|
|||||||
|
|
||||||
const openEdit = (store: Store) => {
|
const openEdit = (store: Store) => {
|
||||||
setEditingStore(store);
|
setEditingStore(store);
|
||||||
|
bindingBeforeRef.current = store.watermark_template?.id ?? null;
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
name: store.name,
|
name: store.name,
|
||||||
currency_code: store.currency_code,
|
currency_code: store.currency_code,
|
||||||
note: store.note ?? '',
|
note: store.note ?? '',
|
||||||
|
watermark_template_id: store.watermark_template?.id ?? null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSave = async (values: EditFormValues) => {
|
const handleEditSave = async (values: EditFormValues) => {
|
||||||
if (!editingStore) return;
|
if (!editingStore) return;
|
||||||
|
const storeId = editingStore.id;
|
||||||
|
const bindingBefore = bindingBeforeRef.current;
|
||||||
|
const bindingAfter = values.watermark_template_id ?? null;
|
||||||
|
const bindingChanged = bindingBefore !== bindingAfter;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await storesApi.update(
|
const result = await storesApi.update(
|
||||||
editingStore.id,
|
storeId,
|
||||||
{
|
{
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
currency_code: values.currency_code,
|
currency_code: values.currency_code,
|
||||||
@@ -208,13 +308,20 @@ const ShopsPage = () => {
|
|||||||
},
|
},
|
||||||
{ revision: editingStore.revision }
|
{ revision: editingStore.revision }
|
||||||
);
|
);
|
||||||
if (result && 'operation' in result) {
|
// 水印绑定是独立接口,仅在与打开弹窗时不同才提交
|
||||||
message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
|
if (bindingChanged) {
|
||||||
|
await storesApi.bindWatermark(storeId, bindingAfter);
|
||||||
|
}
|
||||||
|
if (result && 'operation' in result && result.operation) {
|
||||||
|
message.success('已保存,正在验证新密钥,结果将自动刷新');
|
||||||
|
setEditingStore(null);
|
||||||
|
await refreshAll();
|
||||||
|
void pollOperation(result.operation.id);
|
||||||
} else {
|
} else {
|
||||||
message.success('店铺已保存');
|
message.success('店铺已保存');
|
||||||
|
setEditingStore(null);
|
||||||
|
await refreshAll();
|
||||||
}
|
}
|
||||||
setEditingStore(null);
|
|
||||||
await refreshAll();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
|
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
@@ -249,14 +356,16 @@ const ShopsPage = () => {
|
|||||||
|
|
||||||
const handleVerify = (store: Store) =>
|
const handleVerify = (store: Store) =>
|
||||||
withPending(store, async () => {
|
withPending(store, async () => {
|
||||||
await storesApi.requestCredentialVerification(store.id);
|
const result = await storesApi.requestCredentialVerification(store.id);
|
||||||
message.success('已发起凭据验证,稍后可刷新查看结果');
|
message.success('已发起凭据验证,结果将自动刷新');
|
||||||
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSyncWarehouses = (store: Store) =>
|
const handleSyncWarehouses = (store: Store) =>
|
||||||
withPending(store, async () => {
|
withPending(store, async () => {
|
||||||
await storesApi.requestWarehouseSync(store.id);
|
const result = await storesApi.requestWarehouseSync(store.id);
|
||||||
message.success('已发起仓库同步,稍后可刷新查看结果');
|
message.success('已发起仓库同步,结果将自动刷新');
|
||||||
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = (store: Store) => {
|
const handleDelete = (store: Store) => {
|
||||||
@@ -336,24 +445,18 @@ const ShopsPage = () => {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, store) => {
|
render: (_, store) => {
|
||||||
const status = STORE_STATUS_MAP[store.status] ?? {
|
const view = deriveStoreStatusView(store);
|
||||||
color: 'default',
|
|
||||||
text: store.status,
|
|
||||||
};
|
|
||||||
const credential = CREDENTIAL_STATUS_MAP[store.credential_status] ?? {
|
|
||||||
color: 'default',
|
|
||||||
text: store.credential_status,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color={status.color}>{status.text}</Tag>
|
<Tag color={view.color}>{view.text}</Tag>
|
||||||
<Tag color={credential.color}>{credential.text}</Tag>
|
{view.note && (
|
||||||
{store.last_error && (
|
<Typography.Text
|
||||||
<Tooltip
|
type="secondary"
|
||||||
title={`${store.last_error.code}:${store.last_error.message}`}
|
style={{ fontSize: 12, maxWidth: 180 }}
|
||||||
|
ellipsis={{ tooltip: view.note }}
|
||||||
>
|
>
|
||||||
<WarningFilled style={{ color: '#faad14' }} />
|
{view.note}
|
||||||
</Tooltip>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
@@ -402,9 +505,7 @@ const ShopsPage = () => {
|
|||||||
dataIndex: 'updated_at',
|
dataIndex: 'updated_at',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (value: string) => (
|
render: (value: string) => (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">{formatDateTime(value)}</Typography.Text>
|
||||||
{formatDateTime(value)}
|
|
||||||
</Typography.Text>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -497,11 +598,7 @@ const ShopsPage = () => {
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
>
|
>
|
||||||
<Form<CreateFormValues>
|
<Form<CreateFormValues> form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||||
form={createForm}
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={handleCreate}
|
|
||||||
>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label="店铺名称"
|
label="店铺名称"
|
||||||
@@ -514,7 +611,9 @@ const ShopsPage = () => {
|
|||||||
name="client_id"
|
name="client_id"
|
||||||
label="Client ID"
|
label="Client ID"
|
||||||
required
|
required
|
||||||
rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]}
|
rules={[
|
||||||
|
{ required: true, whitespace: true, message: '请输入平台 Client ID' },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input maxLength={120} placeholder="平台后台的 Client ID" />
|
<Input maxLength={120} placeholder="平台后台的 Client ID" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -522,7 +621,9 @@ const ShopsPage = () => {
|
|||||||
name="api_key"
|
name="api_key"
|
||||||
label="API Key"
|
label="API Key"
|
||||||
required
|
required
|
||||||
rules={[{ required: true, whitespace: true, message: '请输入平台 API Key' }]}
|
rules={[
|
||||||
|
{ required: true, whitespace: true, message: '请输入平台 API Key' },
|
||||||
|
]}
|
||||||
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
|
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
|
||||||
>
|
>
|
||||||
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
|
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
|
||||||
@@ -540,11 +641,7 @@ const ShopsPage = () => {
|
|||||||
>
|
>
|
||||||
<Select options={currencyOptions} placeholder="选择币种" />
|
<Select options={currencyOptions} placeholder="选择币种" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="is_default" label="设为默认店铺" valuePropName="checked">
|
||||||
name="is_default"
|
|
||||||
label="设为默认店铺"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -608,6 +705,21 @@ const ShopsPage = () => {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="watermark_template_id"
|
||||||
|
label="水印模板"
|
||||||
|
extra="作为商品图片编辑时的推荐水印;可随时更换或清空"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="暂不绑定"
|
||||||
|
notFoundContent="暂无水印模板,可在水印管理页创建"
|
||||||
|
options={watermarkTemplates.map((template) => ({
|
||||||
|
value: template.id,
|
||||||
|
label: template.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="Client ID">
|
<Form.Item label="Client ID">
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
{editingStore.platform_client_id}(平台与账户不可修改)
|
{editingStore.platform_client_id}(平台与账户不可修改)
|
||||||
|
|||||||
@@ -1,8 +1,990 @@
|
|||||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
import dayjs from 'dayjs';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
App as AntdApp,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Slider,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableProps, UploadFile } from 'antd';
|
||||||
|
import {
|
||||||
|
FileImageOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RestOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
UpdateWatermarkTemplateRequest,
|
||||||
|
WatermarkTemplate,
|
||||||
|
WatermarkTemplateConfig,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import WatermarkPreview, {
|
||||||
|
WATERMARK_POSITION_OPTIONS,
|
||||||
|
watermarkPositionLabel,
|
||||||
|
} from '@/components/WatermarkPreview';
|
||||||
|
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||||
|
import { watermarksApi } from '@/services/watermarks';
|
||||||
|
import { mediaApi } from '@/services/media';
|
||||||
|
import {
|
||||||
|
uploadWatermarkImage,
|
||||||
|
WATERMARK_IMAGE_ACCEPT,
|
||||||
|
WATERMARK_IMAGE_MAX_BYTES,
|
||||||
|
} from '@/services/media';
|
||||||
|
|
||||||
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
|
type WatermarkPositionValue = (typeof WATERMARK_POSITION_OPTIONS)[number]['value'];
|
||||||
const WatermarksPage = () => (
|
type WatermarkKind = 'text' | 'image';
|
||||||
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
|
|
||||||
);
|
interface WatermarkFormValues {
|
||||||
|
name: string;
|
||||||
|
kind: WatermarkKind;
|
||||||
|
text_content: string;
|
||||||
|
font_code: string;
|
||||||
|
font_size_px: number;
|
||||||
|
opacityPercent: number;
|
||||||
|
scalePercent: number;
|
||||||
|
position: WatermarkPositionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KindFilter = 'all' | 'text' | 'image';
|
||||||
|
|
||||||
|
const KIND_TAG: Record<string, { color: string; text: string }> = {
|
||||||
|
image: { color: 'green', text: '图片水印' },
|
||||||
|
text: { color: 'blue', text: '文字水印' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentToOpacity = (percent: number): string => {
|
||||||
|
const value = percent / 100;
|
||||||
|
return value === 0 ? '0' : value === 1 ? '1' : String(Number(value.toFixed(4)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentToScale = (percent: number): string => {
|
||||||
|
const value = percent / 100;
|
||||||
|
return value === 2 ? '2' : String(Number(value.toFixed(4)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value: string): string =>
|
||||||
|
new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 水印管理页,契约见 docs/api/watermarks-and-assets.md。
|
||||||
|
* 列表接口只有 cursor/limit 参数,类型/名称/日期筛选为纯客户端过滤;
|
||||||
|
* 图片水印经三步直传创建素材(services/media.ts),编辑弹窗按 config 实时渲染预览。
|
||||||
|
*/
|
||||||
|
const WatermarksPage = () => {
|
||||||
|
const { message, modal } = AntdApp.useApp();
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<WatermarkTemplate[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const [fonts, setFonts] = useState<Array<{ code: string; display_name: string }>>([]);
|
||||||
|
|
||||||
|
// 筛选:输入草稿 + 点击「查询」后生效的已应用条件(纯客户端过滤)
|
||||||
|
const [kindDraft, setKindDraft] = useState<KindFilter>('all');
|
||||||
|
const [nameDraft, setNameDraft] = useState('');
|
||||||
|
const [dateDraft, setDateDraft] = useState<[string, string] | null>(null);
|
||||||
|
const [kindFilter, setKindFilter] = useState<KindFilter>('all');
|
||||||
|
const [nameFilter, setNameFilter] = useState('');
|
||||||
|
const [dateFilter, setDateFilter] = useState<[string, string] | null>(null);
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<WatermarkTemplate | null>(null);
|
||||||
|
const [previewing, setPreviewing] = useState<WatermarkTemplate | null>(null);
|
||||||
|
const [form] = Form.useForm<WatermarkFormValues>();
|
||||||
|
|
||||||
|
// 图片水印:已上传(或既有)的素材;previewUrl 用于本地/短时预览
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [imageAsset, setImageAsset] = useState<{ id: string; previewUrl: string } | null>(null);
|
||||||
|
|
||||||
|
// 图片素材短时预览 URL 缓存(列表不返回 preview_url,按需经 access-urls 换取)
|
||||||
|
const [assetPreviewUrls, setAssetPreviewUrls] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 实时预览:随渲染相关字段变化
|
||||||
|
const [previewValues, setPreviewValues] = useState<WatermarkFormValues | null>(null);
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(
|
||||||
|
async (cursor?: string) => {
|
||||||
|
if (!cursor) setLoading(true);
|
||||||
|
else setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const result = await watermarksApi.list(cursor);
|
||||||
|
setTemplates((prev) => (cursor ? [...prev, ...result.data] : result.data));
|
||||||
|
setNextCursor(result.meta.has_more ? result.meta.next_cursor : null);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(apiErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[message]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTemplates();
|
||||||
|
void watermarksApi
|
||||||
|
.fonts()
|
||||||
|
.then(setFonts)
|
||||||
|
.catch(() => setFonts([]));
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
|
// 为图片模板按需换取短时预览 URL(缓存后 10 分钟内复用)
|
||||||
|
useEffect(() => {
|
||||||
|
const missing = templates.filter(
|
||||||
|
(template) =>
|
||||||
|
template.kind === 'image' &&
|
||||||
|
template.config.image_asset &&
|
||||||
|
!assetPreviewUrls[template.config.image_asset.id]
|
||||||
|
);
|
||||||
|
for (const template of missing) {
|
||||||
|
const assetId = template.config.image_asset!.id;
|
||||||
|
mediaApi
|
||||||
|
.createPreviewUrl(assetId)
|
||||||
|
.then(({ url }) => setAssetPreviewUrls((prev) => ({ ...prev, [assetId]: url })))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [templates, assetPreviewUrls]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板列表不承诺始终带预览 URL;编辑既有图片模板时按需换取新的短时地址。
|
||||||
|
* 这样不会把对象存储 URL 写入模板数据,也避免打开编辑框时只显示空占位。
|
||||||
|
*/
|
||||||
|
const loadAssetPreviewUrl = useCallback(
|
||||||
|
async (assetId: string): Promise<string> => {
|
||||||
|
const cached = assetPreviewUrls[assetId];
|
||||||
|
if (cached) return cached;
|
||||||
|
const access = await mediaApi.createPreviewUrl(assetId);
|
||||||
|
setAssetPreviewUrls((previous) => ({ ...previous, [assetId]: access.url }));
|
||||||
|
return access.url;
|
||||||
|
},
|
||||||
|
[assetPreviewUrls]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
templates.filter((template) => {
|
||||||
|
if (kindFilter !== 'all' && template.kind !== kindFilter) return false;
|
||||||
|
if (nameFilter && !template.name.includes(nameFilter.trim())) return false;
|
||||||
|
if (dateFilter) {
|
||||||
|
const created = template.created_at.slice(0, 10);
|
||||||
|
const [start, end] = dateFilter;
|
||||||
|
if (start && created < start) return false;
|
||||||
|
if (end && created > end) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[templates, kindFilter, nameFilter, dateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 创建/编辑成功后就地更新列表数据(服务端返回完整模板资源) */
|
||||||
|
const applyTemplate = (template: WatermarkTemplate) => {
|
||||||
|
setTemplates((prev) => {
|
||||||
|
const index = prev.findIndex((item) => item.id === template.id);
|
||||||
|
if (index === -1) return [template, ...prev];
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = template;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setImageAsset(null);
|
||||||
|
const initial: WatermarkFormValues = {
|
||||||
|
name: '',
|
||||||
|
kind: 'text',
|
||||||
|
text_content: '',
|
||||||
|
font_code: fonts[0]?.code ?? '',
|
||||||
|
font_size_px: 24,
|
||||||
|
opacityPercent: 80,
|
||||||
|
scalePercent: 25,
|
||||||
|
position: 'bottom_right',
|
||||||
|
};
|
||||||
|
form.setFieldsValue(initial);
|
||||||
|
setPreviewValues(initial);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (template: WatermarkTemplate) => {
|
||||||
|
setEditing(template);
|
||||||
|
const config = template.config;
|
||||||
|
const assetId = config.image_asset?.id ?? config.image_asset_id ?? null;
|
||||||
|
setImageAsset(
|
||||||
|
template.kind === 'image' && assetId
|
||||||
|
? { id: assetId, previewUrl: assetPreviewUrls[assetId] ?? '' }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const initial: WatermarkFormValues = {
|
||||||
|
name: template.name,
|
||||||
|
kind: template.kind,
|
||||||
|
text_content: config.text_content ?? '',
|
||||||
|
font_code: config.font_code ?? fonts[0]?.code ?? '',
|
||||||
|
font_size_px: config.font_size_px ?? 24,
|
||||||
|
opacityPercent: Math.round(Number(config.opacity ?? '0.8') * 100),
|
||||||
|
scalePercent: Math.round(Number(config.scale_ratio ?? '0.25') * 100),
|
||||||
|
position: config.position,
|
||||||
|
};
|
||||||
|
form.setFieldsValue(initial);
|
||||||
|
setPreviewValues(initial);
|
||||||
|
setEditorOpen(true);
|
||||||
|
|
||||||
|
if (template.kind === 'image' && assetId && !assetPreviewUrls[assetId]) {
|
||||||
|
void loadAssetPreviewUrl(assetId)
|
||||||
|
.then((previewUrl) => {
|
||||||
|
setImageAsset((current) =>
|
||||||
|
current?.id === assetId
|
||||||
|
? { id: assetId, previewUrl }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型切换会丢弃另一种类型的专属配置(尤其是已上传图片),先明确告知用户。
|
||||||
|
* 表单字段由这里显式写入,避免 Select 在确认框出现前就提交了类型变更。
|
||||||
|
*/
|
||||||
|
const requestKindChange = (nextKind: WatermarkKind) => {
|
||||||
|
const currentKind = form.getFieldValue('kind') as WatermarkKind | undefined;
|
||||||
|
if (!currentKind || currentKind === nextKind) return;
|
||||||
|
const applyChange = () => {
|
||||||
|
form.setFieldValue('kind', nextKind);
|
||||||
|
setPreviewValues((previous) => ({
|
||||||
|
...(previous ?? (form.getFieldsValue() as WatermarkFormValues)),
|
||||||
|
kind: nextKind,
|
||||||
|
}));
|
||||||
|
if (currentKind === 'image') setImageAsset(null);
|
||||||
|
};
|
||||||
|
modal.confirm({
|
||||||
|
title: '切换水印类型',
|
||||||
|
content: '切换后将清空当前类型的专属配置,是否继续?',
|
||||||
|
okText: '继续切换',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: applyChange,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeUpload = async (file: UploadFile): Promise<boolean> => {
|
||||||
|
const okTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
if (!okTypes.includes(file.type ?? '')) {
|
||||||
|
message.error('仅支持 JPG、PNG、WebP 格式图片');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((file.size ?? 0) > WATERMARK_IMAGE_MAX_BYTES) {
|
||||||
|
message.error('图片大小不能超过 5MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setUploadingImage(true);
|
||||||
|
try {
|
||||||
|
const asset = await uploadWatermarkImage(file as unknown as File);
|
||||||
|
setImageAsset({
|
||||||
|
id: asset.id,
|
||||||
|
previewUrl: URL.createObjectURL(file as unknown as File),
|
||||||
|
});
|
||||||
|
message.success('图片已上传并通过校验');
|
||||||
|
} catch (err) {
|
||||||
|
message.error(apiErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
}
|
||||||
|
// 阻止 antd 自动上传:上传由三步直传流程接管
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (values: WatermarkFormValues) => {
|
||||||
|
if (values.kind === 'image' && !imageAsset) {
|
||||||
|
message.error('请先上传水印图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 滑杆字段在类型切换后可能缺值,统一兜底默认值,避免 NaN
|
||||||
|
const opacity = percentToOpacity(values.opacityPercent ?? 80);
|
||||||
|
const scaleRatio = percentToScale(values.scalePercent ?? 25);
|
||||||
|
// 编辑时只提交有变化的字段:渲染参数未变时仅改名,
|
||||||
|
// 服务端不会创建新的渲染版本(docs/api/watermarks-and-assets.md §3.3);
|
||||||
|
// 切换类型必须提交新类型的完整 config。
|
||||||
|
if (editing) {
|
||||||
|
let payload: UpdateWatermarkTemplateRequest;
|
||||||
|
if (editing.kind !== values.kind) {
|
||||||
|
// 切换类型:提交新类型完整 config
|
||||||
|
let config: UpdateWatermarkTemplateRequest['config'];
|
||||||
|
if (values.kind === 'text') {
|
||||||
|
config = {
|
||||||
|
text_content: values.text_content?.trim() ?? '',
|
||||||
|
font_code: values.font_code,
|
||||||
|
font_size_px: values.font_size_px,
|
||||||
|
opacity,
|
||||||
|
position: values.position,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
config = {
|
||||||
|
image_asset_id: imageAsset?.id ?? '',
|
||||||
|
scale_ratio: scaleRatio,
|
||||||
|
opacity,
|
||||||
|
position: values.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
payload = { kind: values.kind, config };
|
||||||
|
if (values.name.trim() !== editing.name) {
|
||||||
|
payload.name = values.name.trim();
|
||||||
|
}
|
||||||
|
} else if (values.kind === 'text') {
|
||||||
|
const current = editing.config;
|
||||||
|
const diff: UpdateWatermarkTemplateRequest = {};
|
||||||
|
if (values.name.trim() !== editing.name) {
|
||||||
|
diff.name = values.name.trim();
|
||||||
|
}
|
||||||
|
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
|
||||||
|
if (values.text_content?.trim() !== current.text_content) {
|
||||||
|
configPatch.text_content = values.text_content.trim();
|
||||||
|
}
|
||||||
|
if (values.font_code !== current.font_code) {
|
||||||
|
configPatch.font_code = values.font_code;
|
||||||
|
}
|
||||||
|
if (values.font_size_px !== current.font_size_px) {
|
||||||
|
configPatch.font_size_px = values.font_size_px;
|
||||||
|
}
|
||||||
|
if (Number(opacity) !== Number(current.opacity)) {
|
||||||
|
configPatch.opacity = opacity;
|
||||||
|
}
|
||||||
|
if (values.position !== current.position) {
|
||||||
|
configPatch.position = values.position;
|
||||||
|
}
|
||||||
|
if (Object.keys(configPatch).length > 0) {
|
||||||
|
diff.config = configPatch;
|
||||||
|
}
|
||||||
|
payload = diff;
|
||||||
|
} else {
|
||||||
|
const current = editing.config;
|
||||||
|
const diff: UpdateWatermarkTemplateRequest = {};
|
||||||
|
if (values.name.trim() !== editing.name) {
|
||||||
|
diff.name = values.name.trim();
|
||||||
|
}
|
||||||
|
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
|
||||||
|
const currentAssetId = current.image_asset?.id ?? current.image_asset_id;
|
||||||
|
if (imageAsset && imageAsset.id !== currentAssetId) {
|
||||||
|
configPatch.image_asset_id = imageAsset.id;
|
||||||
|
}
|
||||||
|
if (percentToScale(values.scalePercent) !== current.scale_ratio) {
|
||||||
|
configPatch.scale_ratio = scaleRatio;
|
||||||
|
}
|
||||||
|
if (Number(opacity) !== Number(current.opacity)) {
|
||||||
|
configPatch.opacity = opacity;
|
||||||
|
}
|
||||||
|
if (values.position !== current.position) {
|
||||||
|
configPatch.position = values.position;
|
||||||
|
}
|
||||||
|
if (Object.keys(configPatch).length > 0) {
|
||||||
|
diff.config = configPatch;
|
||||||
|
}
|
||||||
|
payload = diff;
|
||||||
|
}
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
message.info('内容没有变化,无需保存');
|
||||||
|
setEditorOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const template = await watermarksApi.update(editing.id, payload, {
|
||||||
|
revision: editing.revision,
|
||||||
|
});
|
||||||
|
applyTemplate(template);
|
||||||
|
message.success('水印模板已保存');
|
||||||
|
} else if (values.kind === 'text') {
|
||||||
|
const template = await watermarksApi.create({
|
||||||
|
name: values.name.trim(),
|
||||||
|
kind: 'text',
|
||||||
|
config: {
|
||||||
|
text_content: values.text_content.trim(),
|
||||||
|
font_code: values.font_code,
|
||||||
|
font_size_px: values.font_size_px,
|
||||||
|
opacity,
|
||||||
|
position: values.position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applyTemplate(template);
|
||||||
|
message.success('水印模板已创建');
|
||||||
|
} else {
|
||||||
|
const template = await watermarksApi.create({
|
||||||
|
name: values.name.trim(),
|
||||||
|
kind: 'image',
|
||||||
|
config: {
|
||||||
|
image_asset_id: imageAsset?.id ?? '',
|
||||||
|
scale_ratio: scaleRatio,
|
||||||
|
opacity,
|
||||||
|
position: values.position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applyTemplate(template);
|
||||||
|
message.success('水印模板已创建');
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
message.error('模板已被其他操作更新,请关闭后重新打开编辑');
|
||||||
|
} else {
|
||||||
|
const details = apiErrorDetails(err);
|
||||||
|
const knownFields: Array<keyof WatermarkFormValues> = [
|
||||||
|
'name',
|
||||||
|
'kind',
|
||||||
|
'text_content',
|
||||||
|
'font_code',
|
||||||
|
'font_size_px',
|
||||||
|
'opacityPercent',
|
||||||
|
'scalePercent',
|
||||||
|
'position',
|
||||||
|
];
|
||||||
|
if (details.length > 0) {
|
||||||
|
const fieldErrors = [];
|
||||||
|
for (const item of details) {
|
||||||
|
const field = item.field.replace(/^config\./, '');
|
||||||
|
if (knownFields.includes(field as keyof WatermarkFormValues)) {
|
||||||
|
fieldErrors.push({
|
||||||
|
name: field as keyof WatermarkFormValues,
|
||||||
|
errors: [item.message],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
message.error(item.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fieldErrors.length > 0) {
|
||||||
|
form.setFields(fieldErrors);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(apiErrorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 查看效果前刷新图片短时预览 URL(10 分钟有效) */
|
||||||
|
const openPreview = async (template: WatermarkTemplate) => {
|
||||||
|
if (template.kind === 'image') {
|
||||||
|
const assetId = template.config.image_asset?.id ?? template.config.image_asset_id ?? '';
|
||||||
|
// 缓存优先,缺失/过期时重新换取短时预览 URL
|
||||||
|
let url = assetPreviewUrls[assetId];
|
||||||
|
if (!url) {
|
||||||
|
try {
|
||||||
|
url = await loadAssetPreviewUrl(assetId);
|
||||||
|
} catch {
|
||||||
|
// 换取失败按原样打开,图片渲染失败会显示不可用提示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPreviewing({
|
||||||
|
...template,
|
||||||
|
config: {
|
||||||
|
...template.config,
|
||||||
|
image_asset: {
|
||||||
|
id: assetId,
|
||||||
|
preview_url: url ?? '',
|
||||||
|
preview_url_expires_at: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewing(template);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (targets: WatermarkTemplate[]) => {
|
||||||
|
const label =
|
||||||
|
targets.length === 1 ? `「${targets[0].name}」` : `${targets.length} 个水印模板`;
|
||||||
|
modal.confirm({
|
||||||
|
title: '删除水印模板',
|
||||||
|
content: `将删除${label}并解除所有店铺绑定,已生成的图片不受影响。确定删除吗?`,
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
if (targets.length === 1) {
|
||||||
|
await watermarksApi.remove(targets[0].id);
|
||||||
|
message.success('水印模板已删除');
|
||||||
|
} else {
|
||||||
|
setBatchDeleting(true);
|
||||||
|
let failed = 0;
|
||||||
|
for (const template of targets) {
|
||||||
|
try {
|
||||||
|
await watermarksApi.remove(template.id);
|
||||||
|
} catch (err) {
|
||||||
|
failed += 1;
|
||||||
|
message.error(`${template.name}:${apiErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
message.success(`已删除 ${targets.length} 个模板`);
|
||||||
|
} else {
|
||||||
|
message.warning(`已删除 ${targets.length - failed} 个,${failed} 个失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadTemplates();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(apiErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableProps<WatermarkTemplate>['columns'] = [
|
||||||
|
{
|
||||||
|
title: '序号',
|
||||||
|
key: 'index',
|
||||||
|
width: 70,
|
||||||
|
render: (_, _record, index) => index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模板名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 220,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '水印类型',
|
||||||
|
dataIndex: 'kind',
|
||||||
|
width: 110,
|
||||||
|
render: (kind: string) => {
|
||||||
|
const tag = KIND_TAG[kind] ?? { color: 'default', text: kind };
|
||||||
|
return <Tag color={tag.color}>{tag.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '水印内容',
|
||||||
|
key: 'content',
|
||||||
|
render: (_, template) => {
|
||||||
|
if (template.kind === 'image') {
|
||||||
|
const assetId =
|
||||||
|
template.config.image_asset?.id ?? template.config.image_asset_id;
|
||||||
|
const previewUrl = assetId ? assetPreviewUrls[assetId] : undefined;
|
||||||
|
return previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={template.name}
|
||||||
|
style={{ width: 48, height: 48, objectFit: 'contain', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip title="正在获取图片预览">
|
||||||
|
<FileImageOutlined style={{ fontSize: 20, color: '#9ca3af' }} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{template.config.text_content}</div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{template.config.font_code}, {template.config.font_size_px}px, ■
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '水印位置',
|
||||||
|
key: 'position',
|
||||||
|
width: 110,
|
||||||
|
render: (_, template) => watermarkPositionLabel(template.config.position),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '版本',
|
||||||
|
dataIndex: 'current_version',
|
||||||
|
width: 70,
|
||||||
|
render: (version: number) => `v${version}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (value: string) => (
|
||||||
|
<Typography.Text type="secondary">{formatDateTime(value)}</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 190,
|
||||||
|
render: (_, template) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button type="link" size="small" onClick={() => void openPreview(template)}>
|
||||||
|
查看效果
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(template)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
onClick={() => confirmDelete([template])}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const previewConfig = useMemo<Partial<WatermarkTemplateConfig>>(() => {
|
||||||
|
if (!previewValues) return {};
|
||||||
|
if (previewValues.kind === 'image') {
|
||||||
|
return {
|
||||||
|
image_asset: {
|
||||||
|
id: imageAsset?.id ?? '',
|
||||||
|
preview_url: imageAsset?.previewUrl ?? '',
|
||||||
|
preview_url_expires_at: '',
|
||||||
|
},
|
||||||
|
scale_ratio: percentToScale(previewValues.scalePercent),
|
||||||
|
opacity: percentToOpacity(previewValues.opacityPercent),
|
||||||
|
position: previewValues.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text_content: previewValues.text_content,
|
||||||
|
font_code: previewValues.font_code,
|
||||||
|
font_size_px: previewValues.font_size_px,
|
||||||
|
opacity: percentToOpacity(previewValues.opacityPercent),
|
||||||
|
position: previewValues.position,
|
||||||
|
};
|
||||||
|
}, [previewValues, imageAsset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="watermarks-page">
|
||||||
|
<Card
|
||||||
|
title="水印管理"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadTemplates()} />
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<RestOutlined />}
|
||||||
|
disabled={selectedIds.length === 0 || batchDeleting}
|
||||||
|
loading={batchDeleting}
|
||||||
|
onClick={() => {
|
||||||
|
const targets = templates.filter((item) =>
|
||||||
|
selectedIds.includes(item.id)
|
||||||
|
);
|
||||||
|
confirmDelete(targets);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
|
添加模板
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space wrap style={{ marginBottom: 16 }} size={8}>
|
||||||
|
<Select<KindFilter>
|
||||||
|
value={kindDraft}
|
||||||
|
onChange={setKindDraft}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部类型' },
|
||||||
|
{ value: 'text', label: '文字水印' },
|
||||||
|
{ value: 'image', label: '图片水印' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入模板名称"
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(event) => setNameDraft(event.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
value={
|
||||||
|
dateDraft
|
||||||
|
? ([dayjsOrNull(dateDraft[0]), dayjsOrNull(dateDraft[1])] as never)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={(_, dateString) =>
|
||||||
|
setDateDraft(
|
||||||
|
dateString[0] && dateString[1]
|
||||||
|
? [dateString[0], dateString[1]]
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setKindFilter(kindDraft);
|
||||||
|
setNameFilter(nameDraft.trim());
|
||||||
|
setDateFilter(dateDraft);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKindDraft('all');
|
||||||
|
setNameDraft('');
|
||||||
|
setDateDraft(null);
|
||||||
|
setKindFilter('all');
|
||||||
|
setNameFilter('');
|
||||||
|
setDateFilter(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重 置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table<WatermarkTemplate>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedIds,
|
||||||
|
onChange: (keys) => setSelectedIds(keys.map(String)),
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无水印模板,点击右上角「添加模板」创建' }}
|
||||||
|
/>
|
||||||
|
{nextCursor && (
|
||||||
|
<div className="watermarks-load-more">
|
||||||
|
<Button
|
||||||
|
loading={loadingMore}
|
||||||
|
onClick={() => void loadTemplates(nextCursor)}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑水印模板' : '添加水印模板'}
|
||||||
|
open={editorOpen}
|
||||||
|
onCancel={() => setEditorOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
destroyOnHidden
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
<Form<WatermarkFormValues>
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)}
|
||||||
|
onFinish={handleSave}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="模板名称"
|
||||||
|
required
|
||||||
|
rules={[{ required: true, whitespace: true, message: '请输入模板名称' }]}
|
||||||
|
>
|
||||||
|
<Input maxLength={120} placeholder="例如:右下角品牌水印" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="水印类型" required>
|
||||||
|
<Select
|
||||||
|
value={previewValues?.kind ?? form.getFieldValue('kind') ?? 'text'}
|
||||||
|
onChange={requestKindChange}
|
||||||
|
options={[
|
||||||
|
{ value: 'text', label: '文字水印' },
|
||||||
|
{ value: 'image', label: '图片水印' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, next) => prev.kind !== next.kind}>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('kind') === 'image' ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="上传图片" required>
|
||||||
|
<Upload
|
||||||
|
accept={WATERMARK_IMAGE_ACCEPT}
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleBeforeUpload}
|
||||||
|
disabled={uploadingImage}
|
||||||
|
>
|
||||||
|
<div className="watermark-image-box">
|
||||||
|
{imageAsset?.previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageAsset.previewUrl}
|
||||||
|
alt="水印图片"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileImageOutlined
|
||||||
|
style={{ fontSize: 28, color: '#9ca3af' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uploadingImage && (
|
||||||
|
<div className="watermark-image-uploading">
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, marginTop: -8 }}
|
||||||
|
>
|
||||||
|
推荐使用透明底的 PNG 格式图片;支持 JPG、PNG、WebP,不超过
|
||||||
|
5MB
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form.Item
|
||||||
|
name="scalePercent"
|
||||||
|
label="图片缩放"
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={10}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
tooltip={{ formatter: (value) => `${value ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="text_content"
|
||||||
|
label="水印文字"
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
whitespace: true,
|
||||||
|
message: '请输入水印文字',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={200} placeholder="例如:Atlas Store" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="font_code"
|
||||||
|
label="字体"
|
||||||
|
required
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择字体"
|
||||||
|
notFoundContent="渲染服务暂无可用字体"
|
||||||
|
options={fonts.map((font) => ({
|
||||||
|
value: font.code,
|
||||||
|
label: font.display_name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="font_size_px"
|
||||||
|
label="字号(px)"
|
||||||
|
required
|
||||||
|
rules={[{ required: true, message: '请输入字号' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={12}
|
||||||
|
max={60}
|
||||||
|
precision={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="opacityPercent" label="不透明度">
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
tooltip={{ formatter: (value) => `${value ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="水印位置" required>
|
||||||
|
<Form.Item name="position" noStyle>
|
||||||
|
<PositionGrid />
|
||||||
|
</Form.Item>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="预览效果">
|
||||||
|
<WatermarkPreview config={previewConfig} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="watermark-form-actions">
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>
|
||||||
|
保 存
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setEditorOpen(false)}>取 消</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={previewing ? `查看效果:${previewing.name}` : '查看效果'}
|
||||||
|
open={!!previewing}
|
||||||
|
onCancel={() => setPreviewing(null)}
|
||||||
|
footer={null}
|
||||||
|
width={420}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{previewing && <WatermarkPreview config={previewing.config} />}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayjsOrNull = (value: string) => {
|
||||||
|
const parsed = dayjs(value);
|
||||||
|
return parsed.isValid() ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 3x3 水印位置九宫格选择器:选中格高亮,旁边显示位置名称 */
|
||||||
|
const PositionGrid = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: WatermarkPositionValue;
|
||||||
|
onChange?: (value: WatermarkPositionValue) => void;
|
||||||
|
}) => {
|
||||||
|
const selected = WATERMARK_POSITION_OPTIONS.find((option) => option.value === value);
|
||||||
|
return (
|
||||||
|
<Space size={12} align="center">
|
||||||
|
<div className="position-grid">
|
||||||
|
{WATERMARK_POSITION_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`position-grid-cell${option.value === value ? ' active' : ''}`}
|
||||||
|
aria-label={option.label}
|
||||||
|
onClick={() => onChange?.(option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary">{positionGridLabel(selected?.label)}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionGridLabel = (label?: string) => label ?? '请选择位置';
|
||||||
|
|
||||||
export default WatermarksPage;
|
export default WatermarksPage;
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export const api = {
|
|||||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
|
put: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.put<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
@@ -133,8 +136,7 @@ export function apiErrorMessage(error: unknown): string {
|
|||||||
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
|
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
|
||||||
export function apiErrorCode(error: unknown): string | null {
|
export function apiErrorCode(error: unknown): string | null {
|
||||||
if (!axios.isAxiosError(error)) return null;
|
if (!axios.isAxiosError(error)) return null;
|
||||||
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error
|
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error?.code;
|
||||||
?.code;
|
|
||||||
return typeof code === 'string' ? code : null;
|
return typeof code === 'string' ? code : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CollectionItem, CreateCollectionItemRequest, CursorPaginationMeta, ListCollectionItemsQuery, UpdateCollectionItemRequest } from '@atlas/contracts';
|
||||||
|
import { api } from './api';
|
||||||
|
export const collectionApi = {
|
||||||
|
list: (platformId: string, query: Partial<ListCollectionItemsQuery> = {}) => api.getList<CollectionItem, CursorPaginationMeta & { active_count: number; collection_limit: number }>('/collection-items', { headers: { 'X-Atlas-Platform-Id': platformId }, params: { limit: 20, ...query } }),
|
||||||
|
create: (payload: CreateCollectionItemRequest) => api.post<{ collection_item: CollectionItem; product_id: string; editor_entry_path: string }>('/collection-items', payload, { headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||||
|
update: (id: string, payload: UpdateCollectionItemRequest, revision: number) => api.patch<CollectionItem>(`/collection-items/${id}`, payload, { headers: { 'Idempotency-Key': crypto.randomUUID(), 'If-Match': `"${revision}"` } }),
|
||||||
|
bulkDelete: (ids: string[]) => api.post<{ deleted_count: number }>('/collection-items/bulk-delete', { ids }, { headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||||
|
clear: (expected_active_count: number) => api.post<{ deleted_count: number }>('/collection-items/clear', { confirm: true, expected_active_count }, { headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type {
|
||||||
|
CreateMediaUploadIntentRequest,
|
||||||
|
MediaAccessUrl,
|
||||||
|
MediaAsset,
|
||||||
|
MediaUploadIntent,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
/** 水印图片的用途与限制(docs/api/watermarks-and-assets.md §1) */
|
||||||
|
export const WATERMARK_IMAGE_ACCEPT = '.jpg,.jpeg,.png,.webp';
|
||||||
|
export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||||
|
export const mediaApi = {
|
||||||
|
createUploadIntent: (payload: CreateMediaUploadIntentRequest) =>
|
||||||
|
api.post<MediaUploadIntent>('/media/upload-intents', payload, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
|
||||||
|
completeUpload: (uploadId: string, checksumSha256: string) =>
|
||||||
|
api.post<{ asset: MediaAsset }>(
|
||||||
|
`/media/upload-intents/${uploadId}/complete`,
|
||||||
|
{ checksum_sha256: checksumSha256 },
|
||||||
|
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
|
||||||
|
),
|
||||||
|
|
||||||
|
/** 短时预览 URL:过期后重新请求,不能写回业务表或长期缓存 */
|
||||||
|
createPreviewUrl: (assetId: string) =>
|
||||||
|
api.post<MediaAccessUrl>(
|
||||||
|
`/media-assets/${assetId}/access-urls`,
|
||||||
|
{ purpose: 'preview' },
|
||||||
|
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */
|
||||||
|
export async function sha256Hex(file: File): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片水印素材三步直传(docs/api/watermarks-and-assets.md §1):
|
||||||
|
* 创建上传意图 → 按返回的 method/url/headers 原样 PUT 到对象存储 → 完成校验换取素材。
|
||||||
|
* 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。
|
||||||
|
*/
|
||||||
|
export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
|
||||||
|
const checksumSha256 = await sha256Hex(file);
|
||||||
|
const intent = await mediaApi.createUploadIntent({
|
||||||
|
purpose: 'watermark_image',
|
||||||
|
filename: file.name,
|
||||||
|
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
|
||||||
|
byte_size: file.size,
|
||||||
|
checksum_sha256: checksumSha256,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 必须以服务端返回的 method、URL 与 headers 原样上传,不能自行拼接对象路径
|
||||||
|
const putResponse = await fetch(intent.upload.url, {
|
||||||
|
method: intent.upload.method,
|
||||||
|
headers: intent.upload.headers,
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!putResponse.ok) {
|
||||||
|
throw new Error(`图片上传到对象存储失败(HTTP ${putResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = await mediaApi.completeUpload(intent.upload_id, checksumSha256);
|
||||||
|
return completed.asset;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Operation } from '@atlas/contracts';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
/** operation 的终态集合;页面最终状态以刷新后的店铺资源为准,不以 operation 为准 */
|
||||||
|
export const OPERATION_TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']);
|
||||||
|
|
||||||
|
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||||
|
export const operationsApi = {
|
||||||
|
get: (operationId: string) => api.get<Operation>(`/operations/${operationId}`),
|
||||||
|
};
|
||||||
@@ -130,9 +130,7 @@ export function PlatformProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformContextContext.Provider value={value}>
|
<PlatformContextContext.Provider value={value}>{children}</PlatformContextContext.Provider>
|
||||||
{children}
|
|
||||||
</PlatformContextContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ export const storesApi = {
|
|||||||
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
|
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
|
||||||
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
|
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
|
||||||
*/
|
*/
|
||||||
update: (
|
update: (storeId: string, payload: UpdateStoreRequest, options: UpdateStoreOptions) =>
|
||||||
storeId: string,
|
|
||||||
payload: UpdateStoreRequest,
|
|
||||||
options: UpdateStoreOptions
|
|
||||||
) =>
|
|
||||||
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
||||||
headers: {
|
headers: {
|
||||||
'If-Match': `"${options.revision}"`,
|
'If-Match': `"${options.revision}"`,
|
||||||
@@ -71,4 +67,17 @@ export const storesApi = {
|
|||||||
api.delete<void>(`/stores/${storeId}`, {
|
api.delete<void>(`/stores/${storeId}`, {
|
||||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定水印模板,契约见 docs/api/stores-and-platforms.md(PUT /api/stores/{id}/watermark-binding)。
|
||||||
|
* 传 null 表示「暂不绑定」;模板必须是当前工作空间的活动模板,返回店铺水印摘要。
|
||||||
|
*/
|
||||||
|
bindWatermark: (storeId: string, templateId: string | null) =>
|
||||||
|
api.put<Store>(
|
||||||
|
`/stores/${storeId}/watermark-binding`,
|
||||||
|
{ template_id: templateId },
|
||||||
|
{
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type {
|
||||||
|
CreateWatermarkTemplateRequest,
|
||||||
|
UpdateWatermarkTemplateRequest,
|
||||||
|
WatermarkFont,
|
||||||
|
WatermarkTemplate,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import type { CursorPaginationMeta } from '@atlas/contracts';
|
||||||
|
|
||||||
|
export interface WatermarkListResult {
|
||||||
|
data: WatermarkTemplate[];
|
||||||
|
meta: CursorPaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWatermarkOptions {
|
||||||
|
/** 提交时的乐观锁版本,取自模板 revision */
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 水印模板接口,契约见 docs/api/watermarks-and-assets.md §2/§3。
|
||||||
|
* 列表/详情的图片模板携带短时 preview_url,过期后重新读取即可。
|
||||||
|
*/
|
||||||
|
export const watermarksApi = {
|
||||||
|
/** 渲染服务已部署的可用字体;字体 code 是模板的稳定保存值 */
|
||||||
|
fonts: () => api.get<WatermarkFont[]>('/watermark-fonts'),
|
||||||
|
|
||||||
|
list: (cursor?: string) =>
|
||||||
|
api.getList<WatermarkTemplate, CursorPaginationMeta>('/watermark-templates', {
|
||||||
|
params: { limit: 100, ...(cursor ? { cursor } : {}) },
|
||||||
|
}),
|
||||||
|
|
||||||
|
get: (templateId: string) => api.get<WatermarkTemplate>(`/watermark-templates/${templateId}`),
|
||||||
|
|
||||||
|
create: (payload: CreateWatermarkTemplateRequest) =>
|
||||||
|
api.post<WatermarkTemplate>('/watermark-templates', payload, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** 渲染参数变更由服务端创建不可变版本并递增 current_version;仅改名不创建版本 */
|
||||||
|
update: (
|
||||||
|
templateId: string,
|
||||||
|
payload: UpdateWatermarkTemplateRequest,
|
||||||
|
options: UpdateWatermarkOptions
|
||||||
|
) =>
|
||||||
|
api.patch<WatermarkTemplate>(`/watermark-templates/${templateId}`, payload, {
|
||||||
|
headers: {
|
||||||
|
'If-Match': `"${options.revision}"`,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** 软删除并解除所有店铺绑定,返回 204 */
|
||||||
|
remove: (templateId: string) =>
|
||||||
|
api.delete<void>(`/watermark-templates/${templateId}`, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -2,14 +2,18 @@
|
|||||||
"name": "@atlas/worker",
|
"name": "@atlas/worker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
"check:ozon-store": "tsx src/check-ozon-store.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"@atlas/database": "workspace:*",
|
||||||
|
"@aws-sdk/client-s3": "3.1015.0",
|
||||||
"bullmq": "6.3.4",
|
"bullmq": "6.3.4",
|
||||||
"ioredis": "6.0.0"
|
"ioredis": "6.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createApplicationDatabase } from '@atlas/database';
|
||||||
|
import { workerConfig } from './config.js';
|
||||||
|
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||||
|
|
||||||
|
type StoreCredential = {
|
||||||
|
workspace_id: string;
|
||||||
|
store_id: string;
|
||||||
|
client_id: string;
|
||||||
|
ciphertext: Buffer;
|
||||||
|
nonce: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeName = process.argv[2];
|
||||||
|
if (!storeName) {
|
||||||
|
throw new Error('Usage: tsx src/check-ozon-store.ts <store-name>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const workspaces = await database.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.workspaces',
|
||||||
|
);
|
||||||
|
const matches: StoreCredential[] = [];
|
||||||
|
for (const workspace of workspaces.rows) {
|
||||||
|
const found = await database.workspaceTransaction(workspace.id, (tx) =>
|
||||||
|
tx.query<StoreCredential>(
|
||||||
|
`SELECT s.workspace_id, s.id AS store_id, s.platform_client_id AS client_id,
|
||||||
|
secret.secret_ciphertext AS ciphertext, secret.secret_nonce AS nonce
|
||||||
|
FROM atlas.stores s
|
||||||
|
JOIN atlas.platforms p ON p.id = s.platform_id AND p.code = 'OZON'
|
||||||
|
JOIN atlas.store_secrets secret ON secret.store_id = s.id
|
||||||
|
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||||
|
WHERE s.name = $1 AND s.deleted_at IS NULL`,
|
||||||
|
[storeName],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
matches.push(...found.rows);
|
||||||
|
}
|
||||||
|
if (matches.length === 0) throw new Error(`No active Ozon store named “${storeName}”`);
|
||||||
|
if (matches.length > 1) throw new Error(`Multiple active Ozon stores named “${storeName}”; use a unique name`);
|
||||||
|
|
||||||
|
const store = matches[0];
|
||||||
|
const apiKey = decryptStoreSecret(store.ciphertext, store.nonce, workerConfig.storeSecretEncryptionKey);
|
||||||
|
const checks = [
|
||||||
|
{ name: 'warehouse-list', path: '/v1/warehouse/list', body: {} },
|
||||||
|
{ name: 'category-tree', path: '/v1/description-category/tree', body: { language: 'ZH_HANS' } },
|
||||||
|
];
|
||||||
|
for (const check of checks) {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`https://api-seller.ozon.ru${check.path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Client-Id': store.client_id,
|
||||||
|
'Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(check.body),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.info(`${check.name}: network error or timeout`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.info(`${check.name}: HTTP ${response.status} ${response.ok ? 'OK' : 'FAILED'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().finally(() => database.close());
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { loadEnvFile } from 'node:process';
|
||||||
|
|
||||||
|
for (const directory of [
|
||||||
|
process.cwd(),
|
||||||
|
resolve(process.cwd(), '..'),
|
||||||
|
resolve(process.cwd(), '../..'),
|
||||||
|
]) {
|
||||||
|
const path = resolve(directory, '.env');
|
||||||
|
if (existsSync(path)) {
|
||||||
|
loadEnvFile(path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`${name} is required`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectStorageCredential(
|
||||||
|
accessKeyName: string,
|
||||||
|
localFallbackName: string,
|
||||||
|
): string {
|
||||||
|
return process.env[accessKeyName] || required(localFallbackName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workerConfig = {
|
||||||
|
databaseUrl: required('DATABASE_URL'),
|
||||||
|
redisUrl: required('REDIS_URL'),
|
||||||
|
storeSecretEncryptionKey: required('STORE_SECRET_ENCRYPTION_KEY'),
|
||||||
|
objectStorageEndpoint:
|
||||||
|
process.env.OBJECT_STORAGE_ENDPOINT ?? 'http://localhost:9000',
|
||||||
|
objectStorageForcePathStyle:
|
||||||
|
(process.env.OBJECT_STORAGE_FORCE_PATH_STYLE ?? 'true') === 'true',
|
||||||
|
objectStorageAccessKey: objectStorageCredential(
|
||||||
|
'OBJECT_STORAGE_ACCESS_KEY',
|
||||||
|
'MINIO_ROOT_USER',
|
||||||
|
),
|
||||||
|
objectStorageSecretKey: objectStorageCredential(
|
||||||
|
'OBJECT_STORAGE_SECRET_KEY',
|
||||||
|
'MINIO_ROOT_PASSWORD',
|
||||||
|
),
|
||||||
|
};
|
||||||
+288
-18
@@ -1,28 +1,298 @@
|
|||||||
import { Queue } from 'bullmq';
|
import { randomUUID } from 'node:crypto';
|
||||||
import IORedis from 'ioredis';
|
import { S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { Queue, Worker } from 'bullmq';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||||
|
import { workerConfig } from './config.js';
|
||||||
|
import { OutboxDispatcher, type OutboxJob } from './outbox-dispatcher.js';
|
||||||
|
import {
|
||||||
|
OzonCredentialError,
|
||||||
|
OzonRequestError,
|
||||||
|
verifyOzonCredentials,
|
||||||
|
} from './ozon-client.js';
|
||||||
|
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||||
|
import { MediaUploadCleanup } from './media-upload-cleanup.js';
|
||||||
|
|
||||||
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
|
const connection = new Redis(workerConfig.redisUrl, {
|
||||||
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
|
maxRetriesPerRequest: null,
|
||||||
const connection = new IORedis(
|
});
|
||||||
process.env.REDIS_URL ?? 'redis://localhost:6379',
|
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||||
{
|
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
||||||
maxRetriesPerRequest: null,
|
const dispatcher = new OutboxDispatcher(
|
||||||
},
|
database,
|
||||||
|
queue,
|
||||||
|
`worker_${randomUUID()}`,
|
||||||
);
|
);
|
||||||
const dispatchQueue = new Queue('atlas-dispatch', { connection });
|
const objectStorage = new S3Client({
|
||||||
|
endpoint: workerConfig.objectStorageEndpoint,
|
||||||
|
region: 'us-east-1',
|
||||||
|
forcePathStyle: workerConfig.objectStorageForcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: workerConfig.objectStorageAccessKey,
|
||||||
|
secretAccessKey: workerConfig.objectStorageSecretKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
|
||||||
|
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
|
||||||
|
|
||||||
async function bootstrap() {
|
const worker = new Worker<OutboxJob>(
|
||||||
await dispatchQueue.waitUntilReady();
|
'atlas-dispatch',
|
||||||
console.info(
|
async (job) => {
|
||||||
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
if (job.name === 'store_credential_verification.requested') {
|
||||||
|
await verifyStoreCredentials(database, job.data as OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } });
|
||||||
|
} else if (job.name === 'collection.cleanup.requested') {
|
||||||
|
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection, concurrency: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function cleanupCollection(database: Database, job: OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } }) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
|
await tx.query(`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`, [job.payload.operation_id]);
|
||||||
|
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', updated_at = now(), completed_at = now() WHERE collection_item_id = ANY($1::uuid[]) AND status IN ('queued', 'running')`, [job.payload.collection_item_ids]);
|
||||||
|
await tx.query(`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), result = jsonb_build_object('cancelled_transfer_count', 0) WHERE id = $1 AND status IN ('queued', 'running')`, [job.payload.operation_id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.on('failed', (job, error) => {
|
||||||
|
if (!job || job.attemptsMade < (job.opts.attempts ?? 1) || job.name !== 'store_credential_verification.requested') return;
|
||||||
|
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
||||||
|
const source = await database.workspaceTransaction(
|
||||||
|
job.workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'running', started_at = COALESCE(started_at, now()),
|
||||||
|
attempt_count = attempt_count + 1 WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id],
|
||||||
|
);
|
||||||
|
const result = await tx.query<{
|
||||||
|
platform_code: string;
|
||||||
|
platform_client_id: string;
|
||||||
|
secret_ciphertext: Buffer;
|
||||||
|
secret_nonce: Buffer;
|
||||||
|
}>(
|
||||||
|
`SELECT p.code AS platform_code, s.platform_client_id, secret.secret_ciphertext, secret.secret_nonce
|
||||||
|
FROM atlas.operations operation JOIN atlas.stores s ON s.id = operation.resource_id AND s.deleted_at IS NULL
|
||||||
|
JOIN atlas.platforms p ON p.id = s.platform_id JOIN atlas.store_secrets secret ON secret.id = $2
|
||||||
|
WHERE operation.id = $1 AND operation.kind = 'store_credential_verification'
|
||||||
|
AND secret.store_id = s.id AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL`,
|
||||||
|
[job.payload.operation_id, job.payload.secret_id ?? ''],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!source) return cancelStaleOperation(database, job);
|
||||||
|
const apiKey = decryptStoreSecret(
|
||||||
|
source.secret_ciphertext,
|
||||||
|
source.secret_nonce,
|
||||||
|
workerConfig.storeSecretEncryptionKey,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (source.platform_code.toUpperCase() !== 'OZON')
|
||||||
|
throw new OzonRequestError(422);
|
||||||
|
await verifyOzonCredentials(source.platform_client_id, apiKey);
|
||||||
|
await completeVerification(database, job, true);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OzonCredentialError) {
|
||||||
|
await completeVerification(database, job, false, error.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof OzonRequestError) {
|
||||||
|
await markRequestRejected(database, job, error.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelStaleOperation(database: Database, job: StoreVerificationJob) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||||||
|
error_code = 'CREDENTIAL_REPLACED', error_message = '凭据已更新,旧验证任务已取消'
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
async function completeVerification(
|
||||||
|
database: Database,
|
||||||
async function shutdown() {
|
job: StoreVerificationJob,
|
||||||
await dispatchQueue.close();
|
valid: boolean,
|
||||||
|
rejectedStatus?: 401 | 403,
|
||||||
|
) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
|
const current = await tx.query<{ id: string }>(
|
||||||
|
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||||
|
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||||
|
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||||
|
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||||
|
);
|
||||||
|
if (!current.rows[0]) return;
|
||||||
|
const state = valid ? 'valid' : 'invalid';
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_secrets SET status = $1::atlas.credential_status,
|
||||||
|
last_verified_at = now(), updated_at = now() WHERE id = $2`,
|
||||||
|
[state, job.payload.secret_id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
valid
|
||||||
|
? `UPDATE atlas.stores SET status = 'active', credential_status = 'valid',
|
||||||
|
credential_checked_at = now(), last_error_code = NULL, last_error_message = NULL,
|
||||||
|
last_error_at = NULL, revision = revision + 1, updated_at = now() WHERE id = $1`
|
||||||
|
: `UPDATE atlas.stores SET status = 'credential_invalid', credential_status = 'invalid',
|
||||||
|
credential_checked_at = now(), last_error_code = $2,
|
||||||
|
last_error_message = $3, last_error_at = now(),
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||||
|
valid
|
||||||
|
? [job.payload.store_id]
|
||||||
|
: [
|
||||||
|
job.payload.store_id,
|
||||||
|
rejectedStatus === 403
|
||||||
|
? 'OZON_CREDENTIAL_FORBIDDEN'
|
||||||
|
: 'OZON_CREDENTIAL_UNAUTHORIZED',
|
||||||
|
rejectedStatus === 403
|
||||||
|
? 'Ozon 拒绝访问:请检查 Client ID 与 API Key 是否属于同一账户,及密钥是否具备读取权限'
|
||||||
|
: 'Ozon 未接受该 Client ID 或 API Key',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
valid
|
||||||
|
? `UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
|
||||||
|
result = jsonb_build_object('credential_status', 'valid'), error_code = NULL, error_message = NULL
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`
|
||||||
|
: `UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||||
|
error_code = $2, error_message = $3
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
valid
|
||||||
|
? [job.payload.operation_id]
|
||||||
|
: [
|
||||||
|
job.payload.operation_id,
|
||||||
|
rejectedStatus === 403
|
||||||
|
? 'OZON_CREDENTIAL_FORBIDDEN'
|
||||||
|
: 'OZON_CREDENTIAL_UNAUTHORIZED',
|
||||||
|
rejectedStatus === 403
|
||||||
|
? 'Ozon 拒绝访问:请检查 Client ID 与 API Key 是否属于同一账户,及密钥是否具备读取权限'
|
||||||
|
: 'Ozon 未接受该 Client ID 或 API Key',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markRequestRejected(
|
||||||
|
database: Database,
|
||||||
|
job: StoreVerificationJob,
|
||||||
|
status: number,
|
||||||
|
) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
|
const current = await tx.query<{ id: string }>(
|
||||||
|
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||||
|
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||||
|
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||||
|
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||||
|
);
|
||||||
|
if (!current.rows[0]) return;
|
||||||
|
const message = `Ozon 拒绝了验证请求(HTTP ${status});这不代表 API Key 无效`;
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||||
|
error_code = 'OZON_VERIFICATION_REQUEST_REJECTED', error_message = $2,
|
||||||
|
result = jsonb_build_object('ozon_http_status', $3)
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id, message, status],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.stores SET status = 'sync_error', last_error_code = 'OZON_VERIFICATION_REQUEST_REJECTED',
|
||||||
|
last_error_message = $2, last_error_at = now(), revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $1 AND status = 'verifying'`,
|
||||||
|
[job.payload.store_id, message],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markUnavailable(
|
||||||
|
database: Database,
|
||||||
|
job: StoreVerificationJob,
|
||||||
|
_error: Error,
|
||||||
|
) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
|
const current = await tx.query<{ id: string }>(
|
||||||
|
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||||
|
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||||
|
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||||
|
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||||
|
);
|
||||||
|
if (!current.rows[0]) return;
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||||
|
error_code = 'OZON_VERIFICATION_UNAVAILABLE', error_message = '暂时无法验证 Ozon 凭据,请稍后重试'
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.stores SET status = 'sync_error', last_error_code = 'OZON_VERIFICATION_UNAVAILABLE',
|
||||||
|
last_error_message = '暂时无法验证 Ozon 凭据,请稍后重试', last_error_at = now(),
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $1 AND status = 'verifying'`,
|
||||||
|
[job.payload.store_id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let dispatcherTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
async function bootstrap() {
|
||||||
|
await queue.waitUntilReady();
|
||||||
|
await worker.waitUntilReady();
|
||||||
|
await dispatcher.dispatchBatch();
|
||||||
|
dispatcherTimer = setInterval(
|
||||||
|
() =>
|
||||||
|
void dispatcher.dispatchBatch().catch((error: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'outbox_dispatch_failed',
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
2_000,
|
||||||
|
);
|
||||||
|
void mediaUploadCleanup.run().catch((error: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'media_upload_cleanup_startup_failed',
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
mediaCleanupTimer = setInterval(
|
||||||
|
() =>
|
||||||
|
void mediaUploadCleanup.run().catch((error: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'media_upload_cleanup_failed',
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
5 * 60_000,
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
'Atlas Worker is ready; consuming transactional-outbox events.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void bootstrap().catch((error: unknown) => {
|
||||||
|
console.error(
|
||||||
|
'worker_startup_failed',
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
async function shutdown() {
|
||||||
|
if (dispatcherTimer) clearInterval(dispatcherTimer);
|
||||||
|
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
|
||||||
|
await worker.close();
|
||||||
|
await queue.close();
|
||||||
|
objectStorage.destroy();
|
||||||
|
await database.close();
|
||||||
|
await connection.quit();
|
||||||
|
}
|
||||||
process.once('SIGINT', () => void shutdown());
|
process.once('SIGINT', () => void shutdown());
|
||||||
process.once('SIGTERM', () => void shutdown());
|
process.once('SIGTERM', () => void shutdown());
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { DeleteObjectCommand, type S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
|
||||||
|
type CleanupCandidate = {
|
||||||
|
id: string;
|
||||||
|
bucket: string;
|
||||||
|
object_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MediaUploadCleanup {
|
||||||
|
private running: Promise<void> | null = null;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly database: Database,
|
||||||
|
private readonly s3: S3Client,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传 URL 到期并不代表浏览器一定上传过对象;DeleteObject 对不存在对象也安全,
|
||||||
|
* 所以终态意图统一尝试删除。只在删除成功后写 object_cleaned_at,失败下轮重试。
|
||||||
|
*/
|
||||||
|
public async run() {
|
||||||
|
if (this.running) return this.running;
|
||||||
|
this.running = this.runOnce().finally(() => {
|
||||||
|
this.running = null;
|
||||||
|
});
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runOnce() {
|
||||||
|
const workspaces = await this.database.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.workspaces',
|
||||||
|
);
|
||||||
|
let expired = 0;
|
||||||
|
let cleaned = 0;
|
||||||
|
for (const workspace of workspaces.rows) {
|
||||||
|
expired += await this.expirePending(workspace.id);
|
||||||
|
const candidates = await this.claimCandidates(workspace.id);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
await this.s3.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: candidate.bucket,
|
||||||
|
Key: candidate.object_key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.markCleaned(workspace.id, candidate.id);
|
||||||
|
cleaned += 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'media_upload_cleanup_failed',
|
||||||
|
JSON.stringify({
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
upload_id: candidate.id,
|
||||||
|
reason:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'unknown_error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expired || cleaned) {
|
||||||
|
console.info(
|
||||||
|
'media_upload_cleanup_completed',
|
||||||
|
JSON.stringify({ expired, cleaned }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async expirePending(workspaceId: string): Promise<number> {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const result = await tx.query(
|
||||||
|
`UPDATE atlas.media_upload_intents
|
||||||
|
SET status = 'expired', error_code = 'UPLOAD_EXPIRED',
|
||||||
|
error_message = '上传意图已过期', completed_at = now()
|
||||||
|
WHERE status = 'pending' AND expires_at <= now()`,
|
||||||
|
);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claimCandidates(workspaceId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const candidates = await tx.query<CleanupCandidate>(
|
||||||
|
`SELECT id, bucket, object_key
|
||||||
|
FROM atlas.media_upload_intents
|
||||||
|
WHERE status IN ('failed', 'expired') AND object_cleaned_at IS NULL
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 50`,
|
||||||
|
);
|
||||||
|
return candidates.rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markCleaned(workspaceId: string, intentId: string) {
|
||||||
|
await this.database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.media_upload_intents SET object_cleaned_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('failed', 'expired')
|
||||||
|
AND object_cleaned_at IS NULL`,
|
||||||
|
[intentId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Queue } from 'bullmq';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
|
||||||
|
export type OutboxJob = {
|
||||||
|
eventId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
eventType: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventRow = Omit<OutboxJob, 'eventId' | 'workspaceId' | 'eventType'> & {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
event_type: string;
|
||||||
|
event_payload: OutboxJob['payload'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OutboxDispatcher {
|
||||||
|
public constructor(
|
||||||
|
private readonly database: Database,
|
||||||
|
private readonly queue: Queue<OutboxJob>,
|
||||||
|
private readonly workerId: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async dispatchBatch() {
|
||||||
|
// 业务表受 RLS 保护;先枚举工作空间,再在各自租户事务中认领事件。
|
||||||
|
const workspaces = await this.database.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.workspaces',
|
||||||
|
);
|
||||||
|
for (const workspace of workspaces.rows) {
|
||||||
|
const events = await this.claim(workspace.id);
|
||||||
|
for (const event of events) await this.dispatch(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claim(workspaceId: string): Promise<OutboxJob[]> {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const events = await tx.query<EventRow>(
|
||||||
|
`WITH candidates AS (
|
||||||
|
SELECT id FROM atlas.outbox_events
|
||||||
|
WHERE (status = 'pending' AND next_attempt_at <= now())
|
||||||
|
OR (status = 'dispatching' AND locked_until < now())
|
||||||
|
ORDER BY created_at
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
UPDATE atlas.outbox_events event
|
||||||
|
SET status = 'dispatching', locked_by = $1,
|
||||||
|
locked_until = now() + interval '2 minutes', updated_at = now()
|
||||||
|
FROM candidates
|
||||||
|
WHERE event.id = candidates.id
|
||||||
|
RETURNING event.id, event.workspace_id, event.event_type, event.event_payload`,
|
||||||
|
[this.workerId],
|
||||||
|
);
|
||||||
|
return events.rows.map((event) => ({
|
||||||
|
eventId: event.id,
|
||||||
|
workspaceId: event.workspace_id,
|
||||||
|
eventType: event.event_type,
|
||||||
|
payload: event.event_payload,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(event: OutboxJob) {
|
||||||
|
try {
|
||||||
|
await this.queue.add(event.eventType, event, {
|
||||||
|
jobId: event.eventId,
|
||||||
|
attempts: 5,
|
||||||
|
backoff: { type: 'exponential', delay: 5_000 },
|
||||||
|
removeOnComplete: 1_000,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
});
|
||||||
|
await this.database.workspaceTransaction(event.workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.outbox_events SET status = 'dispatched', dispatched_at = now(),
|
||||||
|
locked_by = NULL, locked_until = NULL, updated_at = now()
|
||||||
|
WHERE id = $1 AND status = 'dispatching' AND locked_by = $2`,
|
||||||
|
[event.eventId, this.workerId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await this.database.workspaceTransaction(event.workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.outbox_events SET status = 'pending', attempt_count = attempt_count + 1,
|
||||||
|
next_attempt_at = now() + interval '30 seconds', locked_by = NULL, locked_until = NULL,
|
||||||
|
last_error_code = 'QUEUE_UNAVAILABLE', last_error_message = '任务队列暂不可用', updated_at = now()
|
||||||
|
WHERE id = $1 AND status = 'dispatching' AND locked_by = $2`,
|
||||||
|
[event.eventId, this.workerId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/** 401 是认证失败;403 表示密钥权限或账户组合不被允许。 */
|
||||||
|
export class OzonCredentialError extends Error {
|
||||||
|
public constructor(public readonly status: 401 | 403) {
|
||||||
|
super(`Ozon credential request failed (${status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 认证以外的确定性 4xx,不能误报为凭据无效。 */
|
||||||
|
export class OzonRequestError extends Error {
|
||||||
|
public constructor(public readonly status: number) {
|
||||||
|
super(`Ozon verification request was rejected (${status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class OzonRetryableError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the category-tree read endpoint as the credential probe. It has no write side effect and,
|
||||||
|
* unlike warehouse-list, is verified against the current Ozon account integration.
|
||||||
|
*/
|
||||||
|
export async function verifyOzonCredentials(
|
||||||
|
clientId: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Client-Id': clientId,
|
||||||
|
'Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new OzonRetryableError('Ozon request could not be completed');
|
||||||
|
}
|
||||||
|
if (response.ok) return;
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new OzonCredentialError(response.status);
|
||||||
|
}
|
||||||
|
if (response.status === 429 || response.status >= 500) {
|
||||||
|
throw new OzonRetryableError(`Ozon temporarily unavailable (${response.status})`);
|
||||||
|
}
|
||||||
|
throw new OzonRequestError(response.status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createDecipheriv, createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
/** Decrypt only inside a running Worker; callers must never log the returned API key. */
|
||||||
|
export function decryptStoreSecret(
|
||||||
|
ciphertextWithTag: Buffer,
|
||||||
|
nonce: Buffer,
|
||||||
|
keyMaterial: string,
|
||||||
|
): string {
|
||||||
|
const tagLength = 16;
|
||||||
|
if (ciphertextWithTag.length <= tagLength) {
|
||||||
|
throw new Error('Invalid encrypted store secret');
|
||||||
|
}
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
||||||
|
decipher.setAuthTag(ciphertextWithTag.subarray(-tagLength));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(ciphertextWithTag.subarray(0, -tagLength)),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
|
|||||||
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
||||||
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
||||||
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 |
|
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 |
|
||||||
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
|
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 |
|
||||||
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
||||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||||
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
|
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"code": "OZON",
|
"code": "OZON",
|
||||||
"name": "Ozon",
|
"name": "Ozon",
|
||||||
"supported_currencies": ["CNY", "RUB"],
|
"supported_currencies": ["CNY", "RUB"],
|
||||||
|
"default_currency_code": "CNY",
|
||||||
"default_market_code": "RU",
|
"default_market_code": "RU",
|
||||||
"default_locale": "ru-RU"
|
"default_locale": "ru-RU"
|
||||||
}]
|
}]
|
||||||
@@ -81,7 +82,7 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
校验 `currency_code` 是否为目标平台可用币种。服务端立即把 API Key 信封加密保存、创建 `status=verifying` 的店铺,随后投递“低风险凭据验证 + 仓库同步”操作;不得将 Key 放入任务 payload、日志或响应。
|
校验 `currency_code` 是否为目标平台可用币种。服务端立即把 API Key 信封加密保存、创建 `status=verifying` 的店铺,并投递低风险凭据验证操作;不得将 Key 放入任务 payload、日志或响应。仓库同步是独立的后续操作,不随创建自动触发。
|
||||||
|
|
||||||
返回 `202 Accepted`:
|
返回 `202 Accepted`:
|
||||||
|
|
||||||
@@ -96,6 +97,32 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
|
|
||||||
首家未删除店铺强制为默认。对已有店铺提交 `is_default=true` 时,服务端在同一事务取消旧默认店铺;校验失败不把店铺置为 `active`,失败原因以可展示的脱敏 `last_error` 摘要(`code`、`message`、`at`)返回给后续读取接口。
|
首家未删除店铺强制为默认。对已有店铺提交 `is_default=true` 时,服务端在同一事务取消旧默认店铺;校验失败不把店铺置为 `active`,失败原因以可展示的脱敏 `last_error` 摘要(`code`、`message`、`at`)返回给后续读取接口。
|
||||||
|
|
||||||
|
### 4.1 Ozon 凭据验证与前端展示
|
||||||
|
|
||||||
|
Worker 只调用一个无写副作用的 Ozon Seller API 探针:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/description-category/tree
|
||||||
|
Client-Id: <store platform_client_id>
|
||||||
|
Api-Key: <decrypted secret, worker memory only>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "language": "ZH_HANS" }
|
||||||
|
```
|
||||||
|
|
||||||
|
该调用只用于验证 Ozon 是否接受当前 Client ID 与 API Key,不创建、修改或同步任何 Ozon 数据。它不携带 API Key 至队列、日志、operation 或 HTTP 响应。
|
||||||
|
|
||||||
|
| Worker 结果 | 店铺最终状态 | 前端文案与行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| operation 为 `queued` / `running` | `status=verifying`、`credential_status=unknown` | 仅显示“验证中”;不要同时展示“未验证”。可轮询 operation,并在终态后刷新店铺列表。 |
|
||||||
|
| Ozon 返回 2xx | `active` / `valid` | 显示“正常”。 |
|
||||||
|
| Ozon 返回 401 | `credential_invalid` / `invalid` | 显示“凭据无效”,提示用户核对 Client ID 与 API Key。 |
|
||||||
|
| Ozon 返回 403 | `credential_invalid` / `invalid` | 显示“访问被拒绝”,提示 Client ID 与 API Key 必须属于同一账户,且密钥需要读取权限。 |
|
||||||
|
| Ozon 返回其他确定性 4xx | `sync_error` / 保留当前凭据状态 | 显示 `last_error.message`;该状态**不代表 API Key 无效**。 |
|
||||||
|
| 网络超时、429 或 5xx | 保持验证中,Worker 重试;重试耗尽后为 `sync_error` | 显示“暂时无法验证,请稍后重试”。 |
|
||||||
|
|
||||||
|
页面最终以 `GET /api/stores` 返回的店铺状态为准;`GET /api/operations/{operation_id}` 只用于得知异步过程是否结束及读取脱敏错误。点击“重新验证凭据”会重新发起上述一次类目树请求。
|
||||||
|
|
||||||
## 5. 编辑、默认店铺、验证与删除
|
## 5. 编辑、默认店铺、验证与删除
|
||||||
|
|
||||||
`PATCH /api/stores/{store_id}`
|
`PATCH /api/stores/{store_id}`
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
首期允许 `image/png`、`image/jpeg`、`image/webp`。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和短时预签名上传地址。返回 `201 Created`:
|
首期允许 `image/png`、`image/jpeg`、`image/webp`,最大 5 MiB,图片宽高须为 1–8192 px。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和 10 分钟有效的预签名上传地址。返回 `201 Created`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -50,7 +50,7 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
{ "checksum_sha256": "hex-sha256" }
|
{ "checksum_sha256": "hex-sha256" }
|
||||||
```
|
```
|
||||||
|
|
||||||
服务端检查对象存在、文件大小、MIME/魔数、哈希和图片尺寸;校验通过后创建/复用 `media_asset` 并返回 `201 Created`。图片解码或安全扫描耗时时可先返回 `202` 与 `media_processing` operation。
|
服务端检查对象存在、文件大小、声明 MIME、文件魔数、哈希和图片尺寸;校验通过后创建/复用 `media_asset` 并返回 `201 Created`。校验失败时上传意图标记为失败,不能被水印模板引用。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -67,7 +67,7 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`object_key` 是服务端内部字段,不放在普通客户端响应。上传意图、完成状态和对象清理需要在实现时增加持久化记录;它不能只存在于 Redis 或预签名 token 中。
|
`object_key` 是服务端内部字段,不放在普通客户端响应。上传意图和完成状态均持久化在数据库,不能只存在于 Redis 或预签名 token 中;Worker 每 5 分钟将逾期未完成意图标记为 `expired`,并删除 `failed` / `expired` 意图的临时对象。删除成功才记录清理时间;失败会在下一轮重试。
|
||||||
|
|
||||||
### 1.3 临时预览 URL
|
### 1.3 临时预览 URL
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
{ "purpose": "preview" }
|
{ "purpose": "preview" }
|
||||||
```
|
```
|
||||||
|
|
||||||
返回短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
|
返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
|
||||||
|
|
||||||
## 2. 可用字体
|
## 2. 可用字体
|
||||||
|
|
||||||
|
|||||||
@@ -90,12 +90,13 @@ NestJS API/Worker 通过环境变量拿到连接信息,本地 `.env` 示例:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 对象存储(本地 MinIO / 生产 Kodo 只换这一组值)
|
# 对象存储(本地 MinIO / 生产 Kodo 只换这一组值)
|
||||||
S3_ENDPOINT=http://localhost:9000
|
OBJECT_STORAGE_ENDPOINT=http://localhost:9000
|
||||||
S3_REGION=us-east-1 # MinIO 不校验 region,任意合法值即可
|
OBJECT_STORAGE_BUCKET=atlas-dev-assets
|
||||||
S3_BUCKET=atlas-dev-assets
|
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||||
S3_ACCESS_KEY=atlas-dev
|
# 生产或共享环境必须提供专用最小权限凭证:
|
||||||
S3_SECRET_KEY=atlas-dev-secret
|
OBJECT_STORAGE_ACCESS_KEY=atlas-dev
|
||||||
S3_FORCE_PATH_STYLE=true # MinIO 必须用 path-style,见第 6.3 节
|
OBJECT_STORAGE_SECRET_KEY=atlas-dev-secret
|
||||||
|
# 本地也可仅设置 MINIO_ROOT_USER / MINIO_ROOT_PASSWORD,由 API 作为兼容回退读取。
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 创建 Bucket
|
## 4. 创建 Bucket
|
||||||
@@ -159,24 +160,24 @@ import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3
|
|||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
endpoint: process.env.S3_ENDPOINT!, // http://localhost:9000
|
endpoint: process.env.OBJECT_STORAGE_ENDPOINT!, // http://localhost:9000
|
||||||
region: process.env.S3_REGION!, // MinIO 任意值即可
|
region: 'us-east-1',
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
accessKeyId: process.env.OBJECT_STORAGE_ACCESS_KEY!,
|
||||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
secretAccessKey: process.env.OBJECT_STORAGE_SECRET_KEY!,
|
||||||
},
|
},
|
||||||
forcePathStyle: true, // MinIO 必须;Kodo 走虚拟主机式,配置驱动即可
|
forcePathStyle: process.env.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 预签名上传 URL:前端拿到后直传,浏览器不保存永久密钥
|
// 预签名上传 URL:前端拿到后直传,浏览器不保存永久密钥
|
||||||
export async function presignUpload(objectKey: string, contentType: string) {
|
export async function presignUpload(objectKey: string, contentType: string) {
|
||||||
const cmd = new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: objectKey, ContentType: contentType });
|
const cmd = new PutObjectCommand({ Bucket: process.env.OBJECT_STORAGE_BUCKET, Key: objectKey, ContentType: contentType });
|
||||||
return getSignedUrl(s3, cmd, { expiresIn: 600 }); // 10 分钟
|
return getSignedUrl(s3, cmd, { expiresIn: 600 }); // 10 分钟
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预签名下载 URL:预览/下载私有文件
|
// 预签名下载 URL:预览/下载私有文件
|
||||||
export async function presignDownload(objectKey: string) {
|
export async function presignDownload(objectKey: string) {
|
||||||
const cmd = new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: objectKey });
|
const cmd = new GetObjectCommand({ Bucket: process.env.OBJECT_STORAGE_BUCKET, Key: objectKey });
|
||||||
return getSignedUrl(s3, cmd, { expiresIn: 900 }); // 15 分钟
|
return getSignedUrl(s3, cmd, { expiresIn: 900 }); // 15 分钟
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
## 在Cursor中连接数据库
|
||||||
|
可以。为这次手工维护 Ozon 币种,请在 SQLTools 用迁移专用账号连接:
|
||||||
|
|
||||||
|
| 字段 | 填写内容 |
|
||||||
|
|---|---|
|
||||||
|
| Connection name | `Atlas Local - Migrator` |
|
||||||
|
| Connection group | 可留空,或填 `Local` |
|
||||||
|
| Connect using | `Server and Port` |
|
||||||
|
| Server Address | `localhost` |
|
||||||
|
| Port | 根目录 `.env` 的 `POSTGRES_PORT`(通常是 `5432`) |
|
||||||
|
| Database | 根目录 `.env` 的 `POSTGRES_DB`(通常是 `atlas`) |
|
||||||
|
| Username | `atlas_migrator` |
|
||||||
|
| Use password | 选择 SQLTools Driver Credentials,并填根目录 `.env` 的 `ATLAS_MIGRATOR_PASSWORD` 值 |
|
||||||
|
|
||||||
|
不要使用 `atlas_app_login`:它给 API/Worker 用,权限不足以执行迁移或平台配置维护。
|
||||||
|
|
||||||
|
连上后,先执行:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT code, name, capabilities
|
||||||
|
FROM atlas.platforms
|
||||||
|
WHERE code = 'OZON';
|
||||||
|
```
|
||||||
|
|
||||||
|
确认能读到 Ozon 后,再执行我前一条消息提供的事务 SQL。密码不要贴到聊天里。
|
||||||
+48
-9
@@ -1,12 +1,12 @@
|
|||||||
# Atlas Seller Kit 交接说明
|
# Atlas Seller Kit 交接说明
|
||||||
|
|
||||||
> 更新:2026-09-03。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
> 更新:2026-09-04。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||||
|
|
||||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;真实平台验证与同步 Worker 尚未接入。Web 已具备完整前端框架(布局、路由、主题)、登录/注册页、页头平台选择器与店铺管理页(已接入真实 API),其余业务页面为占位;Worker 与扩展为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店、水印/素材与采集箱基础管理;Worker 已具备 Ozon 凭据验证及采集记录清理消费链路,仓库同步与来源素材转存尚未接入。Web 已具备完整前端框架(布局、路由、主题)、登录/注册页、页头平台选择器、店铺管理、水印管理与真实采集箱页面;浏览器扩展仍为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||||
|
|
||||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||||
|
|
||||||
@@ -47,6 +47,46 @@
|
|||||||
- 修复 API 阻断性 bug(跨领域改动,原因:阻断本功能联调;影响 `apps/api/src/stores/stores.controller.ts`):tsx/esbuild 不生成 `design:paramtypes` 装饰器元数据,控制器裸类型构造参数在 dev 运行时注入 `undefined`,`GET /api/platforms` 返回 500。已改为与 `auth.controller.ts` 一致的显式 `@Inject(...)`;服务层原本就使用显式 token,不受影响。
|
- 修复 API 阻断性 bug(跨领域改动,原因:阻断本功能联调;影响 `apps/api/src/stores/stores.controller.ts`):tsx/esbuild 不生成 `design:paramtypes` 装饰器元数据,控制器裸类型构造参数在 dev 运行时注入 `undefined`,`GET /api/platforms` 返回 500。已改为与 `auth.controller.ts` 一致的显式 `@Inject(...)`;服务层原本就使用显式 token,不受影响。
|
||||||
- 浏览器实测(全新迁移的本地 PostgreSQL 18 + Redis 7):注册 → 平台选择器 → 创建/编辑/设默认/删除保护/同步 422 错误路径 → 刷新后会话恢复与列表持久化,全部通过;contracts/api/web typecheck 与 web production build 通过。
|
- 浏览器实测(全新迁移的本地 PostgreSQL 18 + Redis 7):注册 → 平台选择器 → 创建/编辑/设默认/删除保护/同步 422 错误路径 → 刷新后会话恢复与列表持久化,全部通过;contracts/api/web typecheck 与 web production build 通过。
|
||||||
|
|
||||||
|
### 1.3 开发进度(2026-09-03 晚):店铺表单与界面收口
|
||||||
|
|
||||||
|
- 店铺创建与编辑表单补齐空白字符拦截;API Key 输入上限与冻结契约的 8192 字符保持一致,不在前端自行收紧。
|
||||||
|
- 设置页补充取消操作;全局主题目前使用 `borderRadius: 10`、默认控件高度 34,小尺寸控件高度 26,输入类控件单独使用 16px 字号与 40px 高度。按钮保留 Ant Design 默认字号、高度与内边距。
|
||||||
|
- 页头面包屑改用 12px `RightOutlined` 分隔符,并完成条目、分隔符与侧栏折叠按钮的垂直对齐;页头水平内边距调整为 `0 20px 0 10px`。店铺与设置页的视觉调整不改变接口或领域行为。
|
||||||
|
- Ozon 平台现支持 `CNY` 与 `RUB`;新建 Ozon 店铺时默认预选 `CNY`。默认币种由平台能力 `default_currency_code` 提供,服务端仍以平台支持币种表校验提交值。
|
||||||
|
|
||||||
|
### 1.4 开发进度(2026-09-04):凭据验证 Worker
|
||||||
|
|
||||||
|
- Worker 轮询 Transactional Outbox,按工作空间隔离认领事件并投递 BullMQ;任务只携带 store、operation 与 secret ID,绝不携带 API Key。
|
||||||
|
- `store_credential_verification` 任务仅在 Worker 内读取当前未撤销密钥、短暂解密并调用 Ozon `POST /v1/description-category/tree`(`{ "language": "ZH_HANS" }`)进行低风险验证。该请求已用真实店铺验证可用;仓库接口留待仓库同步模块按其实际请求契约接入。401 表示认证失败;403 单独提示账户/权限不匹配;其他确定性 4xx 记录脱敏 HTTP 状态并标为验证请求错误,不误报为凭据无效。临时网络、限流和 Ozon 服务端错误最多指数退避重试 5 次。
|
||||||
|
- 验证成功回写 `active / valid`;密钥被替换或门店删除时,旧任务取消且不得覆盖新状态。前端在验证中只显示一个标签,避免与“未验证”同时出现。
|
||||||
|
|
||||||
|
### 1.5 开发进度(2026-09-04):水印模板 API
|
||||||
|
|
||||||
|
- 已实现字体目录、按工作空间隔离的水印模板列表/详情/创建/编辑/软删除,以及店铺推荐水印绑定与解绑。文字/图片模板均创建不可变渲染版本;仅改名不新增版本,渲染参数或类型变化会新增版本。
|
||||||
|
- 删除模板会解除所有当前店铺绑定,但不删除历史版本或图片资产。模板与店铺写操作均要求 `Idempotency-Key`;编辑要求 `If-Match`。
|
||||||
|
- 图片模板只可引用当前工作空间内、已完成安全校验的 `media_asset`;素材直传、图片校验和短时预览 URL 的实现见 1.6,前端仍不能自行拼接对象存储地址。
|
||||||
|
|
||||||
|
### 1.6 开发进度(2026-09-04):水印图片素材直传
|
||||||
|
|
||||||
|
- 已实现仅供 `watermark_image` 使用的两阶段素材接口:创建 10 分钟有效的 MinIO/S3 预签名 PUT 上传意图、完成上传后验证对象存在、大小、SHA-256、PNG/JPEG/WebP 魔数与 1–8192 px 尺寸,并创建或复用工作空间内的 `media_asset`。
|
||||||
|
- 已实现素材短时预览 URL(10 分钟);普通 API 返回永不包含 bucket、object key 或长期 URL。图片模板只接受当前工作空间内、类型为 PNG/JPEG/WebP 的已验证素材。
|
||||||
|
- 本地环境默认使用 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 连接 `http://localhost:9000`;生产必须显式配置最小权限的 `OBJECT_STORAGE_ACCESS_KEY` / `OBJECT_STORAGE_SECRET_KEY`。
|
||||||
|
|
||||||
|
### 1.7 开发进度(2026-09-04):上传意图清理 Worker
|
||||||
|
|
||||||
|
- Worker 启动时立即执行、之后每 5 分钟扫描一次所有工作空间:将超过有效期仍为 `pending` 的上传意图置为 `expired`。
|
||||||
|
- 对 `failed` / `expired` 意图,Worker 删除其 MinIO/S3 临时对象;对象不存在也视为已清理。仅对象存储删除成功才写入 `object_cleaned_at`,删除暂时失败会保留记录并在下次扫描重试。
|
||||||
|
- 新增迁移 `007_media_upload_cleanup.sql`,该字段与清理索引必须在部署 Worker 前执行。
|
||||||
|
|
||||||
|
### 1.8 开发进度(2026-09-04):M2.1 采集箱基础闭环
|
||||||
|
|
||||||
|
- 已新增 `008_collection_box_foundation.sql` 与 `009_fix_collection_limit_function.sql`,并在本地 PostgreSQL 实际执行。迁移建立工作空间隔离的采集记录、商品草稿、默认 SKU 与来源素材转存任务;采集箱默认上限为 1000 条,数据库触发器在并发创建/恢复时锁定工作空间并强制限制。
|
||||||
|
- 平台能力现有固定键:`can_be_source`、`can_be_target`、`can_be_header_context`。Ozon 是首期目标和页头平台;1688、淘宝、天猫仅作为采集来源,API 平台目录不会把它们暴露给 ERP 页头选择器。
|
||||||
|
- API 已实现手动创建采集草稿、按页头平台列出采集箱、备注的 `If-Match` 更新、批量删除、清空和失败素材重试入口。采集记录始终不绑定目标平台或店铺;本阶段列表的上架摘要固定为“等待首次上架”,不冒充 Ozon 前台可见性。
|
||||||
|
- 批量删除和清空会软删除 Atlas 采集草稿、创建 `operation` 与 Outbox 事件;Worker 取消尚未执行的素材任务并完成清理操作。它们绝不删除或下架平台商品。
|
||||||
|
- Web 的采集箱已替换占位页,支持容量显示、标题/来源筛选、手动创建、批量删除、清空确认、空/加载/错误状态。商品编辑入口明确保留给下一任务,未伪造可发布能力。
|
||||||
|
- `apps/api/test/collection.e2e.test.ts` 覆盖手动创建、幂等重放、列表读取、乐观锁冲突和软删除;该测试、本次受影响的 API/Worker/Web TypeScript 检查及 Web 生产构建均已通过。任务清单见 [`m2.1-collection-box-foundation.md`](./m2.1-collection-box-foundation.md)。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
@@ -99,19 +139,18 @@ User → Workspace → Membership / Role
|
|||||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||||
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
||||||
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||||
- DDL 已通过本地隔离 PostgreSQL 执行验证;迁移执行器、认证/工作空间基础、首个幂等与 Outbox 写入闭环以及上述门店 HTTP 模块已实现。Outbox Dispatcher 与平台 Worker 仍未实现。
|
- DDL 已通过本地隔离 PostgreSQL 执行验证;迁移执行器、认证/工作空间基础、幂等与 Outbox 写入闭环、Outbox Dispatcher、Ozon 凭据验证 Worker、上传意图清理 Worker 与采集记录清理 Worker 均已实现。仓库同步、来源素材转存、发布和在线商品同步 Worker 仍待实现。
|
||||||
|
|
||||||
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
||||||
|
|
||||||
## 6. 下一步建议
|
## 6. 下一步建议
|
||||||
|
|
||||||
当前优先完成门店纵向闭环,而不是继续扩展商品接口:
|
下一步建议按采集入口顺序推进:
|
||||||
|
|
||||||
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
|
1. M2.2:实现扩展 PKCE 授权、`POST /api/extension/materials` 与来源素材转存 Worker;素材只传来源 URL 和净化证据,不传 Cookie、店铺凭据或二进制。
|
||||||
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
2. M2.3:实现插件悬浮窗与 Ozon 公开商品页解析器,验证真实 SKU 组合、素材关联和重复采集复用。
|
||||||
3. ~~Web 接入真实认证和门店管理 API~~ 已基本完成:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换/删除均已上线(见 1.2);剩余水印绑定入口待素材/水印模块交付后接入。前端不得保存 API Key 或 refresh token。
|
3. M2.4:实现 1688、淘宝、天猫解析器及各自的稳定 SKU 证据规则。
|
||||||
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
4. 进入商品编辑/在线同步前,关闭评审中的 P1:SKU 与平台发布配置同商品归属、未映射属性持久化、统一乐观锁 Repository 规则;平台能力 Schema 已在 M2.1 按固定键落地。在线商品归档策略和 Client ID 编辑边界仍待确认。
|
||||||
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
|
||||||
|
|
||||||
账户资料的已确认补充:`users.avatar_asset_id` 只保存素材引用,绝不保存公开 URL;`users.revision` 用于 `PATCH /api/auth/me` 的 `If-Match`。资料更新已实现幂等与版本冲突检测;修改密码已实现旧密码校验、按 IP/用户限流、撤销旧会话并轮换新会话。头像上传必须复用素材模块的预签名直传与校验流程,不引入 base64 直传;素材模块尚未实现时头像只能为 `null`。
|
账户资料的已确认补充:`users.avatar_asset_id` 只保存素材引用,绝不保存公开 URL;`users.revision` 用于 `PATCH /api/auth/me` 的 `If-Match`。资料更新已实现幂等与版本冲突检测;修改密码已实现旧密码校验、按 IP/用户限流、撤销旧会话并轮换新会话。头像上传必须复用素材模块的预签名直传与校验流程,不引入 base64 直传;素材模块尚未实现时头像只能为 `null`。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# M2.1:采集箱基础闭环(手动创建)
|
||||||
|
|
||||||
|
> 状态:进行中。负责人:当前全栈开发。开始日期:2026-09-04。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
交付一个工作空间级采集箱,使用户能够在 ERP 手动创建商品草稿、查看和管理采集记录;为后续浏览器插件上报、素材转存和商品编辑建立真实、隔离且可恢复的数据边界。
|
||||||
|
|
||||||
|
采集箱记录不绑定目标平台、门店、店铺凭据或平台 Cookie。来源平台与未来发布目标平台是两个独立概念;列表中的上架摘要仅按页头当前目标平台计算,不能代表采集状态或 Ozon 前台可见性。
|
||||||
|
|
||||||
|
## 本任务范围
|
||||||
|
|
||||||
|
### 数据与平台能力
|
||||||
|
|
||||||
|
- 新增采集记录、商品草稿、默认 SKU 与素材转存任务的最小迁移;所有业务行经 `workspace_id` 受 RLS 保护。
|
||||||
|
- 工作空间采集箱默认上限为 1000 条;数据库锁与约束必须防止并发创建或恢复绕过限制。
|
||||||
|
- 同一工作空间中,后续来源采集将以 `source_platform + source_item_key` 复用活动记录;手动创建使用独立身份,不伪造来源。
|
||||||
|
- 平台能力固定为 `can_be_source`、`can_be_target`、`can_be_header_context`。Ozon 是首期目标/页头平台;1688、淘宝、天猫仅为来源平台,不能出现在页头选择器。
|
||||||
|
- 采集记录的备注更新遵守 `If-Match` 乐观锁;创建、删除、清空遵守幂等与 Transactional Outbox。
|
||||||
|
|
||||||
|
### API 与 Worker
|
||||||
|
|
||||||
|
- `GET /api/collection-items`:必须带 `X-Atlas-Platform-Id`,按标题、来源和当前平台上架摘要筛选,返回容量、缩略图处理状态和采集状态。
|
||||||
|
- `POST /api/collection-items`:创建 `manual` 采集记录、商品草稿和默认 SKU;不设置目标平台或门店。
|
||||||
|
- `PATCH /api/collection-items/{id}`:本阶段仅允许修改备注。
|
||||||
|
- `POST /api/collection-items/bulk-delete`、`POST /api/collection-items/clear`:软删除 Atlas 草稿,返回延迟清理操作;不得删除、下架或隐藏任何平台商品。
|
||||||
|
- `POST /api/collection-items/{id}/asset-retries`:先实现受限校验和操作创建;实际来源素材下载在 M2.2 接入。
|
||||||
|
- Worker 消费采集清理事件,取消未执行的素材任务并完成操作;不得物理删除仍被引用的素材。
|
||||||
|
|
||||||
|
### Web
|
||||||
|
|
||||||
|
- 用真实采集箱页面替换占位页:容量进度、标题搜索、来源/上架状态筛选、分页、加载/空/错误状态。
|
||||||
|
- 实现手动创建、备注编辑、单条与批量删除、清空的确认流程,并明确删除不影响平台商品。
|
||||||
|
- 同时展示 `collection_status` 与 `listing_summary`,不混用两种状态。
|
||||||
|
- “编辑上架”保留为下一项商品编辑任务的正式入口;本任务不会伪造已可发布的编辑能力。
|
||||||
|
|
||||||
|
## 非范围
|
||||||
|
|
||||||
|
- 扩展 PKCE 授权、`POST /api/extension/materials`、来源页面解析器与 Ozon Seller 本地观察。
|
||||||
|
- 来源图片下载、校验、去重和对象存储转存的实际 Worker。
|
||||||
|
- 商品编辑、平台发布配置、定价、首次发布与在线商品同步。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 新建手动商品后可在刷新后的列表中读取,并始终至少存在一个默认 SKU。
|
||||||
|
2. 跨工作空间不能读取、修改或删除采集记录;并发创建无法超出配置容量。
|
||||||
|
3. 相同幂等键重放得到历史响应,不同请求复用同一键返回 `409`;过期 revision 返回 `409 VERSION_CONFLICT`。
|
||||||
|
4. 删除、批量删除和清空仅软删除 Atlas 采集草稿,确认文案和 API 均不暗示平台商品会被删除或下架。
|
||||||
|
5. API 集成测试、受影响工作区 TypeScript 检查与 Web 生产构建通过。
|
||||||
|
|
||||||
|
## 后续顺序
|
||||||
|
|
||||||
|
1. M2.2:扩展 PKCE 授权、插件上报接口与来源素材转存 Worker。
|
||||||
|
2. M2.3:插件悬浮窗与 Ozon 公开商品页解析器。
|
||||||
|
3. M2.4:1688、淘宝、天猫解析器与真实 SKU 证据规则。
|
||||||
|
4. M3:商品编辑、平台发布配置、定价与首次上架。
|
||||||
@@ -1,5 +1,36 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const uuid = z.uuid();
|
||||||
|
|
||||||
|
export const createCollectionItemRequestSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(300).optional(),
|
||||||
|
note: z.string().trim().max(2_000).nullable().optional(),
|
||||||
|
}).strict();
|
||||||
|
export const updateCollectionItemRequestSchema = z.object({
|
||||||
|
note: z.string().trim().max(2_000).nullable(),
|
||||||
|
}).strict();
|
||||||
|
export const listCollectionItemsQuerySchema = z.object({
|
||||||
|
cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
q: z.string().trim().min(1).max(300).optional(),
|
||||||
|
source_platform_code: z.enum(['OZON', '1688', 'TAOBAO', 'TMALL']).optional(),
|
||||||
|
listing_status: z.enum(['not_listed', 'partially_listed', 'listed']).optional(),
|
||||||
|
}).strict();
|
||||||
|
export const bulkDeleteCollectionItemsRequestSchema = z.object({ ids: z.array(uuid).min(1).max(100) }).strict();
|
||||||
|
export const clearCollectionItemsRequestSchema = z.object({ confirm: z.literal(true), expected_active_count: z.number().int().min(0) }).strict();
|
||||||
|
export const retryCollectionAssetsRequestSchema = z.object({ transfer_ids: z.array(uuid).min(1).max(100) }).strict();
|
||||||
|
export type CreateCollectionItemRequest = z.infer<typeof createCollectionItemRequestSchema>;
|
||||||
|
export type UpdateCollectionItemRequest = z.infer<typeof updateCollectionItemRequestSchema>;
|
||||||
|
export type ListCollectionItemsQuery = z.infer<typeof listCollectionItemsQuerySchema>;
|
||||||
|
|
||||||
|
export type CollectionItem = {
|
||||||
|
id: string; product_id: string; source: { kind: 'platform' | 'manual'; platform_code: string | null; url: string | null };
|
||||||
|
title: string | null; thumbnail: { asset_id: string | null; preview_url: string | null; status: string };
|
||||||
|
source_price: { min: string | null; max: string | null; currency_code: string | null };
|
||||||
|
variant_summary: { count: number; needs_review: boolean }; note: string | null;
|
||||||
|
collection_status: string; listing_summary: { status: 'not_listed' | 'partially_listed' | 'listed'; detail: string; last_error: null };
|
||||||
|
created_at: string; updated_at: string; revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浏览器扩展与 API 共享的采集载荷最小契约。
|
* 浏览器扩展与 API 共享的采集载荷最小契约。
|
||||||
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
|
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './collection';
|
export * from './collection';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './stores';
|
export * from './stores';
|
||||||
|
export * from './watermarks';
|
||||||
|
export * from './media';
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const checksum = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-f0-9]{64}$/, '必须是 SHA-256 十六进制摘要');
|
||||||
|
export const createMediaUploadIntentRequestSchema = z
|
||||||
|
.object({
|
||||||
|
purpose: z.literal('watermark_image'),
|
||||||
|
filename: z.string().trim().min(1).max(255),
|
||||||
|
content_type: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
||||||
|
byte_size: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(5 * 1024 * 1024),
|
||||||
|
checksum_sha256: checksum,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const completeMediaUploadIntentRequestSchema = z
|
||||||
|
.object({
|
||||||
|
checksum_sha256: checksum,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const createMediaAccessUrlRequestSchema = z
|
||||||
|
.object({
|
||||||
|
purpose: z.literal('preview'),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateMediaUploadIntentRequest = z.infer<
|
||||||
|
typeof createMediaUploadIntentRequestSchema
|
||||||
|
>;
|
||||||
|
export type CompleteMediaUploadIntentRequest = z.infer<
|
||||||
|
typeof completeMediaUploadIntentRequestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ── 只读响应类型(由前端 AI 按 docs/api/watermarks-and-assets.md §1 补充,后端请复核) ──
|
||||||
|
|
||||||
|
/** 预签名直传端点:前端必须按返回的 method/url/headers 原样上传 */
|
||||||
|
export type MediaUploadEndpoint = {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** POST /api/media/upload-intents 响应 */
|
||||||
|
export type MediaUploadIntent = {
|
||||||
|
upload_id: string;
|
||||||
|
expires_at: string;
|
||||||
|
upload: MediaUploadEndpoint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 校验通过后创建/复用的素材 */
|
||||||
|
export type MediaAsset = {
|
||||||
|
id: string;
|
||||||
|
content_type: string;
|
||||||
|
byte_size: number;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
|
||||||
|
export type MediaAccessUrl = {
|
||||||
|
url: string;
|
||||||
|
expires_at: string;
|
||||||
|
};
|
||||||
@@ -84,6 +84,8 @@ export type Platform = {
|
|||||||
supported_currencies: string[];
|
supported_currencies: string[];
|
||||||
default_market_code: string | null;
|
default_market_code: string | null;
|
||||||
default_locale: string | null;
|
default_locale: string | null;
|
||||||
|
/** 平台新建店铺时预选的计价币种;未配置时前端回退到第一项可用币种。 */
|
||||||
|
default_currency_code: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** GET /api/platform-context 返回的页头上下文;没有默认店铺不是错误。 */
|
/** GET /api/platform-context 返回的页头上下文;没有默认店铺不是错误。 */
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const uuid = z.uuid();
|
||||||
|
const opacity = z
|
||||||
|
.string()
|
||||||
|
.regex(/^(0|1)(\.\d{1,4})?$|^0\.\d{1,4}$/, '不透明度必须是 0 到 1 的小数');
|
||||||
|
const scaleRatio = z
|
||||||
|
.string()
|
||||||
|
.regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间');
|
||||||
|
export type WatermarkPosition = z.infer<typeof watermarkPositionSchema>;
|
||||||
|
export const watermarkPositionSchema = z.enum([
|
||||||
|
'top_left', 'top_center', 'top_right', 'middle_left', 'center',
|
||||||
|
'middle_right', 'bottom_left', 'bottom_center', 'bottom_right',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const textConfig = z.object({
|
||||||
|
text_content: z.string().trim().min(1).max(200),
|
||||||
|
font_code: z.string().trim().min(1).max(120),
|
||||||
|
font_size_px: z.number().int().min(12).max(60),
|
||||||
|
opacity,
|
||||||
|
position: watermarkPositionSchema,
|
||||||
|
}).strict();
|
||||||
|
const imageConfig = z.object({
|
||||||
|
image_asset_id: uuid,
|
||||||
|
scale_ratio: scaleRatio,
|
||||||
|
opacity,
|
||||||
|
position: watermarkPositionSchema,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const createWatermarkTemplateRequestSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
kind: z.enum(['text', 'image']),
|
||||||
|
config: z.union([textConfig, imageConfig]),
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
if (value.kind === 'text' && !('text_content' in value.config)) {
|
||||||
|
context.addIssue({ code: 'custom', path: ['config'], message: '文字水印需要完整文字配置' });
|
||||||
|
}
|
||||||
|
if (value.kind === 'image' && !('image_asset_id' in value.config)) {
|
||||||
|
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const partialConfig = z.object({
|
||||||
|
text_content: z.string().trim().min(1).max(200).optional(),
|
||||||
|
image_asset_id: uuid.optional(),
|
||||||
|
font_code: z.string().trim().min(1).max(120).optional(),
|
||||||
|
font_size_px: z.number().int().min(12).max(60).optional(),
|
||||||
|
opacity: opacity.optional(),
|
||||||
|
scale_ratio: scaleRatio.optional(),
|
||||||
|
position: watermarkPositionSchema.optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const updateWatermarkTemplateRequestSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120).optional(),
|
||||||
|
kind: z.enum(['text', 'image']).optional(),
|
||||||
|
config: partialConfig.optional(),
|
||||||
|
}).strict()
|
||||||
|
.refine((value) => Object.keys(value).length > 0, { message: '至少需要提交一个可修改字段' });
|
||||||
|
|
||||||
|
export const listWatermarkTemplatesQuerySchema = z.object({
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const bindStoreWatermarkRequestSchema = z.object({
|
||||||
|
template_id: uuid.nullable(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export type CreateWatermarkTemplateRequest = z.infer<typeof createWatermarkTemplateRequestSchema>;
|
||||||
|
export type UpdateWatermarkTemplateRequest = z.infer<typeof updateWatermarkTemplateRequestSchema>;
|
||||||
|
export type ListWatermarkTemplatesQuery = z.infer<typeof listWatermarkTemplatesQuerySchema>;
|
||||||
|
|
||||||
|
// ── 只读响应类型(由前端 AI 按 docs/api/watermarks-and-assets.md §2/§3 补充,后端请复核) ──
|
||||||
|
|
||||||
|
/** GET /api/watermark-fonts 返回的渲染服务可用字体 */
|
||||||
|
export type WatermarkFont = {
|
||||||
|
code: string;
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 图片水印模板引用的素材摘要(含短时预览 URL,过期后重新读取列表/详情) */
|
||||||
|
export type WatermarkTemplateImageAsset = {
|
||||||
|
id: string;
|
||||||
|
preview_url: string;
|
||||||
|
preview_url_expires_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 模板当前版本的渲染配置;kind=text 时为文字字段,kind=image 时为图片字段 */
|
||||||
|
export type WatermarkTemplateConfig = {
|
||||||
|
text_content?: string;
|
||||||
|
font_code?: string;
|
||||||
|
font_size_px?: number;
|
||||||
|
image_asset_id?: string;
|
||||||
|
image_asset?: WatermarkTemplateImageAsset;
|
||||||
|
opacity?: string;
|
||||||
|
scale_ratio?: string;
|
||||||
|
position: WatermarkPosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 水印模板读取形状(revision 用于 If-Match 乐观锁;current_version 随渲染参数变更递增) */
|
||||||
|
export type WatermarkTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'text' | 'image';
|
||||||
|
current_version: number;
|
||||||
|
config: WatermarkTemplateConfig;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Ozon 店铺默认以人民币计价;保留 RUB 作为平台支持币种。
|
||||||
|
-- 使用独立迁移,确保已初始化的数据库也会获得该配置。
|
||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
INSERT INTO platform_currency_support (platform_id, currency_code)
|
||||||
|
SELECT id, 'CNY'::CHAR(3)
|
||||||
|
FROM platforms
|
||||||
|
WHERE code = 'OZON'
|
||||||
|
ON CONFLICT (platform_id, currency_code) DO UPDATE
|
||||||
|
SET is_enabled = true;
|
||||||
|
|
||||||
|
UPDATE platforms
|
||||||
|
SET capabilities = capabilities || jsonb_build_object('default_currency_code', 'CNY'),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE code = 'OZON';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- 首期文字水印使用随渲染运行时部署的 Noto Sans 字体。
|
||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
INSERT INTO watermark_fonts (code, display_name, status)
|
||||||
|
VALUES ('NotoSans', 'Noto Sans', 'active')
|
||||||
|
ON CONFLICT (code) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
status = 'active';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 上传意图到达终态后,Worker 需要可靠记录临时对象是否已从对象存储清理。
|
||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
ALTER TABLE media_upload_intents
|
||||||
|
ADD COLUMN object_cleaned_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX ix_media_upload_intents_cleanup
|
||||||
|
ON media_upload_intents (workspace_id, status, created_at)
|
||||||
|
WHERE status IN ('failed', 'expired') AND object_cleaned_at IS NULL;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- Fixed platform-capability schema used by collection sources and the ERP header.
|
||||||
|
UPDATE platforms
|
||||||
|
SET capabilities = capabilities || jsonb_build_object(
|
||||||
|
'can_be_source', true, 'can_be_target', true, 'can_be_header_context', true
|
||||||
|
), updated_at = now()
|
||||||
|
WHERE code = 'OZON';
|
||||||
|
|
||||||
|
INSERT INTO platforms (code, name, capabilities)
|
||||||
|
VALUES
|
||||||
|
('1688', '1688', '{"can_be_source": true, "can_be_target": false, "can_be_header_context": false}'::jsonb),
|
||||||
|
('TAOBAO', '淘宝', '{"can_be_source": true, "can_be_target": false, "can_be_header_context": false}'::jsonb),
|
||||||
|
('TMALL', '天猫', '{"can_be_source": true, "can_be_target": false, "can_be_header_context": false}'::jsonb)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET capabilities = platforms.capabilities || EXCLUDED.capabilities, updated_at = now();
|
||||||
|
|
||||||
|
CREATE TYPE collection_source_kind AS ENUM ('platform', 'manual');
|
||||||
|
CREATE TYPE collection_item_status AS ENUM ('received', 'materializing', 'ready_for_edit', 'needs_review', 'failed', 'archived', 'deleted');
|
||||||
|
CREATE TYPE processing_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||||
|
CREATE TYPE media_candidate_role AS ENUM ('source_main', 'source_sku', 'source_detail');
|
||||||
|
|
||||||
|
ALTER TABLE workspaces ADD COLUMN collection_item_limit INTEGER NOT NULL DEFAULT 1000 CHECK (collection_item_limit >= 0);
|
||||||
|
|
||||||
|
CREATE TABLE collection_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
source_kind collection_source_kind NOT NULL, source_platform_id UUID REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||||
|
source_item_key TEXT NOT NULL, source_url TEXT, source_title TEXT, source_currency_code CHAR(3) REFERENCES currencies(code),
|
||||||
|
source_min_price NUMERIC(18,4) CHECK (source_min_price IS NULL OR source_min_price >= 0),
|
||||||
|
source_max_price NUMERIC(18,4) CHECK (source_max_price IS NULL OR source_max_price >= source_min_price),
|
||||||
|
note TEXT, status collection_item_status NOT NULL DEFAULT 'received', raw_evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb, parser_version TEXT, collected_at TIMESTAMPTZ,
|
||||||
|
last_error JSONB, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), UNIQUE (workspace_id, id),
|
||||||
|
CHECK ((source_kind = 'platform' AND source_platform_id IS NOT NULL) OR (source_kind = 'manual' AND source_platform_id IS NULL)),
|
||||||
|
CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX ux_collection_items_active_source_identity ON collection_items (workspace_id, source_platform_id, source_item_key) WHERE source_platform_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX ix_collection_items_workspace_list ON collection_items (workspace_id, updated_at DESC, id DESC) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_collection_item_limit() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE configured_limit INTEGER; active_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL) OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
|
||||||
|
SELECT collection_item_limit INTO configured_limit FROM workspaces WHERE id = NEW.workspace_id FOR UPDATE;
|
||||||
|
SELECT count(*) INTO active_count FROM collection_items WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL;
|
||||||
|
IF active_count >= configured_limit THEN RAISE EXCEPTION 'collection item limit reached' USING ERRCODE = 'check_violation'; END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END; $$;
|
||||||
|
CREATE TRIGGER enforce_collection_item_limit_before_write BEFORE INSERT OR UPDATE OF deleted_at ON collection_items FOR EACH ROW EXECUTE FUNCTION enforce_collection_item_limit();
|
||||||
|
|
||||||
|
CREATE TABLE products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL,
|
||||||
|
source_title TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), version BIGINT NOT NULL DEFAULT 1,
|
||||||
|
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (workspace_id, id), UNIQUE (workspace_id, collection_item_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE product_variants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL,
|
||||||
|
source_variant_key TEXT, display_name TEXT, source_sku TEXT, sort_order INTEGER NOT NULL DEFAULT 0, deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_product_variants_product ON product_variants (workspace_id, product_id, sort_order) WHERE deleted_at IS NULL;
|
||||||
|
CREATE TABLE collection_asset_transfers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL, product_id UUID NOT NULL,
|
||||||
|
source_variant_id UUID, role media_candidate_role NOT NULL, source_url TEXT NOT NULL, status processing_status NOT NULL DEFAULT 'queued',
|
||||||
|
asset_id UUID, error_payload JSONB, task_key UUID NOT NULL DEFAULT gen_random_uuid(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ,
|
||||||
|
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, task_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$ DECLARE table_name TEXT; BEGIN
|
||||||
|
FOREACH table_name IN ARRAY ARRAY['collection_items', 'products', 'product_variants', 'collection_asset_transfers'] LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||||
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||||
|
EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION atlas.enforce_collection_item_limit() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE configured_limit INTEGER; active_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL) OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
|
||||||
|
SELECT collection_item_limit INTO configured_limit FROM atlas.workspaces WHERE id = NEW.workspace_id FOR UPDATE;
|
||||||
|
SELECT count(*) INTO active_count FROM atlas.collection_items WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL;
|
||||||
|
IF active_count >= configured_limit THEN RAISE EXCEPTION 'collection item limit reached' USING ERRCODE = 'check_violation'; END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END; $$;
|
||||||
Generated
+5392
-5992
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user