7 Commits

Author SHA1 Message Date
R524809 87db74fb89 feat(collection): 采集箱开发 2026-09-04 17:32:28 +08:00
R524809 edf7939963 feat(watermark): 水印前后端代码修改 2026-09-04 16:48:32 +08:00
R524809 f635359216 feat(api-watermark): 水印接口优化 2026-09-04 15:58:53 +08:00
R524809 e277754588 feat(web-water): 水印设置页面开发 2026-09-04 15:57:16 +08:00
R524809 009082d0f4 feat(api-water): 支持上传水印图片 2026-09-04 14:27:59 +08:00
R524809 c2523fe349 feat(api-watermark): 添加水印管理相关接口 2026-09-04 12:45:27 +08:00
R524809 70d9d5b92a feat(store): 添加门店、门店验证 2026-09-04 10:27:29 +08:00
60 changed files with 9588 additions and 6130 deletions
+8
View File
@@ -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.
View File
+3 -1
View File
@@ -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)。
## 设计目标 ## 设计目标
+2
View File
@@ -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",
+6
View File
@@ -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', '分页游标无效'); } }
+24
View File
@@ -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',
}; };
} }
+132
View File
@@ -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);
}
+11
View File
@@ -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 {}
+537
View File
@@ -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;
}
+5
View File
@@ -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',
);
}
+28 -1
View File
@@ -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', '分页游标无效');
}
}
+17
View File
@@ -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); });
});
+1
View File
@@ -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"
+87
View File
@@ -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;
+1 -5
View File
@@ -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 {
+1 -7
View File
@@ -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>;
}
+167 -55
View File
@@ -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) {
} else { await storesApi.bindWatermark(storeId, bindingAfter);
message.success('店铺已保存');
} }
if (result && 'operation' in result && result.operation) {
message.success('已保存,正在验证新密钥,结果将自动刷新');
setEditingStore(null); setEditingStore(null);
await refreshAll(); await refreshAll();
void pollOperation(result.operation.id);
} else {
message.success('店铺已保存');
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;
+4 -2
View File
@@ -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;
} }
+9
View File
@@ -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() } }),
};
+71
View File
@@ -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;
}
+10
View File
@@ -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}`),
};
+1 -3
View File
@@ -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>
); );
} }
+14 -5
View File
@@ -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() },
}
),
}; };
+58
View File
@@ -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() },
}),
};
+4
View File
@@ -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"
}, },
+70
View File
@@ -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());
+46
View File
@@ -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',
),
};
+287 -17
View File
@@ -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 的要求
const connection = new IORedis(
process.env.REDIS_URL ?? 'redis://localhost:6379',
{
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
}, });
const database = createApplicationDatabase(workerConfig.databaseUrl);
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
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());
+107
View File
@@ -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],
),
);
}
}
+94
View File
@@ -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;
}
}
}
+46
View File
@@ -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);
}
+20
View File
@@ -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
View File
@@ -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 首次上架。 |
+28 -1
View File
@@ -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}`
+4 -4
View File
@@ -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. 可用字体
+14 -13
View File
@@ -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 分钟
} }
``` ```
+26
View File
@@ -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
View File
@@ -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:商品编辑、平台发布配置、定价与首次上架。
+31
View File
@@ -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 请求。
+2
View File
@@ -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';
+67
View File
@@ -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;
};
+2
View File
@@ -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 返回的页头上下文;没有默认店铺不是错误。 */
+110
View File
@@ -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; $$;
+1619 -2219
View File
File diff suppressed because it is too large Load Diff