feat(collection): 采集箱开发
This commit is contained in:
@@ -9,6 +9,7 @@ import { SecurityModule } from './security/security.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()
|
||||
class HealthController {
|
||||
@@ -45,6 +46,7 @@ class HealthController {
|
||||
StoresModule,
|
||||
WatermarksModule,
|
||||
MediaModule,
|
||||
CollectionModule,
|
||||
],
|
||||
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,6 +24,7 @@ export class PlatformsService {
|
||||
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
||||
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||
WHERE p.status = 'active'
|
||||
AND coalesce((p.capabilities->>'can_be_header_context')::boolean, false)
|
||||
GROUP BY p.id
|
||||
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||
);
|
||||
|
||||
@@ -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,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 CollectionPage = () => (
|
||||
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
|
||||
);
|
||||
|
||||
export default CollectionPage;
|
||||
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
|
||||
export default function CollectionPage() {
|
||||
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]);
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from 'antd';
|
||||
import type { TableProps, UploadFile } from 'antd';
|
||||
import {
|
||||
CopyOutlined,
|
||||
FileImageOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -165,6 +164,21 @@ const WatermarksPage = () => {
|
||||
}
|
||||
}, [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) => {
|
||||
@@ -232,6 +246,42 @@ const WatermarksPage = () => {
|
||||
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> => {
|
||||
@@ -307,7 +357,7 @@ const WatermarksPage = () => {
|
||||
}
|
||||
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
|
||||
if (values.text_content?.trim() !== current.text_content) {
|
||||
configPatch.text_content = values.text_content?.trim() ?? null;
|
||||
configPatch.text_content = values.text_content.trim();
|
||||
}
|
||||
if (values.font_code !== current.font_code) {
|
||||
configPatch.font_code = values.font_code;
|
||||
@@ -332,7 +382,8 @@ const WatermarksPage = () => {
|
||||
diff.name = values.name.trim();
|
||||
}
|
||||
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
|
||||
if (imageAsset && imageAsset.id !== current.image_asset?.id) {
|
||||
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) {
|
||||
@@ -436,9 +487,7 @@ const WatermarksPage = () => {
|
||||
let url = assetPreviewUrls[assetId];
|
||||
if (!url) {
|
||||
try {
|
||||
const access = await mediaApi.createPreviewUrl(assetId);
|
||||
url = access.url;
|
||||
setAssetPreviewUrls((prev) => ({ ...prev, [assetId]: url }));
|
||||
url = await loadAssetPreviewUrl(assetId);
|
||||
} catch {
|
||||
// 换取失败按原样打开,图片渲染失败会显示不可用提示
|
||||
}
|
||||
@@ -469,6 +518,7 @@ const WatermarksPage = () => {
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
if (targets.length === 1) {
|
||||
await watermarksApi.remove(targets[0].id);
|
||||
message.success('水印模板已删除');
|
||||
@@ -483,7 +533,6 @@ const WatermarksPage = () => {
|
||||
message.error(`${template.name}:${apiErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
setBatchDeleting(false);
|
||||
if (failed === 0) {
|
||||
message.success(`已删除 ${targets.length} 个模板`);
|
||||
} else {
|
||||
@@ -492,6 +541,11 @@ const WatermarksPage = () => {
|
||||
}
|
||||
setSelectedIds([]);
|
||||
await loadTemplates();
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -747,8 +801,10 @@ const WatermarksPage = () => {
|
||||
<Input maxLength={120} placeholder="例如:右下角品牌水印" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="kind" label="水印类型" required>
|
||||
<Form.Item label="水印类型" required>
|
||||
<Select
|
||||
value={previewValues?.kind ?? form.getFieldValue('kind') ?? 'text'}
|
||||
onChange={requestKindChange}
|
||||
options={[
|
||||
{ value: 'text', label: '文字水印' },
|
||||
{ value: 'image', label: '图片水印' },
|
||||
|
||||
@@ -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() } }),
|
||||
};
|
||||
+19
-8
@@ -33,23 +33,34 @@ const objectStorage = new S3Client({
|
||||
},
|
||||
});
|
||||
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
|
||||
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
|
||||
|
||||
const worker = new Worker<OutboxJob>(
|
||||
'atlas-dispatch',
|
||||
async (job) => {
|
||||
if (job.name === 'store_credential_verification.requested') {
|
||||
await verifyStoreCredentials(database, job.data);
|
||||
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)) return;
|
||||
void markUnavailable(database, job.data, 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: OutboxJob) {
|
||||
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
||||
const source = await database.workspaceTransaction(
|
||||
job.workspaceId,
|
||||
async (tx) => {
|
||||
@@ -98,7 +109,7 @@ async function verifyStoreCredentials(database: Database, job: OutboxJob) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelStaleOperation(database: Database, job: OutboxJob) {
|
||||
async function cancelStaleOperation(database: Database, job: StoreVerificationJob) {
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||||
@@ -111,7 +122,7 @@ async function cancelStaleOperation(database: Database, job: OutboxJob) {
|
||||
|
||||
async function completeVerification(
|
||||
database: Database,
|
||||
job: OutboxJob,
|
||||
job: StoreVerificationJob,
|
||||
valid: boolean,
|
||||
rejectedStatus?: 401 | 403,
|
||||
) {
|
||||
@@ -175,7 +186,7 @@ async function completeVerification(
|
||||
|
||||
async function markRequestRejected(
|
||||
database: Database,
|
||||
job: OutboxJob,
|
||||
job: StoreVerificationJob,
|
||||
status: number,
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
@@ -205,7 +216,7 @@ async function markRequestRejected(
|
||||
|
||||
async function markUnavailable(
|
||||
database: Database,
|
||||
job: OutboxJob,
|
||||
job: StoreVerificationJob,
|
||||
_error: Error,
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ export type OutboxJob = {
|
||||
eventId: string;
|
||||
workspaceId: string;
|
||||
eventType: string;
|
||||
payload: { store_id: string; operation_id: string; secret_id?: string };
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type EventRow = Omit<OutboxJob, 'eventId' | 'workspaceId' | 'eventType'> & {
|
||||
|
||||
+16
-8
@@ -6,7 +6,7 @@
|
||||
|
||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||
|
||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;Worker 已具备首个 Ozon 凭据验证消费链路,仓库同步尚未接入。Web 已具备完整前端框架(布局、路由、主题)、登录/注册页、页头平台选择器与店铺管理页(已接入真实 API),其余业务页面为占位;扩展为可运行骨架。不要从旧项目复制 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)。
|
||||
|
||||
@@ -78,6 +78,15 @@
|
||||
- 对 `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. 已确认产品决策
|
||||
|
||||
| 主题 | 决策 |
|
||||
@@ -130,19 +139,18 @@ User → Workspace → Membership / Role
|
||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`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)。
|
||||
- 关键异步动作必须在同一事务写入业务记录、`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)。
|
||||
|
||||
## 6. 下一步建议
|
||||
|
||||
当前优先完成门店纵向闭环,而不是继续扩展商品接口:
|
||||
下一步建议按采集入口顺序推进:
|
||||
|
||||
1. 验证并补齐 Outbox Dispatcher 与凭据验证 Worker 的集成测试:覆盖成功、401/403、限流/超时重试、旧密钥任务取消和无密钥日志。
|
||||
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
||||
3. ~~Web 接入真实认证和门店管理 API~~ 已基本完成:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换/删除均已上线(见 1.2);剩余水印绑定入口待素材/水印模块交付后接入。前端不得保存 API Key 或 refresh token。
|
||||
4. ~~接着实现素材直传和水印管理~~ 已完成后端上传、校验、预览与临时对象清理;接着实现水印实际渲染、采集箱与商品编辑。浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||
1. M2.2:实现扩展 PKCE 授权、`POST /api/extension/materials` 与来源素材转存 Worker;素材只传来源 URL 和净化证据,不传 Cookie、店铺凭据或二进制。
|
||||
2. M2.3:实现插件悬浮窗与 Ozon 公开商品页解析器,验证真实 SKU 组合、素材关联和重复采集复用。
|
||||
3. M2.4:实现 1688、淘宝、天猫解析器及各自的稳定 SKU 证据规则。
|
||||
4. 进入商品编辑/在线同步前,关闭评审中的 P1:SKU 与平台发布配置同商品归属、未映射属性持久化、统一乐观锁 Repository 规则;平台能力 Schema 已在 M2.1 按固定键落地。在线商品归档策略和 Client ID 编辑边界仍待确认。
|
||||
|
||||
账户资料的已确认补充:`users.avatar_asset_id` 只保存素材引用,绝不保存公开 URL;`users.revision` 用于 `PATCH /api/auth/me` 的 `If-Match`。资料更新已实现幂等与版本冲突检测;修改密码已实现旧密码校验、按 IP/用户限流、撤销旧会话并轮换新会话。头像上传必须复用素材模块的预签名直传与校验流程,不引入 base64 直传;素材模块尚未实现时头像只能为 `null`。
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# M2.1:采集箱基础闭环(手动创建)
|
||||
|
||||
> 状态:进行中。负责人:当前全栈开发。开始日期:2026-09-04。
|
||||
|
||||
## 目标
|
||||
|
||||
交付一个工作空间级采集箱,使用户能够在 ERP 手动创建商品草稿、查看和管理采集记录;为后续浏览器插件上报、素材转存和商品编辑建立真实、隔离且可恢复的数据边界。
|
||||
|
||||
采集箱记录不绑定目标平台、门店、店铺凭据或平台 Cookie。来源平台与未来发布目标平台是两个独立概念;列表中的上架摘要仅按页头当前目标平台计算,不能代表采集状态或 Ozon 前台可见性。
|
||||
|
||||
## 本任务范围
|
||||
|
||||
### 数据与平台能力
|
||||
|
||||
- 新增采集记录、商品草稿、默认 SKU 与素材转存任务的最小迁移;所有业务行经 `workspace_id` 受 RLS 保护。
|
||||
- 工作空间采集箱默认上限为 1000 条;数据库锁与约束必须防止并发创建或恢复绕过限制。
|
||||
- 同一工作空间中,后续来源采集将以 `source_platform + source_item_key` 复用活动记录;手动创建使用独立身份,不伪造来源。
|
||||
- 平台能力固定为 `can_be_source`、`can_be_target`、`can_be_header_context`。Ozon 是首期目标/页头平台;1688、淘宝、天猫仅为来源平台,不能出现在页头选择器。
|
||||
- 采集记录的备注更新遵守 `If-Match` 乐观锁;创建、删除、清空遵守幂等与 Transactional Outbox。
|
||||
|
||||
### API 与 Worker
|
||||
|
||||
- `GET /api/collection-items`:必须带 `X-Atlas-Platform-Id`,按标题、来源和当前平台上架摘要筛选,返回容量、缩略图处理状态和采集状态。
|
||||
- `POST /api/collection-items`:创建 `manual` 采集记录、商品草稿和默认 SKU;不设置目标平台或门店。
|
||||
- `PATCH /api/collection-items/{id}`:本阶段仅允许修改备注。
|
||||
- `POST /api/collection-items/bulk-delete`、`POST /api/collection-items/clear`:软删除 Atlas 草稿,返回延迟清理操作;不得删除、下架或隐藏任何平台商品。
|
||||
- `POST /api/collection-items/{id}/asset-retries`:先实现受限校验和操作创建;实际来源素材下载在 M2.2 接入。
|
||||
- Worker 消费采集清理事件,取消未执行的素材任务并完成操作;不得物理删除仍被引用的素材。
|
||||
|
||||
### Web
|
||||
|
||||
- 用真实采集箱页面替换占位页:容量进度、标题搜索、来源/上架状态筛选、分页、加载/空/错误状态。
|
||||
- 实现手动创建、备注编辑、单条与批量删除、清空的确认流程,并明确删除不影响平台商品。
|
||||
- 同时展示 `collection_status` 与 `listing_summary`,不混用两种状态。
|
||||
- “编辑上架”保留为下一项商品编辑任务的正式入口;本任务不会伪造已可发布的编辑能力。
|
||||
|
||||
## 非范围
|
||||
|
||||
- 扩展 PKCE 授权、`POST /api/extension/materials`、来源页面解析器与 Ozon Seller 本地观察。
|
||||
- 来源图片下载、校验、去重和对象存储转存的实际 Worker。
|
||||
- 商品编辑、平台发布配置、定价、首次发布与在线商品同步。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 新建手动商品后可在刷新后的列表中读取,并始终至少存在一个默认 SKU。
|
||||
2. 跨工作空间不能读取、修改或删除采集记录;并发创建无法超出配置容量。
|
||||
3. 相同幂等键重放得到历史响应,不同请求复用同一键返回 `409`;过期 revision 返回 `409 VERSION_CONFLICT`。
|
||||
4. 删除、批量删除和清空仅软删除 Atlas 采集草稿,确认文案和 API 均不暗示平台商品会被删除或下架。
|
||||
5. API 集成测试、受影响工作区 TypeScript 检查与 Web 生产构建通过。
|
||||
|
||||
## 后续顺序
|
||||
|
||||
1. M2.2:扩展 PKCE 授权、插件上报接口与来源素材转存 Worker。
|
||||
2. M2.3:插件悬浮窗与 Ozon 公开商品页解析器。
|
||||
3. M2.4:1688、淘宝、天猫解析器与真实 SKU 证据规则。
|
||||
4. M3:商品编辑、平台发布配置、定价与首次上架。
|
||||
@@ -1,5 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
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 请求。
|
||||
|
||||
@@ -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; $$;
|
||||
Reference in New Issue
Block a user