feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据

This commit is contained in:
R524809
2026-09-18 18:55:16 +08:00
parent fcddb11da0
commit 388429dbd0
58 changed files with 4347 additions and 640 deletions
+2
View File
@@ -12,6 +12,7 @@ import { MediaModule } from './media/media.module.js';
import { CollectionModule } from './collection/collection.module.js';
import { ExtensionModule } from './extension/extension.module.js';
import { ProductEditorModule } from './product-editor/product-editor.module.js';
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
@Controller()
class HealthController {
@@ -51,6 +52,7 @@ class HealthController {
CollectionModule,
ExtensionModule,
ProductEditorModule,
PlatformCatalogAdminModule,
],
controllers: [HealthController],
})
@@ -0,0 +1,219 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
import { encryptStoreSecret } from '../stores/store-secret.crypto.js';
import type { SourceInput, SyncInput } from './platform-catalog-admin.models.js';
type RunRow = {
id: string;
platform_id: string;
scope: string;
scope_key: string;
status: string;
trigger_kind: string;
created_at: Date;
};
@Injectable()
export class PlatformCatalogAdminCommands {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
public async configureSource(platformId: string, userId: string, input: SourceInput) {
const encrypted = encryptStoreSecret(input.api_key, this.config.STORE_SECRET_ENCRYPTION_KEY);
return this.database.transaction(async (tx) => {
const platform = await tx.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
[platformId],
);
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
}
const existing = await tx.query<{
client_id: string; secret_fingerprint: string; revision: number;
}>(
`SELECT client_id, secret_fingerprint, revision
FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
const unchanged = existing.rows[0]?.client_id === input.client_id &&
existing.rows[0]?.secret_fingerprint === encrypted.fingerprint;
const revision = unchanged ? existing.rows[0].revision : (existing.rows[0]?.revision ?? 0) + 1;
if (!unchanged) {
await tx.query(
`INSERT INTO atlas.platform_catalog_sources
(platform_id, client_id, secret_ciphertext, secret_nonce,
secret_fingerprint, encryption_key_version, revision, updated_by_user_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (platform_id) DO UPDATE
SET client_id = EXCLUDED.client_id,
secret_ciphertext = EXCLUDED.secret_ciphertext,
secret_nonce = EXCLUDED.secret_nonce,
secret_fingerprint = EXCLUDED.secret_fingerprint,
encryption_key_version = EXCLUDED.encryption_key_version,
revision = EXCLUDED.revision,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()`,
[platformId, input.client_id, encrypted.ciphertext, encrypted.nonce,
encrypted.fingerprint, this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
revision, userId],
);
// 旧来源的任务即使已进队列,也不能拿新凭据执行。
await tx.query(
`UPDATE atlas.platform_catalog_sync_runs
SET status = 'cancelled', completed_at = now(),
error_code = 'CATALOG_SOURCE_REPLACED', error_message = '同步来源已更新'
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
[platformId],
);
await enqueueRun(tx, {
platformId, scope: 'tree', scopeKey: 'tree', sourceRevision: revision,
trigger: 'source_configured', userId,
});
}
return {
configured: true,
client_id_hint: `••••${input.client_id.slice(-4)}`,
revision,
changed: !unchanged,
};
});
}
public async sync(platformId: string, userId: string, idempotencyKey: string, input: SyncInput) {
return this.database.transaction(async (tx) => {
const platform = await tx.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
[platformId],
);
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
}
const source = await tx.query<{ revision: number }>(
'SELECT revision FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE',
[platformId],
);
if (!source.rows[0]) {
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
}
const target = await this.resolveTarget(tx, platformId, input);
const prior = await tx.query<RunRow>(
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
run.trigger_kind, run.created_at
FROM atlas.platform_catalog_sync_requests request
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
[userId, idempotencyKey],
);
if (prior.rows[0]) {
if (prior.rows[0].platform_id !== platformId || prior.rows[0].scope_key !== target.scopeKey) {
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他同步请求');
}
return presentRun(prior.rows[0]);
}
const run = await enqueueRun(tx, {
platformId,
scope: input.scope,
scopeKey: target.scopeKey,
categoryId: target.categoryId,
attributeId: target.attributeId,
sourceRevision: source.rows[0].revision,
trigger: 'manual',
userId,
idempotencyKey,
});
await tx.query(
`INSERT INTO atlas.platform_catalog_sync_requests
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
VALUES ($1, $2, $3, $4, $5)`,
[userId, idempotencyKey, platformId, target.scopeKey, run.id],
);
return presentRun(run);
});
}
private async resolveTarget(tx: DatabaseTransaction, platformId: string, input: SyncInput) {
if (input.scope === 'tree') return { scopeKey: 'tree' };
if (input.scope === 'attributes') {
const category = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_categories
WHERE id = $1 AND platform_id = $2 AND is_active
AND external_type_id IS NOT NULL AND external_type_id <> '0'`,
[input.category_id, platformId],
);
if (!category.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '商品类型不存在');
return { scopeKey: `attributes:${input.category_id}`, categoryId: input.category_id };
}
const attribute = await tx.query<{ platform_product_category_id: string }>(
`SELECT platform_product_category_id
FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND is_active AND dictionary_id IS NOT NULL`,
[input.attribute_id, platformId],
);
if (!attribute.rows[0]) {
throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '字典属性不存在');
}
return {
scopeKey: `dictionary:${input.attribute_id}`,
categoryId: attribute.rows[0].platform_product_category_id,
attributeId: input.attribute_id,
};
}
}
type EnqueueInput = {
platformId: string;
scope: 'tree' | 'attributes' | 'dictionary';
scopeKey: string;
categoryId?: string;
attributeId?: string;
sourceRevision: number;
trigger: 'manual' | 'scheduled' | 'source_configured';
userId?: string;
idempotencyKey?: string;
};
/** 同一事务写入持久任务和平台 Outbox;活跃作用域复用已有任务。 */
export async function enqueueRun(tx: DatabaseTransaction, input: EnqueueInput): Promise<RunRow> {
const active = await tx.query<RunRow>(
`SELECT id, platform_id, scope, scope_key, status, trigger_kind, created_at
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $2 AND status IN ('queued', 'running')
LIMIT 1`,
[input.platformId, input.scopeKey],
);
if (active.rows[0]) return active.rows[0];
const inserted = await tx.query<RunRow>(
`INSERT INTO atlas.platform_catalog_sync_runs
(platform_id, scope, scope_key, category_id, attribute_id, trigger_kind,
source_revision, requested_by_user_id, idempotency_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, platform_id, scope, scope_key, status, trigger_kind, created_at`,
[input.platformId, input.scope, input.scopeKey, input.categoryId ?? null,
input.attributeId ?? null, input.trigger, input.sourceRevision,
input.userId ?? null, input.idempotencyKey ?? null],
);
const run = inserted.rows[0];
await tx.query(
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id) VALUES ($1, $2)`,
[run.id, input.platformId],
);
return run;
}
function presentRun(run: RunRow) {
return {
id: run.id,
platform_id: run.platform_id,
scope: run.scope,
status: run.status,
trigger: run.trigger_kind,
created_at: run.created_at,
};
}
@@ -0,0 +1,132 @@
import {
Body, Controller, Get, Headers, Inject, Param, Post, Put, Query,
Req, UseGuards, HttpCode,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Database } from '@atlas/database';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
import {
parseInput, parseListLimit, parseUuid, sourceInputSchema, syncInputSchema,
} from './platform-catalog-admin.models.js';
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
import { hasSystemAdminGrant, SystemAdminGuard } from './system-admin.guard.js';
@ApiTags('System')
@ApiBearerAuth('access-token')
@Controller('system')
export class SystemAccessController {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
@Get('access')
public async access(@Req() request: AuthenticatedRequest) {
return { data: { is_system_admin: await hasSystemAdminGrant(this.database, request.auth.userId) } };
}
}
@ApiTags('System platform catalog')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/platform-catalog')
export class PlatformCatalogAdminController {
public constructor(
@Inject(PlatformCatalogAdminReads) private readonly reads: PlatformCatalogAdminReads,
@Inject(PlatformCatalogAdminCommands) private readonly commands: PlatformCatalogAdminCommands,
) {}
@Get('platforms')
public async platforms() {
return { data: await this.reads.platforms() };
}
@Put('platforms/:platformId/source')
public async configureSource(
@Param('platformId') platformId: string,
@Req() request: AuthenticatedRequest,
@Body() body: unknown,
) {
return {
data: await this.commands.configureSource(
parseUuid(platformId, 'platform_id'),
request.auth.userId,
parseInput(sourceInputSchema, body),
),
};
}
@Get('platforms/:platformId/categories')
public async categories(@Param('platformId') platformId: string) {
return { data: await this.reads.categories(parseUuid(platformId, 'platform_id')) };
}
@Get('platforms/:platformId/categories/:categoryId/attributes')
public async attributes(
@Param('platformId') platformId: string,
@Param('categoryId') categoryId: string,
) {
return {
data: await this.reads.attributes(
parseUuid(platformId, 'platform_id'),
parseUuid(categoryId, 'category_id'),
),
};
}
@Get('platforms/:platformId/attributes/:attributeId/values')
public async values(
@Param('platformId') platformId: string,
@Param('attributeId') attributeId: string,
@Query('q') keyword: string | undefined,
@Query('page') pageValue: string | undefined,
) {
const page = pageValue === undefined ? 1 : Number(pageValue);
if (!Number.isInteger(page) || page < 1 || page > 100000) {
throw new ApiError(400, 'VALIDATION_FAILED', 'page 必须为正整数');
}
if (keyword && keyword.length > 120) {
throw new ApiError(400, 'VALIDATION_FAILED', '搜索词不能超过 120 字符');
}
return this.reads.values(
parseUuid(platformId, 'platform_id'),
parseUuid(attributeId, 'attribute_id'),
keyword,
page,
);
}
@Post('platforms/:platformId/syncs')
@HttpCode(202)
public async sync(
@Param('platformId') platformId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@Body() body: unknown,
) {
if (!idempotencyKey) {
throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请提供 Idempotency-Key');
}
return {
data: await this.commands.sync(
parseUuid(platformId, 'platform_id'),
request.auth.userId,
parseUuid(idempotencyKey, 'Idempotency-Key'),
parseInput(syncInputSchema, body),
),
};
}
@Get('platforms/:platformId/syncs')
public async syncs(
@Param('platformId') platformId: string,
@Query('limit') limit: string | undefined,
) {
return {
data: await this.reads.runs(
parseUuid(platformId, 'platform_id'),
parseListLimit(limit),
),
};
}
}
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { ApiError } from '../common/api-error.js';
export const sourceInputSchema = z.object({
client_id: z.string().trim().min(1).max(128),
api_key: z.string().trim().min(1).max(2048),
}).strict();
export const syncInputSchema = z.discriminatedUnion('scope', [
z.object({ scope: z.literal('tree') }).strict(),
z.object({ scope: z.literal('attributes'), category_id: z.uuid() }).strict(),
z.object({ scope: z.literal('dictionary'), attribute_id: z.uuid() }).strict(),
]);
export type SyncInput = z.infer<typeof syncInputSchema>;
export type SourceInput = z.infer<typeof sourceInputSchema>;
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
const parsed = schema.safeParse(input);
if (parsed.success) return parsed.data;
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不合法');
}
export function parseUuid(value: string, field: string): string {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(400, 'VALIDATION_FAILED', `${field} 不是有效 UUID`);
}
export function parseListLimit(value: unknown, defaultValue = 20): number {
if (value === undefined) return defaultValue;
const count = Number(value);
if (Number.isInteger(count) && count >= 1 && count <= 50) return count;
throw new ApiError(400, 'VALIDATION_FAILED', 'limit 必须在 1 到 50 之间');
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import {
PlatformCatalogAdminController, SystemAccessController,
} from './platform-catalog-admin.controller.js';
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
import { SystemAdminGuard } from './system-admin.guard.js';
@Module({
controllers: [SystemAccessController, PlatformCatalogAdminController],
providers: [SystemAdminGuard, PlatformCatalogAdminCommands, PlatformCatalogAdminReads],
})
export class PlatformCatalogAdminModule {}
@@ -0,0 +1,158 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
@Injectable()
export class PlatformCatalogAdminReads {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async platforms() {
const rows = await this.database.query<{
id: string; code: string; name: string; status: string;
client_id: string | null; source_updated_at: Date | null;
category_count: string; last_tree_sync_at: Date | null; last_error: string | null;
}>(
`SELECT p.id, p.code, p.name, p.status,
source.client_id, source.updated_at AS source_updated_at,
(SELECT count(*) FROM atlas.platform_product_categories category
WHERE category.platform_id = p.id AND category.is_active)::text AS category_count,
(SELECT max(completed_at) FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = p.id AND run.scope = 'tree' AND run.status = 'succeeded') AS last_tree_sync_at,
(SELECT CASE WHEN run.status = 'failed' THEN run.error_message ELSE NULL END
FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = p.id AND run.scope = 'tree'
ORDER BY run.created_at DESC LIMIT 1) AS last_error
FROM atlas.platforms p
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
);
return rows.rows.map((row) => ({
id: row.id,
code: row.code,
name: row.name,
status: row.status,
source: {
configured: row.client_id !== null,
client_id_hint: row.client_id ? `••••${row.client_id.slice(-4)}` : null,
updated_at: row.source_updated_at,
},
summary: {
category_count: Number(row.category_count),
last_tree_sync_at: row.last_tree_sync_at,
last_error: row.last_error,
},
}));
}
public async categories(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`SELECT id, parent_id, external_category_id, external_type_id, name, path,
is_active, synced_at
FROM atlas.platform_product_categories
WHERE platform_id = $1 ORDER BY path, id LIMIT 20000`,
[platformId],
);
return result.rows;
}
public async attributes(platformId: string, categoryId: string) {
await this.requireCategory(platformId, categoryId);
const result = await this.database.query(
`SELECT attribute.id, attribute.platform_attribute_id, attribute.complex_id,
attribute.name, attribute.description, attribute.group_id, attribute.group_name,
attribute.value_type, attribute.is_required, attribute.is_aspect,
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
attribute.category_dependent, attribute.is_active, attribute.synced_at,
(SELECT count(*)::int FROM atlas.platform_catalog_dictionary_values value
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
FROM atlas.platform_product_category_attributes attribute
WHERE attribute.platform_id = $1 AND attribute.platform_product_category_id = $2
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
attribute.name, attribute.platform_attribute_id`,
[platformId, categoryId],
);
return result.rows;
}
public async values(
platformId: string,
attributeId: string,
keyword: string | undefined,
page: number,
) {
const attribute = await this.database.query<{ dictionary_id: string | null }>(
`SELECT dictionary_id FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND is_active`,
[attributeId, platformId],
);
if (!attribute.rows[0]) throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '属性不存在');
if (!attribute.rows[0].dictionary_id) {
throw new ApiError(422, 'ATTRIBUTE_DICTIONARY_UNAVAILABLE', '该属性没有字典候选值');
}
const q = keyword?.trim() ?? '';
const [items, total, lastRun] = await Promise.all([
this.database.query(
`SELECT id, external_value_id, value, info, picture
FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = $1 AND is_active
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)
ORDER BY value, external_value_id LIMIT 100 OFFSET $3`,
[attributeId, q, (page - 1) * 100],
),
this.database.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = $1 AND is_active
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)`,
[attributeId, q],
),
this.database.query<{ is_complete: boolean }>(
`SELECT COALESCE((result->>'is_complete')::boolean, false) AS is_complete
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $2 AND status = 'succeeded'
ORDER BY completed_at DESC LIMIT 1`,
[platformId, `dictionary:${attributeId}`],
),
]);
return {
data: items.rows,
meta: {
page,
page_size: 100,
total: Number(total.rows[0]?.count ?? 0),
is_complete: lastRun.rows[0]?.is_complete ?? false,
},
};
}
public async runs(platformId: string, limit: number) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`SELECT id, platform_id, scope, scope_key, category_id, attribute_id,
trigger_kind AS trigger, status, result, error_code, error_message,
attempt_count, created_at, started_at, completed_at
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
[platformId, limit],
);
return result.rows;
}
public async requirePlatform(platformId: string) {
const result = await this.database.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1',
[platformId],
);
if (!result.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
return result.rows[0];
}
public async requireCategory(platformId: string, categoryId: string) {
const result = await this.database.query(
`SELECT 1 FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2`,
[categoryId, platformId],
);
if (!result.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '类目不属于当前平台');
}
}
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import type { Database } from '@atlas/database';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
@Injectable()
export class SystemAdminGuard implements CanActivate {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (await hasSystemAdminGrant(this.database, request.auth.userId)) return true;
throw new ApiError(403, 'SYSTEM_ADMIN_REQUIRED', '需要系统管理员权限');
}
}
/** 授权实时查库;撤销后旧 JWT 不能继续使用后台接口。 */
export async function hasSystemAdminGrant(database: Database, userId: string): Promise<boolean> {
const result = await database.query(
`SELECT 1 FROM atlas.system_admin_grants grant_row
JOIN atlas.users user_row ON user_row.id = grant_row.user_id
WHERE grant_row.user_id = $1 AND grant_row.revoked_at IS NULL
AND user_row.status = 'active'`,
[userId],
);
return !!result.rowCount;
}
@@ -0,0 +1,33 @@
import type { ProductAttributeSchema } from '@atlas/contracts';
type AttributeDefinition = Omit<ProductAttributeSchema, 'editor_field'>;
type EditorField = Exclude<ProductAttributeSchema['editor_field'], null>;
/** Ozon 固定属性 ID 到编辑器内建字段的唯一映射,不使用任何名称或翻译文本判断。 */
const editorFieldByAttributeId: Readonly<Record<string, EditorField>> = {
'85': 'brand',
'9048': 'model',
'8229': 'category',
'23171': 'tags',
'11254': 'rich_content',
'4191': 'description',
};
/** 仅依据 Ozon 属性 ID 将类目属性绑定到编辑器内建字段。 */
export function editorFieldForAttributeId(
platformAttributeId: string,
): ProductAttributeSchema['editor_field'] {
return editorFieldByAttributeId[platformAttributeId] ?? null;
}
/** 为数据库中的原始类目属性补充前端和校验共用的内建字段绑定。 */
export function bindEditorFields(
definitions: AttributeDefinition[],
): ProductAttributeSchema[] {
return definitions.map((definition) => ({
...definition,
editor_field: editorFieldForAttributeId(
definition.platform_attribute_id,
),
}));
}
@@ -21,6 +21,26 @@ type CatalogSyncResult = {
body: unknown;
};
type DictionaryCursor = { value: string; value_id: string; query: string };
/** 游标仅记录排序位置;类目、字典及搜索条件仍由当前请求的权限校验约束。 */
function parseDictionaryCursor(cursor: string | undefined): DictionaryCursor | null {
if (!cursor) return null;
if (cursor.length > 2_000) throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
try {
const parsed: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
if (
parsed && typeof parsed === 'object' &&
typeof (parsed as DictionaryCursor).value === 'string' &&
typeof (parsed as DictionaryCursor).value_id === 'string' &&
typeof (parsed as DictionaryCursor).query === 'string'
) return parsed as DictionaryCursor;
} catch {
// 无效游标统一返回参数错误。
}
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
}
type CatalogAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
@@ -155,9 +175,11 @@ export class ProductEditorCatalogOperations {
platformId: string,
dictionaryId: string,
search: string | undefined,
cursor: string | undefined,
categoryId?: string,
storeId?: string,
) {
const after = parseDictionaryCursor(cursor);
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
@@ -202,6 +224,8 @@ export class ProductEditorCatalogOperations {
'字典不属于当前类目的属性',
);
const needle = search?.trim() ?? '';
if (after && after.query !== needle)
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标与搜索条件不匹配');
const values = await transaction.query<{
value_id: string;
value: string;
@@ -211,11 +235,20 @@ export class ProductEditorCatalogOperations {
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3 AND is_active
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
AND ($5::text IS NULL OR (value, external_value_id) > ($5, $6))
ORDER BY value, external_value_id
LIMIT 100`,
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
LIMIT 201`,
[selectedStoreId, selectedCategoryId, dictionaryId, needle,
after?.value ?? null, after?.value_id ?? null],
);
return { items: values.rows, next_cursor: null };
const items = values.rows.slice(0, 200);
const last = items.at(-1);
return {
items,
next_cursor: values.rows.length > 200 && last
? Buffer.from(JSON.stringify({ ...last, query: needle })).toString('base64url')
: null,
};
},
);
}
@@ -241,7 +274,7 @@ export class ProductEditorCatalogOperations {
});
}
/** 创建带关键词限制的属性字典同步任务。 */
/** 创建首批字典值或关键词搜索同步任务。 */
async requestAttributeDictionarySync(
workspaceId: string,
userId: string,
@@ -252,7 +285,7 @@ export class ProductEditorCatalogOperations {
dictionaryId: string,
query: string,
) {
if (query.trim().length < 2)
if (query.trim().length === 1)
throw new ApiError(
422,
'DICTIONARY_SEARCH_REQUIRED',
@@ -108,6 +108,22 @@ export class ProductEditorController {
};
}
/** 返回首次上架前的服务端发布就绪校验。 */
@Get('product-platform-profiles/:profileId/validation')
async validation(
@Param('profileId') profileId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.validation(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 幂等地创建所选类目属性定义的刷新任务。 */
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
async attributeSchemaSync(
@@ -139,6 +155,7 @@ export class ProductEditorController {
@Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string,
@Query('q') query: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('category_id') categoryId: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@@ -153,6 +170,7 @@ export class ProductEditorController {
requiredUuid('X-Atlas-Platform-Id', platformId),
dictionaryId,
query,
cursor,
optionalUuid('category_id', categoryId),
optionalUuid('store_id', storeId),
),
@@ -28,6 +28,7 @@ export type ProductRow = {
/** 商品在一个发布平台下的编辑配置行。 */
export type ProfileRow = {
id: string;
product_id: string;
revision: number;
platform_id: string;
market_code: string | null;
@@ -57,7 +58,7 @@ export type OperationRow = {
};
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
export const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code,
p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
p.platform_type_id, p.category_source, category.name AS category_name,
category.path AS category_path, p.target_store_id, s.name AS store_name,
@@ -16,6 +16,8 @@ import {
profileSelect,
} from './product-editor.models.js';
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
import { bindEditorFields } from './product-editor.attribute-bindings.js';
import { ProductEditorValidationOperations } from './product-editor.validation.js';
import {
latestProductAiSourceMaterial,
normalizedCategoryPath as normalizeCapturedCategoryPath,
@@ -35,6 +37,7 @@ import {
@Injectable()
export class ProductEditorService {
private readonly catalog: ProductEditorCatalogOperations;
private readonly validationOperations: ProductEditorValidationOperations;
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
public constructor(
@@ -45,12 +48,24 @@ export class ProductEditorService {
this.catalog = new ProductEditorCatalogOperations(this.database, {
requireProfileForPlatform: (...args) =>
this.requireProfileForPlatform(...args),
requireEditableStore: (...args) => this.requireEditableStore(...args),
requireActiveCategory: (...args) => this.requireActiveCategory(...args),
requireEditableStore: (...args) =>
this.requireEditableStore(...args),
requireActiveCategory: (...args) =>
this.requireActiveCategory(...args),
attributeSchemaForCategory: (...args) =>
this.attributeSchemaForCategory(...args),
requestCatalogSync: (input) => this.requestCatalogSync(input),
});
this.validationOperations = new ProductEditorValidationOperations(
this.database,
{
requireProfileForPlatform: (...args) =>
this.requireProfileForPlatform(...args),
requireProduct: (...args) => this.requireProduct(...args),
attributeSchemaForCategory: (...args) =>
this.attributeSchemaForCategory(...args),
},
);
}
/** 创建或复用平台发布配置,并返回编辑器导航上下文。 */
@@ -487,6 +502,19 @@ export class ProductEditorService {
);
}
/** 按当前持久化草稿和 Ozon 类目 schema 计算首次上架就绪状态。 */
async validation(
workspaceId: string,
profileId: string,
platformId: string,
) {
return this.validationOperations.validation(
workspaceId,
profileId,
platformId,
);
}
/** 仅从店铺本地缓存查询当前类目的字典值。 */
async attributeDictionary(
workspaceId: string,
@@ -494,6 +522,7 @@ export class ProductEditorService {
platformId: string,
dictionaryId: string,
search: string | undefined,
cursor: string | undefined,
categoryId?: string,
storeId?: string,
) {
@@ -503,6 +532,7 @@ export class ProductEditorService {
platformId,
dictionaryId,
search,
cursor,
categoryId,
storeId,
);
@@ -1002,7 +1032,10 @@ export class ProductEditorService {
'SKU 发布记录已变化,请重新加载编辑器',
);
const listingByVariant = new Map(
listings.rows.map((listing) => [listing.product_variant_id, listing]),
listings.rows.map((listing) => [
listing.product_variant_id,
listing,
]),
);
const assetIds = [
...new Set(input.variants.flatMap((variant) => variant.assetIds)),
@@ -1020,7 +1053,9 @@ export class ProductEditorService {
[assetIds],
)
: { rows: [] };
const assetById = new Map(assets.rows.map((asset) => [asset.id, asset]));
const assetById = new Map(
assets.rows.map((asset) => [asset.id, asset]),
);
const existing = await transaction.query<{
product_variant_id: string;
asset_id: string;
@@ -1033,7 +1068,8 @@ export class ProductEditorService {
);
const existingByVariant = new Map<string, Set<string>>();
for (const item of existing.rows) {
const ids = existingByVariant.get(item.product_variant_id) ?? new Set();
const ids =
existingByVariant.get(item.product_variant_id) ?? new Set();
ids.add(item.asset_id);
existingByVariant.set(item.product_variant_id, ids);
}
@@ -1058,7 +1094,8 @@ export class ProductEditorService {
: { rows: [] };
const candidateVariants = new Map<string, Set<string | null>>();
for (const candidate of candidates.rows) {
const variants = candidateVariants.get(candidate.asset_id) ?? new Set();
const variants =
candidateVariants.get(candidate.asset_id) ?? new Set();
variants.add(candidate.source_variant_id);
candidateVariants.set(candidate.asset_id, variants);
}
@@ -1094,7 +1131,10 @@ export class ProductEditorService {
if (existingByVariant.get(variant.variantId)?.has(assetId))
continue;
const allowedFor = candidateVariants.get(assetId);
if (!allowedFor?.has(null) && !allowedFor?.has(variant.variantId))
if (
!allowedFor?.has(null) &&
!allowedFor?.has(variant.variantId)
)
throw new ApiError(
422,
'LISTING_MEDIA_NOT_SELECTABLE',
@@ -1252,6 +1292,16 @@ export class ProductEditorService {
);
continue;
}
// 主要信息字段是唯一事实来源;兼容旧页面提交的重复属性,但不再持久化。
if (definition.editor_field) continue;
if (definition.is_aspect !== (attribute.scope === 'variant'))
throw new ApiError(
422,
'ATTRIBUTE_SCOPE_INVALID',
definition.is_aspect
? `${definition.name} 必须按 SKU 填写`
: `${definition.name} 必须作为商品通用属性填写`,
);
if (attribute.scope === 'variant') {
if (!attribute.variant_id)
throw new ApiError(
@@ -1441,7 +1491,7 @@ export class ProductEditorService {
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
[storeId, categoryId],
);
return result.rows;
return bindEditorFields(result.rows);
}
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
@@ -1601,7 +1651,8 @@ export class ProductEditorService {
WHERE item.id = $1 AND item.deleted_at IS NULL`,
[product.collection_item_id],
);
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return profile;
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON')
return profile;
const category = await transaction.query<{
id: string;
@@ -0,0 +1,320 @@
import type {
ProductAttributeSchema,
ProductEditorValidation,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type { ProductRow, ProfileRow } from './product-editor.models.js';
type ValidationAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireProduct: (
transaction: DatabaseTransaction,
productId: string,
) => Promise<ProductRow>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
storeId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
};
type VariantValidationRow = {
id: string;
display_name: string | null;
listing_status: string;
offer_id: string | null;
selling_price: string | null;
package_length_mm: string | null;
package_width_mm: string | null;
package_height_mm: string | null;
shipping_weight_g: string | null;
package_spec_status: string;
media_count: string;
};
type AttributeValidationRow = {
scope: 'common' | 'variant';
product_variant_id: string | null;
platform_attribute_id: string;
complex_id: string;
values: unknown;
confirmation_status: string;
};
/**
* 计算商品草稿的首次上架就绪状态。
* 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。
*/
export class ProductEditorValidationOperations {
/** 注入工作空间事务入口和编辑器统一访问边界。 */
public constructor(
private readonly database: Database,
private readonly access: ValidationAccess,
) {}
/** 按当前持久化草稿和 Ozon 类目 schema 返回结构化校验结果。 */
async validation(
workspaceId: string,
profileId: string,
platformId: string,
): Promise<ProductEditorValidation> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const product = await this.access.requireProduct(
transaction,
profile.product_id,
);
const variants = await this.readVariants(
transaction,
profile.id,
profile.product_id,
);
const attributes = await this.readAttributes(
transaction,
profile.id,
);
const schema = profile.store_product_category_id
? await this.access.attributeSchemaForCategory(
transaction,
profile.target_store_id,
profile.store_product_category_id,
)
: [];
const profileIssues = this.profileIssues(
product,
profile,
schema,
attributes,
);
const variantResults = variants.map((variant) =>
this.variantResult(variant, schema, attributes),
);
const publishableVariants = variantResults.filter(
(variant) => variant.status !== 'not_publishable_again',
);
return {
ready:
profileIssues.length === 0 &&
publishableVariants.length > 0 &&
publishableVariants.every((variant) => variant.ready),
profile: {
ready: profileIssues.length === 0,
issues: profileIssues,
},
variants: variantResults,
};
},
);
}
/** 汇总商品级基础信息与通用类目属性问题。 */
private profileIssues(
product: ProductRow,
profile: ProfileRow,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
): ProductEditorValidation['profile']['issues'] {
const issues: ProductEditorValidation['profile']['issues'] = [];
if (!product.source_title?.trim())
issues.push({
code: 'TITLE_REQUIRED',
field: 'common.source_title',
message: '请填写标题',
});
if (!product.model?.trim())
issues.push({
code: 'MODEL_REQUIRED',
field: 'common.model',
message: '请填写型号',
});
if (!product.brand?.trim())
issues.push({
code: 'BRAND_REQUIRED',
field: 'common.brand',
message: '请填写品牌',
});
if (!profile.store_product_category_id)
issues.push({
code: 'CATEGORY_REQUIRED',
field: 'profile.store_product_category_id',
message: '请选择产品类目',
});
if (profile.store_product_category_id && !schema.length)
issues.push({
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
field: 'attributes',
message: '当前类目的 Ozon 属性尚未同步',
});
for (const definition of schema.filter(
(item) => item.is_required && !item.is_aspect && !item.editor_field,
)) {
if (!hasConfirmedValue(attributes, 'common', null, definition))
issues.push({
code: 'OZON_ATTRIBUTE_REQUIRED',
field: `attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
message: `请填写${definition.name}`,
platform_attribute_id: definition.platform_attribute_id,
});
}
return issues;
}
/** 计算单个 SKU 的发布资格与缺失项。 */
private variantResult(
variant: VariantValidationRow,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
): ProductEditorValidation['variants'][number] {
if (variant.listing_status === 'imported')
return {
variant_id: variant.id,
display_name: variant.display_name,
status: 'not_publishable_again',
ready: false,
issues: [],
};
const issues: ProductEditorValidation['variants'][number]['issues'] =
[];
if (!variant.offer_id?.trim())
issues.push({
code: 'OFFER_ID_REQUIRED',
field: `variants.${variant.id}.offer_id`,
message: '请填写卖家货号',
});
if (!variant.selling_price)
issues.push({
code: 'PRICE_REQUIRED',
field: `variants.${variant.id}.selling_price`,
message: '请先确认售价',
});
if (
variant.package_spec_status !== 'confirmed' ||
!variant.package_length_mm ||
!variant.package_width_mm ||
!variant.package_height_mm ||
!variant.shipping_weight_g
)
issues.push({
code: 'PACKAGE_SPEC_REQUIRED',
field: `variants.${variant.id}.package_spec`,
message: '请确认有效的包装重量和包装尺寸',
});
if (Number(variant.media_count) < 1)
issues.push({
code: 'IMAGE_REQUIRED',
field: `variants.${variant.id}.media_asset_ids`,
message: '请至少选择一张 SKU 图片',
});
for (const definition of schema.filter(
(item) => item.is_required && item.is_aspect && !item.editor_field,
)) {
if (
!hasConfirmedValue(
attributes,
'variant',
variant.id,
definition,
)
)
issues.push({
code: 'OZON_ATTRIBUTE_REQUIRED',
field: `variants.${variant.id}.attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
message: `请填写${definition.name}`,
platform_attribute_id: definition.platform_attribute_id,
});
}
return {
variant_id: variant.id,
display_name: variant.display_name,
status: issues.length ? 'blocked' : 'ready',
ready: issues.length === 0,
issues,
};
}
/** 一次读取 SKU 的售价、有效包装规格和素材数量。 */
private async readVariants(
transaction: DatabaseTransaction,
profileId: string,
productId: string,
) {
const variants = await transaction.query<VariantValidationRow>(
`SELECT v.id, v.display_name, listing.status AS listing_status,
listing.offer_id, listing.selling_price::text,
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
coalesce(v.shipping_weight_g, product.default_shipping_weight_g)::text AS shipping_weight_g,
coalesce(v.package_spec_status, product.package_spec_status) AS package_spec_status,
count(media.id)::text AS media_count
FROM atlas.product_variants v
JOIN atlas.products product ON product.id = v.product_id
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = v.id AND listing.profile_id = $1
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
WHERE v.product_id = $2 AND v.deleted_at IS NULL
GROUP BY v.id, v.display_name, listing.status, listing.offer_id,
listing.selling_price, product.default_package_length_mm,
product.default_package_width_mm, product.default_package_height_mm,
product.default_shipping_weight_g, product.package_spec_status
ORDER BY v.sort_order, v.id`,
[profileId, productId],
);
return variants.rows;
}
/** 读取当前发布配置下已经持久化的动态属性值。 */
private async readAttributes(
transaction: DatabaseTransaction,
profileId: string,
) {
const attributes = await transaction.query<AttributeValidationRow>(
`SELECT scope, product_variant_id, platform_attribute_id, complex_id,
values, confirmation_status
FROM atlas.product_attributes WHERE profile_id = $1`,
[profileId],
);
return attributes.rows;
}
}
/** 只有已确认且至少包含一个非空字符串的值才满足发布要求。 */
function hasConfirmedValue(
attributes: AttributeValidationRow[],
scope: 'common' | 'variant',
variantId: string | null,
definition: ProductAttributeSchema,
) {
return attributes.some(
(attribute) =>
attribute.scope === scope &&
attribute.product_variant_id === variantId &&
attribute.platform_attribute_id ===
definition.platform_attribute_id &&
attribute.complex_id === definition.complex_id &&
attribute.confirmation_status === 'confirmed' &&
stringValues(attribute.values).some((value) => value.trim()),
);
}
function stringValues(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import type { Database } from '@atlas/database';
import { describe, it } from 'vitest';
import {
parseInput,
sourceInputSchema,
syncInputSchema,
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
import { hasSystemAdminGrant } from '../src/platform-catalog-admin/system-admin.guard.js';
describe('平台目录系统管理员边界', () => {
it('只有未撤销且用户有效的数据库授权才被视为系统管理员', async () => {
const queries: Array<{ sql: string; parameters: unknown[] }> = [];
const database = {
query: async (sql: string, parameters: unknown[]) => {
queries.push({ sql, parameters });
return { rowCount: 1, rows: [{ '?column?': 1 }] };
},
} as unknown as Database;
assert.equal(await hasSystemAdminGrant(database, 'user-1'), true);
assert.deepEqual(queries[0].parameters, ['user-1']);
assert.match(queries[0].sql, /revoked_at IS NULL/);
assert.match(queries[0].sql, /user_row\.status = 'active'/);
});
it('同步请求不能混入其他作用域字段', () => {
assert.throws(() => parseInput(syncInputSchema, {
scope: 'tree',
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
}));
assert.throws(() => parseInput(syncInputSchema, {
scope: 'dictionary',
attribute_id: 'not-a-uuid',
}));
});
it('同步来源仅接受 Client-Id 与 Api-Key', () => {
assert.deepEqual(parseInput(sourceInputSchema, {
client_id: ' 12345 ',
api_key: ' secret ',
}), { client_id: '12345', api_key: 'secret' });
assert.throws(() => parseInput(sourceInputSchema, {
client_id: '12345',
api_key: 'secret',
workspace_id: 'forbidden',
}));
});
});
+399 -94
View File
@@ -68,7 +68,14 @@ async function seedStoreCategory(
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
RETURNING id`,
[storeId, platformId, externalCategoryId, externalTypeId, name, path],
[
storeId,
platformId,
externalCategoryId,
externalTypeId,
name,
path,
],
);
return { id: category.rows[0].id, storeId };
});
@@ -115,9 +122,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'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.operations WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1',
[workspaceId],
@@ -161,9 +169,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
workspaceId,
]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
workspaceId,
]);
await tx.query(
'DELETE FROM atlas.media_assets WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
@@ -852,38 +861,64 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
);
});
it('M3.4 按当前类目返回动态属性,并将类目切换后的旧属性保留为未映射草稿', async () => {
it('M3.4 返回去重语义的动态属性,并校验必填、scope 与类目切换', async () => {
const dictionarySuffix = randomUUID();
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
const categoryA = await seedStoreCategory(`m34-a-${randomUUID()}`, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱');
const categoryB = await seedStoreCategory(`m34-b-${randomUUID()}`, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包');
const categoryA = await seedStoreCategory(
`m34-a-${randomUUID()}`,
'type-a',
'M3.4 收纳箱',
'家居 › 收纳 › M3.4 收纳箱',
);
const categoryB = await seedStoreCategory(
`m34-b-${randomUUID()}`,
'type-b',
'M3.4 背包',
'箱包 › 背包 › M3.4 背包',
);
seededCategoryIds.push(categoryA.id, categoryB.id);
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
`INSERT INTO atlas.store_product_category_attributes
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_product_category_attributes
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
VALUES
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '85', '品牌', 'text', true, false, 1, NULL, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '9048', '型号名称', 'text', true, false, 1, NULL, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '8229', '类型', 'text', true, false, 1, NULL, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '23171', '主题标签', 'text', false, true, 10, NULL, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '4191', '简介', 'text', true, false, 1, NULL, false),
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
[
categoryA.storeId,
platformId,
categoryA.id,
categoryB.id,
materialDictionaryId,
colorDictionaryId,
],
));
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
`INSERT INTO atlas.store_attribute_dictionary_values
[
categoryA.storeId,
platformId,
categoryA.id,
categoryB.id,
materialDictionaryId,
colorDictionaryId,
],
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_attribute_dictionary_values
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId, colorDictionaryId],
));
[
categoryA.storeId,
platformId,
categoryA.id,
materialDictionaryId,
colorDictionaryId,
],
),
);
const categories = await request(
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
@@ -900,6 +935,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
const attributeVariantId =
before.body.data.variants.find(
(variant: any) => variant.listing.status !== 'imported',
)?.id ?? before.body.data.variants[0].id;
const variants = before.body.data.variants.map((variant: any) => ({
id: variant.id,
client_key: null,
@@ -945,7 +984,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
},
{
scope: 'variant',
variant_id: variants[0].id,
variant_id: attributeVariantId,
platform_attribute_id: 'color',
complex_id: '0',
values: ['black'],
@@ -953,6 +992,16 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
source: 'manual',
confirmation_status: 'confirmed',
},
{
scope: 'common',
variant_id: null,
platform_attribute_id: '85',
complex_id: '0',
values: ['重复品牌值'],
language_code: null,
source: 'manual',
confirmation_status: 'confirmed',
},
],
variants,
};
@@ -981,6 +1030,48 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
).is_aspect,
true,
);
assert.equal(
schema.body.data.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '85',
).editor_field,
'brand',
);
assert.equal(
schema.body.data.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '9048',
).editor_field,
'model',
);
assert.equal(
schema.body.data.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '8229',
).editor_field,
'category',
);
assert.equal(
schema.body.data.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '23171',
).editor_field,
'tags',
);
assert.equal(
schema.body.data.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '11254',
).editor_field,
'rich_content',
);
assert.equal(
schema.body.data.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '4191',
).editor_field,
'description',
);
const dictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
@@ -988,19 +1079,174 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
assert.deepEqual(dictionary.body.data.items, [
{ value_id: 'cotton', value: '棉' },
]);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_attribute_dictionary_values
(workspace_id, store_id, platform_id, store_product_category_id,
dictionary_id, external_value_id, value)
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
'page-' || value::text, '测试选项' || lpad(value::text, 3, '0')
FROM generate_series(1, 205) AS series(value)`,
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId],
),
);
const dictionaryPageOne = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(dictionaryPageOne.body.data.items.length, 200);
assert.ok(dictionaryPageOne.body.data.next_cursor);
const dictionaryPageTwo = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}&cursor=${encodeURIComponent(dictionaryPageOne.body.data.next_cursor)}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(dictionaryPageTwo.body.data.items.length, 6);
assert.equal(dictionaryPageTwo.body.data.next_cursor, null);
const invalidDictionaryCursor = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}&cursor=invalid`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(invalidDictionaryCursor.response.status, 400);
const afterSave = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
assert.equal(
afterSave.body.data.profile.category.id,
categoryA.id,
);
assert.equal(afterSave.body.data.profile.category.id, categoryA.id);
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
assert.equal(afterSave.body.data.attributes.length, 2);
const validAttributes = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(validAttributes.response.status, 200);
assert.ok(
!validAttributes.body.data.profile.issues.some(
(issue: { code: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED',
),
);
assert.ok(
!validAttributes.body.data.variants
.find(
(variant: { variant_id: string }) =>
variant.variant_id === attributeVariantId,
)
.issues.some(
(issue: { code: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED',
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`DELETE FROM atlas.product_attributes
WHERE profile_id = $1 AND product_variant_id = $2
AND platform_attribute_id = 'color'`,
[first.profileId, attributeVariantId],
),
);
const missingVariantAttribute = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.ok(
missingVariantAttribute.body.data.variants
.find(
(variant: { variant_id: string }) =>
variant.variant_id === attributeVariantId,
)
.issues.some(
(issue: { code: string; platform_attribute_id?: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED' &&
issue.platform_attribute_id === 'color',
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.product_attributes
(workspace_id, product_id, profile_id, product_variant_id, scope,
platform_attribute_id, complex_id, values, source, confirmation_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'variant',
'color', '0', '["black"]'::jsonb, 'manual', 'confirmed')`,
[first.productId, first.profileId, attributeVariantId],
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`DELETE FROM atlas.product_attributes
WHERE profile_id = $1 AND scope = 'common'
AND platform_attribute_id = 'material'`,
[first.profileId],
),
);
const missingCommonAttribute = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.ok(
missingCommonAttribute.body.data.profile.issues.some(
(issue: { code: string; platform_attribute_id?: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED' &&
issue.platform_attribute_id === 'material',
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.product_attributes
(workspace_id, product_id, profile_id, product_variant_id, scope,
platform_attribute_id, complex_id, values, source, confirmation_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, NULL, 'common',
'material', '0', '["cotton"]'::jsonb, 'manual', 'confirmed')`,
[first.productId, first.profileId],
),
);
const invalidScope = {
...draft,
ai_source_material: afterSave.body.data.source_material,
ai_source_overridden_fields:
afterSave.body.data.source_material_state.overridden_fields,
attributes: afterSave.body.data.attributes.map(
(attribute: any) => ({
scope:
attribute.platform_attribute_id === 'color'
? 'common'
: attribute.scope,
variant_id:
attribute.platform_attribute_id === 'color'
? null
: attribute.variant_id,
platform_attribute_id: attribute.platform_attribute_id,
complex_id: attribute.complex_id,
values: attribute.values,
language_code: attribute.language_code,
source: attribute.source,
confirmation_status: attribute.confirmation_status,
}),
),
};
const invalidScopeSave = await request(
`/api/products/${first.productId}/editor-draft`,
{
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${afterSave.body.data.product.revision}:${afterSave.body.data.profile.revision}"`,
}),
body: JSON.stringify(invalidScope),
},
);
assert.equal(invalidScopeSave.response.status, 422);
assert.equal(
invalidScopeSave.body.error.code,
'ATTRIBUTE_SCOPE_INVALID',
);
const categoryChanged = {
...draft,
ai_source_material: afterSave.body.data.source_material,
@@ -1059,8 +1305,18 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
const suffix = randomUUID();
const captured = await seedStoreCategory(`m35-source-${suffix}`, 'source-type', 'M3.5 采集叶', 'M3.5 来源 › M3.5 采集叶');
const manual = await seedStoreCategory(`m35-manual-${suffix}`, 'manual-type', 'M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶');
const captured = await seedStoreCategory(
`m35-source-${suffix}`,
'source-type',
'M3.5 采集叶',
'M3.5 来源 › M3.5 采集叶',
);
const manual = await seedStoreCategory(
`m35-manual-${suffix}`,
'manual-type',
'M3.5 手动叶',
'M3.5 手动 › M3.5 手动叶',
);
seededCategoryIds.push(captured.id, manual.id);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
@@ -1089,14 +1345,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
[first.profileId],
);
});
const resolved = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(resolved.response.status, 200);
assert.equal(
resolved.body.data.profile.category.id,
captured.id,
const resolved = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
assert.equal(resolved.response.status, 200);
assert.equal(resolved.body.data.profile.category.id, captured.id);
assert.equal(
resolved.body.data.profile.category.source,
'source_capture',
@@ -1111,29 +1367,36 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
[manual.id, first.profileId],
),
);
const preserved = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(preserved.response.status, 200);
assert.equal(
preserved.body.data.profile.category.id,
manual.id,
const preserved = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
assert.equal(preserved.response.status, 200);
assert.equal(preserved.body.data.profile.category.id, manual.id);
assert.equal(preserved.body.data.profile.category.source, 'manual');
});
it('M3.5 为类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
const dictionaryId = `m35-dictionary-${randomUUID()}`;
const category = await seedStoreCategory(`m35-category-${randomUUID()}`, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目');
const category = await seedStoreCategory(
`m35-category-${randomUUID()}`,
'm35-type',
'M3.5 测试类目',
'测试 › M3.5 测试类目',
);
seededCategoryIds.push(category.id);
seededDictionaryIds.push(dictionaryId);
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
`INSERT INTO atlas.store_product_category_attributes
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_product_category_attributes
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
[category.storeId, platformId, category.id, dictionaryId],
));
[category.storeId, platformId, category.id, dictionaryId],
),
);
await database.workspaceTransaction(workspaceId, async (tx) => {
const profile = await tx.query<{ target_store_id: string }>(
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
@@ -1164,7 +1427,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
},
);
assert.equal(schema.response.status, 202);
assert.equal(schema.body.data.operation.kind, 'store_category_attribute_sync');
assert.equal(
schema.body.data.operation.kind,
'store_category_attribute_sync',
);
const dictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
{
@@ -1176,74 +1442,107 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
},
);
assert.equal(dictionary.response.status, 202);
assert.equal(dictionary.body.data.operation.kind, 'store_attribute_dictionary_sync');
assert.equal(
dictionary.body.data.operation.kind,
'store_attribute_dictionary_sync',
);
const initialDictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}`,
{
method: 'POST',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
}),
},
);
assert.equal(initialDictionary.response.status, 202);
await database.workspaceTransaction(workspaceId, async (tx) => {
const events = await tx.query<{ event_type: string; event_payload: unknown }>(
const events = await tx.query<{
event_type: string;
event_payload: unknown;
}>(
`SELECT event_type, event_payload FROM atlas.outbox_events
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
ORDER BY created_at`,
[first.profileId],
);
assert.equal(events.rows.length, 2);
assert.equal(events.rows.length, 3);
assert.deepEqual(
events.rows.map((event) => event.event_type),
events.rows.map((event) => event.event_type).sort(),
[
'store_category_attribute_sync.requested',
'store_attribute_dictionary_sync.requested',
'store_attribute_dictionary_sync.requested',
'store_category_attribute_sync.requested',
],
);
assert.ok(
events.rows.every(
(event) =>
!JSON.stringify(event.event_payload).includes('m35-test-ciphertext'),
!JSON.stringify(event.event_payload).includes(
'm35-test-ciphertext',
),
),
);
});
});
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
const before = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
const before = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
const variant = before.body.data.variants[0];
const asset = await database.workspaceTransaction(workspaceId, async (tx) => {
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
const asset = await database.workspaceTransaction(
workspaceId,
async (tx) => {
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
'image/jpeg', 1024, $2, 1200, 900)
RETURNING id`,
[`m36/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '')],
);
const snapshot = await tx.query<{ source_snapshot_revision: string }>(
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
[first.collectionItemId],
);
await tx.query(
`INSERT INTO atlas.collection_asset_transfers
[
`m36/${randomUUID()}.jpg`,
randomUUID().replaceAll('-', ''),
],
);
const snapshot = await tx.query<{
source_snapshot_revision: string;
}>(
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
[first.collectionItemId],
);
await tx.query(
`INSERT INTO atlas.collection_asset_transfers
(workspace_id, collection_item_id, product_id, source_variant_id, role,
source_url, status, asset_id, source_snapshot_revision, completed_at)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
[
first.collectionItemId,
first.productId,
variant.id,
created.rows[0].id,
snapshot.rows[0].source_snapshot_revision,
],
);
return created.rows[0].id;
});
[
first.collectionItemId,
first.productId,
variant.id,
created.rows[0].id,
snapshot.rows[0].source_snapshot_revision,
],
);
return created.rows[0].id;
},
);
const payload = {
ai_source_material: before.body.data.source_material,
ai_source_overridden_fields:
before.body.data.source_material_state.overridden_fields,
common: {
...before.body.data.product.common,
default_physical_spec: before.body.data.product.default_physical_spec,
default_package_spec: before.body.data.product.default_package_spec,
default_physical_spec:
before.body.data.product.default_physical_spec,
default_package_spec:
before.body.data.product.default_package_spec,
},
profile: {
target_store_id: before.body.data.profile.target_store.id,
@@ -1278,19 +1577,25 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
media_asset_ids: item.id === variant.id ? [asset] : [],
})),
};
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
const saved = await request(
`/api/products/${first.productId}/editor-draft`,
{
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
}),
body: JSON.stringify(payload),
},
);
assert.equal(saved.response.status, 200);
const after = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.deepEqual(after.body.data.variants[0].media.map((item: any) => item.asset_id), [asset]);
assert.deepEqual(
after.body.data.variants[0].media.map((item: any) => item.asset_id),
[asset],
);
});
});
+8
View File
@@ -9,12 +9,14 @@ import {
MenuUnfoldOutlined,
RightOutlined,
UserOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import SidebarMenu from './SidebarMenu';
import PlatformSelector from './PlatformSelector';
import { getBreadcrumbItems } from './menuConfig';
import { useSession } from '@/services/session';
import './MainLayout.css';
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
const { Header, Sider, Content } = Layout;
@@ -26,6 +28,7 @@ const MainLayout = () => {
const location = useLocation();
const navigate = useNavigate();
const { session, logout } = useSession();
const systemAdminAccess = useSystemAdminAccess();
const breadcrumbItems = useMemo(
() =>
@@ -60,12 +63,17 @@ const MainLayout = () => {
navigate('/settings/password');
} else if (key === 'logout') {
void handleLogout();
} else if (key === 'system') {
navigate('/system/platform-catalog');
}
};
const userMenuItems: MenuProps['items'] = [
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
...(systemAdminAccess === 'allowed'
? [{ key: 'system', icon: <SafetyCertificateOutlined />, label: '系统管理' }]
: []),
{ type: 'divider' },
{
key: 'logout',
@@ -0,0 +1,47 @@
.system-admin-gate {
min-height: 100vh;
display: grid;
place-items: center;
}
.system-admin-layout {
min-height: 100vh;
}
.system-admin-sider {
background: var(--sider-bg) !important;
}
.system-admin-brand {
color: #fff;
font-size: 17px;
font-weight: 700;
padding: 20px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}
.system-admin-sider .ant-menu {
background: transparent;
}
.system-admin-header {
height: 56px;
line-height: 1;
padding: 0 24px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
}
.system-admin-content {
padding: 24px;
background: var(--bg-color);
}
@media (max-width: 768px) {
.system-admin-content { padding: 12px; }
.system-admin-header { padding: 0 12px; }
}
@@ -0,0 +1,52 @@
import { Button, Layout, Menu, Result, Spin } from 'antd';
import { AppstoreOutlined, ArrowLeftOutlined, DatabaseOutlined } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
import './SystemAdminLayout.css';
const { Sider, Header, Content } = Layout;
/** 全局后台与租户工作台分离,避免页头平台/门店上下文影响系统级操作。 */
export default function SystemAdminLayout() {
const access = useSystemAdminAccess();
const location = useLocation();
const navigate = useNavigate();
if (access === 'checking') {
return <div className="system-admin-gate"><Spin tip="正在验证系统管理员权限…" /></div>;
}
if (access === 'denied') {
return <Result status="403" title="无权访问系统管理" subTitle="请联系系统维护者授予权限" extra={
<Button onClick={() => navigate('/online-products')}>返回工作台</Button>
} />;
}
return (
<Layout className="system-admin-layout">
<Sider className="system-admin-sider" width={204} breakpoint="lg" collapsedWidth={0}>
<div className="system-admin-brand">
<AppstoreOutlined /> <span>Atlas 系统管理</span>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname.startsWith('/system/platform-catalog')
? '/system/platform-catalog' : location.pathname]}
items={[{
key: '/system/platform-catalog',
icon: <DatabaseOutlined />,
label: '平台类目与属性',
}]}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header className="system-admin-header">
<span>系统管理 / 平台类目与属性</span>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/online-products')}>
返回工作台
</Button>
</Header>
<Content className="system-admin-content"><Outlet /></Content>
</Layout>
</Layout>
);
}
@@ -87,7 +87,7 @@
}
/*
* 01 / 02 / 04 共用的横向表单:Label 与控件列作为最大 780px 的整体居中。
* 01 / 02 / 04 共用的横向表单:Label 与控件列作为最大 850px 的整体居中。
* 宽度充足时控件不超过 680px;卡片变窄时先吃掉两侧留白,再收缩控件列。
* 容器查询以卡片实际宽度为准,不受左侧菜单或采集面板布局的间接影响。
*/
@@ -99,18 +99,18 @@
min-width: 0;
}
.editor-horizontal-section .ant-form-item-label {
flex: 0 0 100px !important;
max-width: 100px !important;
flex: 0 0 170px !important;
max-width: 170px !important;
}
.editor-horizontal-section .ant-form-item-control {
flex: 0 1 min(680px, calc(100% - 100px)) !important;
flex: 0 1 min(680px, calc(100% - 170px)) !important;
width: auto;
max-width: min(680px, calc(100% - 100px)) !important;
max-width: min(680px, calc(100% - 170px)) !important;
min-width: 0;
}
/*
* 100px 标签列与包装尺寸输入组在 480px 以下已没有足够可读空间;此时才
* 170px 标签列与包装尺寸输入组在 480px 以下已没有足够可读空间;此时才
* 切换为纵向表单。该断点读取卡片宽度,非浏览器窗口宽度。
*/
@container (max-width: 479px) {
+140 -41
View File
@@ -46,6 +46,7 @@ function missingAttributeDrafts(
) {
const missing: AttributeDraft[] = [];
for (const definition of schema) {
if (definition.editor_field) continue;
const scopes = definition.is_aspect
? snapshot.variants.map((variant) => ({
scope: 'variant' as const,
@@ -236,9 +237,8 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
description: nullable(values.profile.description),
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
},
// Schema fields are rendered eagerly so the user can fill them immediately.
// Do not persist untouched optional fields: the API correctly requires every
// stored attribute to contain at least one non-empty value.
// schema 字段会预先加入表单供用户直接填写;未触碰的空选填项不落库,
// 因为服务端要求每条已提交属性至少包含一个非空值。
attributes: (values.attributes ?? []).flatMap((attribute) => {
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
@@ -376,7 +376,7 @@ const ProductEditPage = () => {
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
const [categorySearch, setCategorySearch] = useState('');
// 类目属性 schema 与字典值:仅在展开"更多特征"后按需加载。
// 类目属性 schema 与字典值:选定类目后加载,必填属性始终展示。
const [attributeSchema, setAttributeSchema] = useState<ProductAttributeSchema[]>([]);
const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState<string | null>(null);
const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState<
@@ -387,8 +387,16 @@ const ProductEditPage = () => {
const [dictionaryValues, setDictionaryValues] = useState<
Record<string, ProductAttributeDictionaryValue[]>
>({});
const [dictionaryCursors, setDictionaryCursors] = useState<Record<string, string | null>>({});
const dictionaryQueries = useRef<Record<string, string>>({});
const dictionaryInitialSyncs = useRef(new Set<string>());
const dictionaryPageLoads = useRef(new Set<string>());
const dictionaryContext = `${snapshot?.profile.id ?? ''}:${selectedStoreId ?? ''}:${selectedCategoryId ?? ''}`;
const dictionaryContextRef = useRef(dictionaryContext);
dictionaryContextRef.current = dictionaryContext;
const [catalogRefresh, setCatalogRefresh] = useState(0);
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
const catalogSyncToken = useRef<symbol | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
@@ -412,6 +420,11 @@ const ProductEditPage = () => {
: ([] as ProductAttributeSchema[]),
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
);
// 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。
const editableAttributeSchema = useMemo(
() => activeAttributeSchema.filter((attribute) => !attribute.editor_field),
[activeAttributeSchema]
);
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
const load = useCallback(async () => {
@@ -460,8 +473,14 @@ const ProductEditPage = () => {
active = false;
};
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
// 属性 schema:展开"更多特征"时才加载,同一类目与刷新版本只请求一次;
// 未选中类目时清空全部属性相关状态。
// 切换店铺或类目后,不复用旧类目的字典搜索、游标和自动同步记录。
useEffect(() => {
dictionaryQueries.current = {};
dictionaryInitialSyncs.current.clear();
dictionaryPageLoads.current.clear();
setDictionaryCursors({});
}, [selectedCategoryId, selectedStoreId, snapshot?.profile.id]);
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
useEffect(() => {
if (!snapshot || !currentPlatform || !selectedCategoryId) {
setAttributeSchema([]);
@@ -470,14 +489,12 @@ const ProductEditPage = () => {
setAttributeSchemaLoading(false);
setAttributeSchemaError(null);
setDictionaryValues({});
setDictionaryCursors({});
return;
}
// Attributes are an on-demand panel. Once loaded for the selected
// category, collapsing and reopening it must reuse the same schema.
if (
!moreFeaturesExpanded ||
(attributeSchemaCategoryId === selectedCategoryId &&
attributeSchemaCatalogRefresh === catalogRefresh)
attributeSchemaCategoryId === selectedCategoryId &&
attributeSchemaCatalogRefresh === catalogRefresh
)
return;
let active = true;
@@ -486,6 +503,7 @@ const ProductEditPage = () => {
setAttributeSchema([]);
setAttributeSchemaCategoryId(null);
setDictionaryValues({});
setDictionaryCursors({});
void productEditorApi
.attributeSchema(
snapshot.profile.id,
@@ -525,7 +543,6 @@ const ProductEditPage = () => {
catalogRefresh,
currentPlatform,
form,
moreFeaturesExpanded,
selectedCategoryId,
selectedStoreId,
snapshot,
@@ -535,13 +552,14 @@ const ProductEditPage = () => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
const dictionaryIds = [
...new Set(
activeAttributeSchema
editableAttributeSchema
.map((attribute) => attribute.dictionary_id)
.filter((dictionaryId): dictionaryId is string => !!dictionaryId)
),
];
if (!dictionaryIds.length) {
setDictionaryValues({});
setDictionaryCursors({});
return;
}
let active = true;
@@ -563,20 +581,36 @@ const ProductEditPage = () => {
)
.then((results) => {
if (!active) return;
setDictionaryValues(
Object.fromEntries(results.map(([id, page]) => [id, page.items]))
setDictionaryValues((current) =>
Object.fromEntries(
results.map(([id, page]) => [
id,
dictionaryQueries.current[id] ? (current[id] ?? []) : page.items,
])
)
);
setDictionaryCursors((current) =>
Object.fromEntries(
results.map(([id, page]) => [
id,
dictionaryQueries.current[id] ? (current[id] ?? null) : page.next_cursor,
])
)
);
})
.catch(() => {
if (active) setDictionaryValues({});
if (active) {
setDictionaryValues({});
setDictionaryCursors({});
}
});
return () => {
active = false;
};
}, [
activeAttributeSchema,
catalogRefresh,
currentPlatform,
editableAttributeSchema,
selectedCategoryId,
selectedStoreId,
snapshot,
@@ -773,19 +807,23 @@ const ProductEditPage = () => {
}
return null;
};
/** 发起一次 Ozon 目录同步并跟踪其异步 operation;成功后递增 catalogRefresh 触发缓存重拉。 */
/** 跟踪 Ozon 目录同步;schema 成功后刷新目录,字典只重读对应选项。 */
const runCatalogSync = async (
syncKey: string,
action: () => Promise<{ operation: { id: string } }>,
successMessage: string
successMessage: string | null,
refreshCatalog = true
) => {
const token = Symbol(syncKey);
catalogSyncToken.current = token;
setCatalogSyncing(syncKey);
try {
const queued = await action();
const completed = await waitForCatalogOperation(queued.operation.id);
if (completed?.status === 'succeeded') {
message.success(successMessage);
setCatalogRefresh((version) => version + 1);
if (successMessage) message.success(successMessage);
if (refreshCatalog) setCatalogRefresh((version) => version + 1);
return true;
} else if (completed?.status === 'failed') {
message.error(completed.error?.message ?? 'Ozon 同步失败,请稍后重试');
} else if (completed?.status === 'cancelled') {
@@ -796,8 +834,12 @@ const ProductEditPage = () => {
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setCatalogSyncing(null);
if (catalogSyncToken.current === token) {
catalogSyncToken.current = null;
setCatalogSyncing(null);
}
}
return false;
};
const syncAttributeSchema = () => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
@@ -812,25 +854,51 @@ const ProductEditPage = () => {
'该类目的属性已同步'
);
};
const loadDictionaryValues = async (dictionaryId: string, query = '') => {
const loadDictionaryValues = async (dictionaryId: string, query = '', cursor?: string) => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
const page = await productEditorApi.attributeDictionary(
snapshot.profile.id,
currentPlatform.id,
dictionaryId,
query,
selectedCategoryId,
selectedStoreId
);
setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items }));
};
const syncDictionaryValues = (dictionaryId: string, query: string) => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
if (query.length < 2) {
message.info('请输入至少 2 个字符后再从 Ozon 搜索');
return;
const requestContext = dictionaryContext;
const requestKey = `${requestContext}:${dictionaryId}:${query}:${cursor ?? ''}`;
if (dictionaryPageLoads.current.has(requestKey)) return;
dictionaryPageLoads.current.add(requestKey);
if (!cursor) dictionaryQueries.current[dictionaryId] = query;
try {
const page = await productEditorApi.attributeDictionary(
snapshot.profile.id,
currentPlatform.id,
dictionaryId,
query,
selectedCategoryId,
selectedStoreId,
cursor
);
if (
dictionaryContextRef.current !== requestContext ||
dictionaryQueries.current[dictionaryId] !== query
) return;
setDictionaryValues((current) => ({
...current,
[dictionaryId]: cursor
? [...(current[dictionaryId] ?? []), ...page.items].filter(
(value, index, all) =>
all.findIndex((item) => item.value_id === value.value_id) === index
)
: page.items,
}));
setDictionaryCursors((current) => ({
...current,
[dictionaryId]: page.next_cursor,
}));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
dictionaryPageLoads.current.delete(requestKey);
}
void runCatalogSync(
};
const syncDictionaryValues = async (dictionaryId: string, query: string) => {
if (!snapshot || !currentPlatform || !selectedCategoryId || query.length === 1)
return false;
const requestContext = dictionaryContext;
const succeeded = await runCatalogSync(
`dictionary:${dictionaryId}:${query}`,
() =>
productEditorApi.syncAttributeDictionary(
@@ -840,8 +908,28 @@ const ProductEditPage = () => {
selectedCategoryId,
query
),
'字典值已同步'
).then(() => void loadDictionaryValues(dictionaryId, query));
null,
false
);
if (
succeeded && dictionaryContextRef.current === requestContext &&
dictionaryQueries.current[dictionaryId] === query
) void loadDictionaryValues(dictionaryId, query);
return succeeded;
};
const openDictionaryValues = (dictionaryId: string) => {
void loadDictionaryValues(dictionaryId, '');
if (dictionaryInitialSyncs.current.has(dictionaryId)) return;
dictionaryInitialSyncs.current.add(dictionaryId);
const requestContext = dictionaryContext;
void syncDictionaryValues(dictionaryId, '').then((succeeded) => {
if (!succeeded && dictionaryContextRef.current === requestContext)
dictionaryInitialSyncs.current.delete(dictionaryId);
});
};
const loadMoreDictionaryValues = (dictionaryId: string, query: string) => {
const cursor = dictionaryCursors[dictionaryId];
if (cursor) void loadDictionaryValues(dictionaryId, query, cursor);
};
/** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */
const materializeCandidate = async (transferId: string) => {
@@ -956,6 +1044,8 @@ const ProductEditPage = () => {
void loadDictionaryValues(dictionaryId, query)
}
onSyncDictionaryValues={syncDictionaryValues}
onOpenDictionaryValues={openDictionaryValues}
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
onSyncAttributeSchema={syncAttributeSchema}
/>
@@ -964,7 +1054,16 @@ const ProductEditPage = () => {
snapshot={snapshot}
collectionRecord={collectionRecord}
materializingTransferId={materializingTransferId}
activeAttributeSchema={activeAttributeSchema}
catalogSyncing={catalogSyncing}
dictionaryValues={dictionaryValues}
onMaterialize={materializeCandidate}
onLoadDictionaryValues={(dictionaryId, query) =>
void loadDictionaryValues(dictionaryId, query)
}
onSyncDictionaryValues={syncDictionaryValues}
onOpenDictionaryValues={openDictionaryValues}
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
onDirty={() => setDirty(true)}
/>
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, Select, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type {
ProductAttributeDictionaryValue,
ProductAttributeSchema,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
type NamePath = Array<string | number>;
type Props = {
form: FormInstance<EditorFormValues>;
definitions: ProductAttributeSchema[];
scope: 'common' | 'variant';
variantId: string | null;
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
catalogSyncing: string | null;
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
emptyText?: string;
};
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
function attributeIndex(
attributes: AttributeDraft[],
definition: ProductAttributeSchema,
scope: Props['scope'],
variantId: string | null
) {
return attributes.findIndex(
(attribute) =>
attribute.scope === scope &&
attribute.variant_id === variantId &&
attribute.platform_attribute_id === definition.platform_attribute_id &&
attribute.complex_id === definition.complex_id
);
}
/** 渲染一个平台属性控件;必填星号只表达上架要求,不阻止保存草稿。 */
function DynamicAttributeField({
definition,
index,
dictionaryValues,
catalogSyncing,
onLoadDictionaryValues,
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
}: {
definition: ProductAttributeSchema;
index: number;
dictionaryValues: Props['dictionaryValues'];
catalogSyncing: string | null;
onLoadDictionaryValues: Props['onLoadDictionaryValues'];
onSyncDictionaryValues: Props['onSyncDictionaryValues'];
onOpenDictionaryValues: Props['onOpenDictionaryValues'];
onLoadMoreDictionaryValues: Props['onLoadMoreDictionaryValues'];
}) {
const [dictionaryQuery, setDictionaryQuery] = useState('');
const onSyncRef = useRef(onSyncDictionaryValues);
onSyncRef.current = onSyncDictionaryValues;
useEffect(() => setDictionaryQuery(''), [definition.dictionary_id]);
useEffect(() => {
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
const timer = window.setTimeout(
() => onSyncRef.current(definition.dictionary_id!, dictionaryQuery.trim()),
400
);
return () => window.clearTimeout(timer);
}, [definition.dictionary_id, dictionaryQuery]);
const valueName = ['attributes', index, 'values'] as NamePath;
const scalarValueName = [...valueName, 0] as NamePath;
const dictionaryOptions = definition.dictionary_id
? (dictionaryValues[definition.dictionary_id] ?? []).map((value) => ({
value: value.value_id,
label: value.value,
}))
: [];
const input =
definition.value_type === 'dictionary' ? (
<Form.Item name={definition.is_collection ? valueName : scalarValueName} noStyle>
<Select
mode={definition.is_collection ? 'multiple' : undefined}
maxCount={definition.is_collection ? definition.max_value_count : undefined}
options={dictionaryOptions}
showSearch
filterOption={false}
onOpenChange={(open) => {
if (!open || !definition.dictionary_id) return;
setDictionaryQuery('');
onOpenDictionaryValues(definition.dictionary_id);
}}
onSearch={(query) => {
if (!definition.dictionary_id) return;
const needle = query.trim();
setDictionaryQuery(needle);
onLoadDictionaryValues(definition.dictionary_id, needle);
}}
onPopupScroll={(event) => {
if (!definition.dictionary_id) return;
const target = event.currentTarget;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
onLoadMoreDictionaryValues(
definition.dictionary_id,
dictionaryQuery.trim()
);
}}
notFoundContent={
dictionaryQuery.trim().length >= 2 ? (
<Button
type="link"
loading={
catalogSyncing ===
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
}
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
definition.dictionary_id &&
onSyncDictionaryValues(
definition.dictionary_id,
dictionaryQuery.trim()
)
}
>
从 Ozon 搜索“{dictionaryQuery.trim()}”
</Button>
) : catalogSyncing === `dictionary:${definition.dictionary_id}:` ? (
'正在从 Ozon 加载选项…'
) : (
'暂无选项,输入至少 2 个字符可从 Ozon 搜索'
)
}
placeholder={`选择${definition.name}`}
/>
</Form.Item>
) : definition.value_type === 'boolean' ? (
<Form.Item name={scalarValueName} noStyle>
<Select
options={[
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
]}
placeholder={`选择${definition.name}`}
/>
</Form.Item>
) : definition.is_collection ? (
<Form.Item name={valueName} noStyle>
<Select
mode="tags"
maxCount={definition.max_value_count}
tokenSeparators={[',']}
placeholder={`输入后回车添加,最多 ${definition.max_value_count} 项`}
/>
</Form.Item>
) : (
<Form.Item name={scalarValueName} noStyle>
<Input
type={
definition.value_type === 'integer' || definition.value_type === 'decimal'
? 'number'
: undefined
}
step={definition.value_type === 'integer' ? '1' : 'any'}
placeholder={`填写${definition.name}`}
/>
</Form.Item>
);
return (
<Form.Item
label={definition.name}
required={definition.is_required}
className="dynamic-attribute-item"
extra={
definition.is_collection ? `最多填写 ${definition.max_value_count} 项` : undefined
}
>
<div className="dynamic-attribute-control">{input}</div>
</Form.Item>
);
}
/** 在产品或指定 SKU 的上下文中渲染一组动态属性。 */
export function DynamicAttributeFields({
form,
definitions,
scope,
variantId,
dictionaryValues,
catalogSyncing,
onLoadDictionaryValues,
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
emptyText,
}: Props) {
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
const fields = definitions.flatMap((definition) => {
const index = attributeIndex(attributes, definition, scope, variantId);
return index < 0 ? [] : [{ definition, index }];
});
if (!fields.length)
return emptyText ? <Typography.Text type="secondary">{emptyText}</Typography.Text> : null;
return (
<div className="dynamic-attribute-list">
{fields.map(({ definition, index }) => (
<DynamicAttributeField
key={`${scope}:${variantId ?? 'common'}:${definition.platform_attribute_id}:${definition.complex_id}`}
definition={definition}
index={index}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={onLoadDictionaryValues}
onSyncDictionaryValues={onSyncDictionaryValues}
onOpenDictionaryValues={onOpenDictionaryValues}
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
/>
))}
</div>
);
}
@@ -34,9 +34,28 @@
display: grid;
grid-template-columns: 1fr;
}
.dynamic-attribute-item .ant-space-compact {
/* Ozon 属性名可能很长:Label 使用常规字重并完整换行,不能省略或裁切。 */
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label {
height: auto;
overflow: visible;
text-align: right;
white-space: normal;
}
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label > label {
width: 100%;
height: auto;
align-items: flex-start;
justify-content: flex-end;
overflow: visible;
font-weight: 400;
line-height: 22px;
text-align: right;
text-overflow: clip;
white-space: normal;
word-break: break-word;
}
.dynamic-attribute-control,
.dynamic-attribute-control .ant-select,
.dynamic-attribute-control .ant-input {
width: 100%;
}
.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text {
min-width: 56px;
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
import type { FormInstance } from 'antd';
import { EditOutlined } from '@ant-design/icons';
@@ -9,12 +9,10 @@ import type {
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { SectionTitle } from './SectionTitle';
import { DynamicAttributeFields } from './DynamicAttributeFields';
import './ProductAttributeSection.css';
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
type NamePath = Array<string | number>;
/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */
function sourceValue(value: unknown): string {
if (typeof value === 'string') return value || '—';
@@ -36,6 +34,8 @@ type Props = {
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncAttributeSchema: () => void;
};
@@ -59,15 +59,27 @@ export function ProductAttributeSection({
dictionaryValues,
onLoadDictionaryValues,
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
onSyncAttributeSchema,
}: Props) {
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
const [richContent, setRichContent] = useState('');
// 各字典下拉框的当前搜索词(仅本地状态);切换类目时清空,避免残留到新类目。
const [dictionarySearches, setDictionarySearches] = useState<Record<string, string>>({});
useEffect(() => {
setDictionarySearches({});
}, [selectedCategoryId]);
const commonDefinitions = useMemo(
() =>
activeAttributeSchema.filter(
(definition) => !definition.is_aspect && !definition.editor_field
),
[activeAttributeSchema]
);
const requiredDefinitions = useMemo(
() => commonDefinitions.filter((definition) => definition.is_required),
[commonDefinitions]
);
const optionalDefinitions = useMemo(
() => commonDefinitions.filter((definition) => !definition.is_required),
[commonDefinitions]
);
return (
<Card className="editor-section editor-horizontal-section" bordered={false}>
<SectionTitle
@@ -100,291 +112,92 @@ export function ProductAttributeSection({
<div id="dynamic-attributes" className="dynamic-attributes">
<div className="dynamic-attributes-heading">
<div>
<Typography.Title level={5}>更多特征</Typography.Title>
<Typography.Title level={5}>Ozon 类目属性</Typography.Title>
<Typography.Text type="secondary">
根据所选类目填写 Ozon 平台属性
根据所选类目填写商品级平台属性;变体属性在对应 SKU 中填写
</Typography.Text>
</div>
<Button
type="dashed"
icon={<EditOutlined />}
onClick={() => {
onToggleExpanded();
window.requestAnimationFrame(() =>
document.getElementById('dynamic-attributes')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
);
}}
>
{expanded ? '收起更多特征' : '展开更多特征'}
</Button>
{optionalDefinitions.length > 0 && (
<Button
type="dashed"
icon={<EditOutlined />}
onClick={() => {
onToggleExpanded();
window.requestAnimationFrame(() =>
document.getElementById('dynamic-attributes')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
);
}}
>
{expanded ? '收起选填属性' : '展开选填属性'}
</Button>
)}
</div>
{expanded &&
(!selectedCategoryId ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请选择产品类目后填写平台属性"
/>
) : attributeSchemaLoading ? (
<div className="dynamic-attributes-loading">
<Spin size="small" />
<span>正在读取类目属性…</span>
</div>
) : !activeAttributeSchema.length ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Space direction="vertical" size={4}>
<span>
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
</span>
<Button
type="link"
loading={
catalogSyncing === `attributes:${selectedCategoryId}`
}
onClick={onSyncAttributeSchema}
>
同步此类目属性
</Button>
</Space>
{!selectedCategoryId ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请选择产品类目后填写平台属性"
/>
) : attributeSchemaLoading ? (
<div className="dynamic-attributes-loading">
<Spin size="small" />
<span>正在读取类目属性…</span>
</div>
) : !activeAttributeSchema.length ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Space direction="vertical" size={4}>
<span>
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
</span>
<Button
type="link"
loading={catalogSyncing === `attributes:${selectedCategoryId}`}
onClick={onSyncAttributeSchema}
>
同步此类目属性
</Button>
</Space>
}
/>
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<DynamicAttributeFields
form={form}
definitions={requiredDefinitions}
scope="common"
variantId={null}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={onLoadDictionaryValues}
onSyncDictionaryValues={onSyncDictionaryValues}
onOpenDictionaryValues={onOpenDictionaryValues}
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
emptyText={
commonDefinitions.length
? undefined
: '当前类目没有需要在此填写的商品级属性;变体属性请在 SKU 中填写。'
}
/>
) : (
<Form.List name="attributes">
{(fields) => (
<div className="dynamic-attribute-list">
{activeAttributeSchema.flatMap((definition) => {
// aspect(变体维度)属性按每个 SKU 展开一行,其余类目级一行。
const scopes = definition.is_aspect
? snapshot.variants.map((variant) => ({
scope: 'variant' as const,
variantId: variant.id,
suffix: variant.display_name || '未命名 SKU',
}))
: [
{
scope: 'common' as const,
variantId: null,
suffix: null,
},
];
return scopes.map(({ scope, variantId, suffix }) => {
const field = fields.find((candidate) => {
const draft = form.getFieldValue([
'attributes',
candidate.name,
]) as AttributeDraft | undefined;
return (
draft?.scope === scope &&
draft?.variant_id === variantId &&
draft?.platform_attribute_id ===
definition.platform_attribute_id &&
draft?.complex_id === definition.complex_id
);
});
const label = suffix
? `${definition.name} · ${suffix}`
: definition.name;
// Attribute rows are created together with
// the schema response. A transient render
// may still occur before Form.List receives
// the new field metadata.
if (!field) return null;
// 字典属性:选项来自页面预取的字典缓存;搜索时经
// onLoadDictionaryValues 远程过滤,无结果时可通过
// notFoundContent 的按钮发起 Ozon 同步。
const dictionaryOptions = definition.dictionary_id
? (
dictionaryValues[definition.dictionary_id] ??
[]
).map((value) => ({
value: value.value_id,
label: value.value,
}))
: [];
const dictionaryQuery = definition.dictionary_id
? (dictionarySearches[definition.dictionary_id] ??
'')
: '';
const valueName = [field.name, 'values'] as NamePath;
const scalarValueName = [...valueName, 0] as NamePath;
const valueRules = definition.is_required
? [
{
validator: (
_rule: unknown,
value: unknown
) => {
const entries = Array.isArray(value)
? value
: [value];
return entries.some(
(entry) =>
typeof entry === 'string' &&
entry.trim()
)
? Promise.resolve()
: Promise.reject(
new Error(
`请填写${definition.name}`
)
);
},
},
]
: undefined;
const input =
definition.value_type === 'dictionary' ? (
<Form.Item
name={
definition.is_collection
? valueName
: scalarValueName
}
rules={valueRules}
noStyle
>
<Select
mode={
definition.is_collection
? 'multiple'
: undefined
}
options={dictionaryOptions}
showSearch
filterOption={false}
onSearch={(query) => {
if (!definition.dictionary_id)
return;
setDictionarySearches(
(current) => ({
...current,
[definition.dictionary_id!]:
query,
})
);
onLoadDictionaryValues(
definition.dictionary_id,
query
);
}}
notFoundContent={
dictionaryQuery.trim().length >=
2 ? (
<Button
type="link"
loading={
catalogSyncing ===
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
}
onMouseDown={(event) =>
event.preventDefault()
}
onClick={() =>
definition.dictionary_id &&
onSyncDictionaryValues(
definition.dictionary_id,
dictionaryQuery.trim()
)
}
>
从 Ozon 搜索“
{dictionaryQuery.trim()}”
</Button>
) : (
'输入至少 2 个字符后可从 Ozon 搜索'
)
}
placeholder={`选择${definition.name}`}
/>
</Form.Item>
) : definition.value_type === 'boolean' ? (
<Form.Item
name={scalarValueName}
rules={valueRules}
noStyle
>
<Select
options={[
{
value: 'true',
label: '是',
},
{
value: 'false',
label: '否',
},
]}
placeholder={`选择${definition.name}`}
/>
</Form.Item>
) : definition.is_collection ? (
<Form.Item
name={valueName}
rules={valueRules}
noStyle
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加多个值"
/>
</Form.Item>
) : (
<Form.Item
name={scalarValueName}
rules={valueRules}
noStyle
>
<Input
inputMode={
definition.value_type ===
'integer' ||
definition.value_type === 'decimal'
? 'decimal'
: undefined
}
placeholder={`填写${definition.name}`}
/>
</Form.Item>
);
return (
<Form.Item
key={field.key}
label={label}
required={definition.is_required}
className="dynamic-attribute-item"
>
<Space.Compact block>
{input}
<Button
danger
type="text"
onClick={() =>
form.setFieldValue(
[
'attributes',
field.name,
'values',
],
[]
)
}
>
清除
</Button>
</Space.Compact>
</Form.Item>
);
});
})}
</div>
)}
</Form.List>
))}
{expanded && optionalDefinitions.length > 0 && (
<DynamicAttributeFields
form={form}
definitions={optionalDefinitions}
scope="common"
variantId={null}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={onLoadDictionaryValues}
onSyncDictionaryValues={onSyncDictionaryValues}
onOpenDictionaryValues={onOpenDictionaryValues}
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
/>
)}
</Space>
)}
</div>
{/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */}
{snapshot.unmapped_attributes.length > 0 && (
@@ -15,11 +15,7 @@ type CategoryOption = {
function PackageFields({ name }: { name: NamePath }) {
return (
<div className="package-fields">
<Form.Item
name={[...name, 'shipping_weight_g']}
label="包装重量"
rules={[{ required: true, message: '请填写包装重量' }]}
>
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required>
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
</Form.Item>
<Form.Item label="包装尺寸" required className="package-dimensions">
@@ -148,6 +148,32 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Ozon 变体属性与 SKU 摘要同属卡片主体;必填项在折叠状态下也始终可见。 */
.sku-attribute-panel {
margin-top: 12px;
padding: 12px 0 0 87px;
border-top: 1px solid #f0edf3;
}
.sku-attribute-panel h5.ant-typography {
margin: 0 0 10px;
font-size: 15px;
}
.sku-attribute-panel .ant-form-item {
margin-bottom: 10px;
}
.sku-attribute-panel .ant-form-item-label {
flex: 0 0 120px !important;
text-align: left;
}
.sku-attribute-panel .ant-form-item-label > label {
justify-content: flex-start;
}
.sku-card-expanded .sku-attribute-panel--optional {
margin: 0;
padding: 0;
border: 0;
}
.sku-card-value strong {
min-height: 32px;
padding-top: 5px;
@@ -314,6 +340,9 @@
}
@media (max-width: 560px) {
.sku-attribute-panel {
padding-left: 0;
}
.sku-inline-dimensions {
grid-template-columns: 1fr;
}
@@ -4,10 +4,13 @@ import type { FormInstance } from 'antd';
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
import type {
CollectionItemInspection,
ProductAttributeDictionaryValue,
ProductAttributeSchema,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { SectionTitle } from './SectionTitle';
import { DynamicAttributeFields } from './DynamicAttributeFields';
import './ProductVariantSection.css';
type EditorFormValues = SaveProductEditorDraftRequest;
@@ -82,7 +85,14 @@ type Props = {
snapshot: ProductEditorSnapshot;
collectionRecord: CollectionItemInspection | null;
materializingTransferId: string | null;
activeAttributeSchema: ProductAttributeSchema[];
catalogSyncing: string | null;
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
onMaterialize: (transferId: string) => void;
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
onDirty: () => void;
};
@@ -98,13 +108,26 @@ export function ProductVariantSection({
snapshot,
collectionRecord,
materializingTransferId,
activeAttributeSchema,
catalogSyncing,
dictionaryValues,
onMaterialize,
onLoadDictionaryValues,
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
onDirty,
}: Props) {
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
const requiredAspectDefinitions = activeAttributeSchema.filter(
(definition) => definition.is_aspect && definition.is_required && !definition.editor_field
);
const optionalAspectDefinitions = activeAttributeSchema.filter(
(definition) => definition.is_aspect && !definition.is_required && !definition.editor_field
);
return (
<Card className="editor-section sku-section" bordered={false}>
<SectionTitle
@@ -345,90 +368,156 @@ export function ProductVariantSection({
</div>
</div>
</div>
{requiredAspectDefinitions.length > 0 && (
<div className="sku-attribute-panel">
<Typography.Title level={5}>
Ozon 变体属性
</Typography.Title>
{sourceId ? (
<DynamicAttributeFields
form={form}
definitions={requiredAspectDefinitions}
scope="variant"
variantId={sourceId}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={
onLoadDictionaryValues
}
onSyncDictionaryValues={
onSyncDictionaryValues
}
onOpenDictionaryValues={
onOpenDictionaryValues
}
onLoadMoreDictionaryValues={
onLoadMoreDictionaryValues
}
/>
) : (
<Typography.Text type="secondary">
保存新 SKU 后即可填写其 Ozon 变体属性。
</Typography.Text>
)}
</div>
)}
{expanded && (
<div className="sku-card-expanded">
{optionalAspectDefinitions.length > 0 &&
sourceId && (
<div className="sku-attribute-panel sku-attribute-panel--optional">
<Typography.Title level={5}>
Ozon 选填变体属性
</Typography.Title>
<DynamicAttributeFields
form={form}
definitions={
optionalAspectDefinitions
}
scope="variant"
variantId={sourceId}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={
onLoadDictionaryValues
}
onSyncDictionaryValues={
onSyncDictionaryValues
}
onOpenDictionaryValues={
onOpenDictionaryValues
}
onLoadMoreDictionaryValues={
onLoadMoreDictionaryValues
}
/>
</div>
)}
<div className="sku-media-panel">
<Typography.Title level={5}>
SKU 图片
</Typography.Title>
{imageCandidates.length ? (
<div className="sku-media-grid">
{imageCandidates.map((candidate, imageIndex) => {
// 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
// 避免在此处重复提供后续素材选择流程的入口。
const selectable =
candidate.status ===
'succeeded' &&
!!candidate.asset_id;
const selected =
selectable &&
selectedAssetIds.includes(
candidate.asset_id!
);
return (
<div
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''} ${selected ? 'selected' : ''}`}
key={candidate.id}
>
{imageIndex === 0 && (
<span className="sku-media-main-badge">
主图
</span>
)}
<Image
src={
candidate.source_url
}
alt="采集候选图片"
/>
{selectable ? (
<Button
size="small"
type={
selected
? 'primary'
: 'default'
{imageCandidates.map(
(candidate, imageIndex) => {
// 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
// 避免在此处重复提供后续素材选择流程的入口。
const selectable =
candidate.status ===
'succeeded' &&
!!candidate.asset_id;
const selected =
selectable &&
selectedAssetIds.includes(
candidate.asset_id!
);
return (
<div
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''} ${selected ? 'selected' : ''}`}
key={candidate.id}
>
{imageIndex === 0 && (
<span className="sku-media-main-badge">
主图
</span>
)}
<Image
src={
candidate.source_url
}
onClick={() =>
toggleImage(
candidate.asset_id!
)
}
>
{selected
? '已选择'
: '选择'}
</Button>
) : candidate.status ===
'pending_selection' ? null : (
<Button
size="small"
loading={
materializingTransferId ===
candidate.id
}
disabled={
candidate.status ===
'queued' ||
candidate.status ===
'running' ||
candidate.status ===
'failed'
}
onClick={() =>
void onMaterialize(
alt="采集候选图片"
/>
{selectable ? (
<Button
size="small"
type={
selected
? 'primary'
: 'default'
}
onClick={() =>
toggleImage(
candidate.asset_id!
)
}
>
{selected
? '已选择'
: '选择'}
</Button>
) : candidate.status ===
'pending_selection' ? null : (
<Button
size="small"
loading={
materializingTransferId ===
candidate.id
)
}
>
{candidate.status === 'failed'
? '转存失败'
: '转存中'}
</Button>
)}
</div>
);
})}
}
disabled={
candidate.status ===
'queued' ||
candidate.status ===
'running' ||
candidate.status ===
'failed'
}
onClick={() =>
void onMaterialize(
candidate.id
)
}
>
{candidate.status ===
'failed'
? '转存失败'
: '转存中'}
</Button>
)}
</div>
);
}
)}
{/* 仅提供素材选择入口的视觉占位,实际选择流程后续接入。 */}
<Button
className="sku-media-picker"
@@ -0,0 +1,32 @@
.system-catalog-page { display: grid; gap: 16px; }
.system-catalog-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.system-catalog-heading .ant-typography { margin-bottom: 2px; }
.system-catalog-platform-select { min-width: 190px; }
.system-catalog-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.system-catalog-summary .ant-card-body { padding: 16px 20px; }
.system-catalog-summary .ant-statistic-content { font-size: 19px; }
.system-catalog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.system-catalog-browser { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); gap: 16px; align-items: stretch; }
.system-catalog-category-card, .system-catalog-attribute-card { min-width: 0; }
.system-catalog-tree-panel { display: grid; gap: 12px; max-height: 620px; overflow: auto; }
.system-catalog-tree-panel .ant-tree { min-width: 0; }
.system-catalog-tree-label { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; flex-wrap: wrap; white-space: normal; }
.system-catalog-tree-label .ant-typography { font-size: 12px; }
.system-catalog-attribute-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
.system-catalog-attribute-name .ant-typography:last-child { font-size: 12px; }
.system-catalog-attribute-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.system-catalog-attribute-tags .ant-tag { margin: 0; }
.system-catalog-values-link { padding-inline: 0; }
.system-catalog-values-content { width: 100%; }
.system-catalog-wrap { white-space: normal; overflow-wrap: anywhere; }
.system-catalog-panel-loading { display: grid; min-height: 160px; place-items: center; }
.system-catalog-source-info { margin-bottom: 16px; }
@media (max-width: 1100px) {
.system-catalog-browser { grid-template-columns: 1fr; }
.system-catalog-tree-panel { max-height: 340px; }
}
@media (max-width: 700px) {
.system-catalog-heading { align-items: stretch; flex-direction: column; }
.system-catalog-summary { grid-template-columns: 1fr; }
.system-catalog-platform-select { flex: 1; }
}
@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App as AntdApp, Button, Card, Empty, Select, Space, Statistic,
Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import {
platformCatalogAdminApi,
type AdminPlatform, type CatalogAttribute, type CatalogCategory,
type CatalogRun, type SyncRequest,
} from '@/services/platformCatalogAdmin';
import CatalogSourceModal from './components/CatalogSourceModal';
import DictionaryValuesDrawer from './components/DictionaryValuesDrawer';
import PlatformAttributesTable from './components/PlatformAttributesTable';
import PlatformCategoryTree from './components/PlatformCategoryTree';
import './SystemCatalogPage.css';
function dateTime(value: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未同步';
}
const statusLabels: Record<string, { label: string; color: string }> = {
queued: { label: '排队中', color: 'gold' },
running: { label: '同步中', color: 'blue' },
succeeded: { label: '成功', color: 'green' },
failed: { label: '失败', color: 'red' },
cancelled: { label: '已取消', color: 'default' },
};
const scopeLabels = { tree: '类目树', attributes: '类型属性', dictionary: '字典候选值' };
export default function SystemCatalogPage() {
const { message } = AntdApp.useApp();
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
const [platformId, setPlatformId] = useState<string | null>(null);
const [categories, setCategories] = useState<CatalogCategory[]>([]);
const [attributes, setAttributes] = useState<CatalogAttribute[]>([]);
const [runs, setRuns] = useState<CatalogRun[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedAttribute, setSelectedAttribute] = useState<CatalogAttribute | null>(null);
const [sourceOpen, setSourceOpen] = useState(false);
const [loadingPlatforms, setLoadingPlatforms] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
const [loadingAttributes, setLoadingAttributes] = useState(false);
const [syncingScope, setSyncingScope] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const previousRunStatuses = useRef<Map<string, string>>(new Map());
const selectedCategoryIdRef = useRef<string | null>(null);
const currentPlatform = useMemo(
() => platforms.find((item) => item.id === platformId) ?? null,
[platforms, platformId],
);
const selectedCategory = useMemo(
() => categories.find((item) => item.id === selectedCategoryId) ?? null,
[categories, selectedCategoryId],
);
const loadPlatforms = useCallback(async () => {
setLoadingPlatforms(true);
try {
const items = await platformCatalogAdminApi.platforms();
setPlatforms(items);
setPlatformId((previous) => previous && items.some((item) => item.id === previous)
? previous : (items.find((item) => item.code.toUpperCase() === 'OZON')?.id ?? items[0]?.id ?? null));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingPlatforms(false);
}
}, [message]);
const loadCategories = useCallback(async (id: string) => {
setLoadingCategories(true);
try {
setCategories(await platformCatalogAdminApi.categories(id));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingCategories(false);
}
}, [message]);
const loadAttributes = useCallback(async (id: string, categoryId: string) => {
setLoadingAttributes(true);
try {
setAttributes(await platformCatalogAdminApi.attributes(id, categoryId));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingAttributes(false);
}
}, [message]);
const loadRuns = useCallback(async (id: string) => {
try {
const next = await platformCatalogAdminApi.runs(id);
const completed = next.some((run) => run.status === 'succeeded' &&
previousRunStatuses.current.has(run.id) &&
previousRunStatuses.current.get(run.id) !== 'succeeded');
previousRunStatuses.current = new Map(next.map((run) => [run.id, run.status]));
setRuns(next);
if (completed) {
setRefreshToken((value) => value + 1);
void loadPlatforms();
void loadCategories(id);
if (selectedCategoryIdRef.current) void loadAttributes(id, selectedCategoryIdRef.current);
}
} catch {
// 轮询失败时保留最后一次任务列表,页面手动刷新仍可重试。
}
}, [loadPlatforms, loadCategories, loadAttributes]);
useEffect(() => { void loadPlatforms(); }, [loadPlatforms]);
useEffect(() => {
if (!platformId) return;
setSelectedCategoryId(null);
selectedCategoryIdRef.current = null;
setSelectedAttribute(null);
setCategories([]);
setAttributes([]);
previousRunStatuses.current.clear();
void loadCategories(platformId);
void loadRuns(platformId);
}, [platformId, loadCategories, loadRuns]);
useEffect(() => {
if (!platformId || !selectedCategoryId) return;
void loadAttributes(platformId, selectedCategoryId);
}, [platformId, selectedCategoryId, loadAttributes]);
useEffect(() => {
if (!platformId) return;
const timer = window.setInterval(() => void loadRuns(platformId), 5000);
return () => window.clearInterval(timer);
}, [platformId, loadRuns]);
const sync = async (input: SyncRequest) => {
if (!platformId) return;
setSyncingScope(input.scope);
try {
await platformCatalogAdminApi.sync(platformId, input);
message.success('同步任务已排队');
await loadRuns(platformId);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setSyncingScope(null);
}
};
const saveSource = async (input: { client_id: string; api_key: string }) => {
if (!platformId) return;
try {
const result = await platformCatalogAdminApi.configureSource(platformId, input);
message.success(result.changed
? '同步来源已保存,类目树同步已排队'
: '同步来源没有变化');
await loadPlatforms();
await loadRuns(platformId);
} catch (error) {
message.error(apiErrorMessage(error));
throw error;
}
};
const runColumns: ColumnsType<CatalogRun> = [
{ title: '范围', dataIndex: 'scope', key: 'scope', render: (scope: CatalogRun['scope']) => scopeLabels[scope] },
{ title: '触发', dataIndex: 'trigger', key: 'trigger', render: (value: string) =>
value === 'scheduled' ? '定时' : value === 'source_configured' ? '配置来源' : '手动' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: string) =>
<Tag color={statusLabels[value]?.color}>{statusLabels[value]?.label ?? value}</Tag> },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: dateTime },
{ title: '结果', key: 'result', render: (_value, run) => run.error_message ??
(run.status === 'succeeded' ? Object.entries(run.result)
.map(([key, value]) => `${key}: ${String(value)}`).join(' · ') : '—') },
];
return <div className="system-catalog-page">
<div className="system-catalog-heading">
<div>
<Typography.Title level={3}>平台类目与属性</Typography.Title>
<Typography.Text type="secondary">按平台查看商品类型、属性定义与候选值,并维护同步状态。</Typography.Text>
</div>
<Space wrap>
<Select
className="system-catalog-platform-select"
loading={loadingPlatforms}
placeholder="选择平台"
value={platformId}
options={platforms.map((item) => ({ value: item.id, label: item.name }))}
onChange={setPlatformId}
/>
<Button icon={<ReloadOutlined />} onClick={() => {
void loadPlatforms();
if (platformId) { void loadCategories(platformId); void loadRuns(platformId); }
if (platformId && selectedCategoryId) void loadAttributes(platformId, selectedCategoryId);
}}>刷新</Button>
</Space>
</div>
{!currentPlatform ? <Card><Empty description="暂无平台" /></Card> : <>
{currentPlatform.summary.last_error && <Alert
type="warning" showIcon message="最近一次类目树同步失败"
description={currentPlatform.summary.last_error}
/>}
<div className="system-catalog-summary">
<Card><Statistic title="已缓存类目节点" value={currentPlatform.summary.category_count} /></Card>
<Card><Statistic title="同步来源" value={currentPlatform.source.configured
? currentPlatform.source.client_id_hint ?? '已配置' : '未配置'} /></Card>
<Card><Statistic title="类目树最近成功" value={dateTime(currentPlatform.summary.last_tree_sync_at)} /></Card>
</div>
<div className="system-catalog-actions">
<Space wrap>
<Button icon={<KeyOutlined />} onClick={() => setSourceOpen(true)}
disabled={currentPlatform.code.toUpperCase() !== 'OZON'}>
配置同步来源
</Button>
<Button type="primary" icon={<CloudSyncOutlined />}
disabled={!currentPlatform.source.configured}
loading={syncingScope === 'tree'}
onClick={() => void sync({ scope: 'tree' })}>
同步类目树
</Button>
<Button icon={<CloudSyncOutlined />}
disabled={!selectedCategory || !currentPlatform.source.configured}
loading={syncingScope === 'attributes'}
onClick={() => selectedCategoryId && void sync({ scope: 'attributes', category_id: selectedCategoryId })}>
同步当前类型属性
</Button>
</Space>
<Typography.Text type="secondary">同步在后台执行,失败时保留上次成功的数据。</Typography.Text>
</div>
<div className="system-catalog-browser">
<Card title="类目 / 商品类型" className="system-catalog-category-card"
extra={<Typography.Text type="secondary">{categories.length} 个节点</Typography.Text>}>
<PlatformCategoryTree
categories={categories} loading={loadingCategories}
selectedId={selectedCategoryId}
onSelect={(category) => {
selectedCategoryIdRef.current = category.id;
setSelectedCategoryId(category.id);
}}
/>
</Card>
<Card title={selectedCategory ? `${selectedCategory.name} · 属性定义` : '属性定义'}
className="system-catalog-attribute-card"
extra={selectedCategory && <Typography.Text type="secondary">
类目 ID {selectedCategory.external_category_id} · 类型 ID {selectedCategory.external_type_id}
</Typography.Text>}>
<PlatformAttributesTable
attributes={attributes} loading={loadingAttributes}
selectedCategoryName={selectedCategory?.name ?? null}
onValues={setSelectedAttribute}
/>
</Card>
</div>
<Card title="最近同步任务" extra={<Button type="link" onClick={() => void loadRuns(currentPlatform.id)}>刷新任务</Button>}>
<Table<CatalogRun> rowKey="id" size="small" columns={runColumns}
dataSource={runs} pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 700 }} locale={{ emptyText: '暂无同步任务' }} />
</Card>
<CatalogSourceModal platform={currentPlatform} open={sourceOpen}
onClose={() => setSourceOpen(false)} onSave={saveSource} />
<DictionaryValuesDrawer
platformId={currentPlatform.id} attribute={selectedAttribute}
onClose={() => setSelectedAttribute(null)} refreshToken={refreshToken}
onSync={(attribute) => sync({ scope: 'dictionary', attribute_id: attribute.id })}
/>
</>}
</div>;
}
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { Alert, Form, Input, Modal, Typography } from 'antd';
import type { AdminPlatform } from '@/services/platformCatalogAdmin';
type Props = {
platform: AdminPlatform | null;
open: boolean;
onClose: () => void;
onSave: (input: { client_id: string; api_key: string }) => Promise<void>;
};
/** 密钥仅保留在当前表单内;关闭时清空,不回显已有密钥。 */
export default function CatalogSourceModal({ platform, open, onClose, onSave }: Props) {
const [form] = Form.useForm<{ client_id: string; api_key: string }>();
const [saving, setSaving] = useState(false);
useEffect(() => { if (!open) form.resetFields(); }, [open, form]);
return <Modal
title={`${platform?.name ?? '平台'} · 同步来源配置`}
open={open}
onCancel={onClose}
confirmLoading={saving}
okText="保存配置"
onOk={() => void form.submit()}
destroyOnHidden
>
<Alert
type="info"
showIcon
className="system-catalog-source-info"
message="该凭据仅用于平台共享目录同步"
description="不会展示给普通用户,也不会作为客户门店出现在店铺管理中。保存后 API Key 不再回显。"
/>
{platform?.source.configured && <Typography.Paragraph type="secondary">
当前来源:{platform.source.client_id_hint}。更换来源时请同时输入 Client-Id 和新的 API Key。
</Typography.Paragraph>}
<Form
form={form}
layout="vertical"
autoComplete="off"
onFinish={(input) => {
setSaving(true);
void onSave(input).then(() => { form.resetFields(); onClose(); })
.catch(() => undefined)
.finally(() => setSaving(false));
}}
>
<Form.Item name="client_id" label="Ozon Client-Id" rules={[{ required: true, message: '请输入 Client-Id' }]}>
<Input maxLength={128} autoComplete="off" />
</Form.Item>
<Form.Item name="api_key" label="Ozon Api-Key" rules={[{ required: true, message: '请输入 Api-Key' }]}>
<Input.Password maxLength={2048} autoComplete="new-password" />
</Form.Item>
</Form>
</Modal>;
}
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Drawer, Empty, Input, Pagination, Space, Spin, Table, Typography } from 'antd';
import type { CatalogAttribute, CatalogValue } from '@/services/platformCatalogAdmin';
import { platformCatalogAdminApi } from '@/services/platformCatalogAdmin';
import { apiErrorMessage } from '@/services/api';
type Props = {
platformId: string;
attribute: CatalogAttribute | null;
onClose: () => void;
onSync: (attribute: CatalogAttribute) => Promise<void>;
refreshToken: number;
};
export default function DictionaryValuesDrawer({
platformId, attribute, onClose, onSync, refreshToken,
}: Props) {
const [keyword, setKeyword] = useState('');
const [draftKeyword, setDraftKeyword] = useState('');
const [page, setPage] = useState(1);
const [values, setValues] = useState<CatalogValue[]>([]);
const [total, setTotal] = useState(0);
const [isComplete, setIsComplete] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
setPage(1);
setKeyword('');
setDraftKeyword('');
}, [attribute?.id, platformId]);
useEffect(() => {
if (!attribute) return;
let active = true;
setLoading(true);
setError(null);
void platformCatalogAdminApi.values(platformId, attribute.id, page, keyword)
.then((result) => {
if (!active) return;
setValues(result.data);
setTotal(result.meta.total);
setIsComplete(result.meta.is_complete);
})
.catch((requestError: unknown) => {
if (active) setError(apiErrorMessage(requestError));
})
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [attribute, platformId, page, keyword, refreshToken]);
return <Drawer
title={attribute ? `${attribute.name} · 候选值` : '候选值'}
width={520}
open={!!attribute}
onClose={onClose}
className="system-catalog-values-drawer"
extra={attribute && <Button
loading={syncing}
onClick={() => {
setSyncing(true);
void onSync(attribute).finally(() => setSyncing(false));
}}
>同步此字典</Button>}
>
{attribute && <Space direction="vertical" size="middle" className="system-catalog-values-content">
<Typography.Text type="secondary">
属性 ID {attribute.platform_attribute_id} · 字典 ID {attribute.dictionary_id}
</Typography.Text>
{!isComplete && <Alert
type="info"
showIcon
message="当前为部分候选值缓存"
description="大字典可能还有其他值;列表搜索只查询 Atlas 已缓存的候选值。"
/>}
<Input.Search
allowClear
placeholder="搜索已缓存的名称或值 ID"
value={draftKeyword}
onChange={(event) => setDraftKeyword(event.target.value)}
onSearch={(value) => { setKeyword(value.trim()); setPage(1); }}
/>
{error && <Alert type="error" showIcon message={error} />}
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
: values.length ? <Table<CatalogValue>
rowKey="id"
size="small"
pagination={false}
dataSource={values}
columns={[
{ title: '候选值', dataIndex: 'value', key: 'value', render: (value: string) =>
<span className="system-catalog-wrap">{value}</span> },
{ title: '值 ID', dataIndex: 'external_value_id', key: 'id', width: 145 },
]}
/> : <Empty description="暂无已缓存候选值" />}
<Pagination
current={page}
pageSize={100}
total={total}
showSizeChanger={false}
showTotal={(count) => `已缓存 ${count} 项`}
onChange={setPage}
/>
</Space>}
</Drawer>;
}
@@ -0,0 +1,58 @@
import { Button, Empty, Table, Tag, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { CatalogAttribute } from '@/services/platformCatalogAdmin';
type Props = {
attributes: CatalogAttribute[];
loading: boolean;
selectedCategoryName: string | null;
onValues: (attribute: CatalogAttribute) => void;
};
const typeLabels: Record<string, string> = {
text: '文本', integer: '整数', decimal: '小数', boolean: '布尔', dictionary: '字典',
};
export default function PlatformAttributesTable({
attributes, loading, selectedCategoryName, onValues,
}: Props) {
const columns: ColumnsType<CatalogAttribute> = [
{
title: '属性', dataIndex: 'name', key: 'name',
render: (_value, row) => <div className="system-catalog-attribute-name">
<Tooltip title={row.description ?? undefined}>
<Typography.Text>{row.name}</Typography.Text>
</Tooltip>
<Typography.Text type="secondary">ID {row.platform_attribute_id}</Typography.Text>
</div>,
},
{
title: '规则', key: 'rules', width: 178,
render: (_value, row) => <div className="system-catalog-attribute-tags">
{row.is_required && <Tag color="red">必填</Tag>}
{row.is_aspect && <Tag color="purple">SKU 变体</Tag>}
{!row.is_active && <Tag>已停用</Tag>}
<Tag>{typeLabels[row.value_type] ?? row.value_type}</Tag>
</div>,
},
{
title: '候选值', key: 'values', width: 118,
render: (_value, row) => row.dictionary_id
? <Button type="link" className="system-catalog-values-link" onClick={() => onValues(row)}>
查看 {row.dictionary_value_count}
</Button>
: <Typography.Text type="secondary">自由填报</Typography.Text>,
},
];
if (!selectedCategoryName) return <Empty description="先在左侧选择商品类型" />;
return <Table<CatalogAttribute>
rowKey="id"
size="middle"
columns={columns}
dataSource={attributes}
loading={loading}
pagination={{ pageSize: 20, showSizeChanger: false }}
locale={{ emptyText: '该类型暂无已同步属性' }}
scroll={{ x: 640 }}
/>;
}
@@ -0,0 +1,83 @@
import { useMemo, useState } from 'react';
import { Empty, Input, Spin, Tag, Tree, Typography } from 'antd';
import type { DataNode } from 'antd/es/tree';
import type { CatalogCategory } from '@/services/platformCatalogAdmin';
type Props = {
categories: CatalogCategory[];
loading: boolean;
selectedId: string | null;
onSelect: (category: CatalogCategory) => void;
};
/** 目录节点保留展开能力,只有活动商品类型叶子可选择。 */
export default function PlatformCategoryTree({ categories, loading, selectedId, onSelect }: Props) {
const [search, setSearch] = useState('');
const categoryById = useMemo(() => new Map(categories.map((item) => [item.id, item])), [categories]);
const treeData = useMemo(() => {
const query = search.trim().toLowerCase();
const visible = new Set<string>();
if (query) {
for (const category of categories) {
if (![category.name, category.path, category.external_category_id,
category.external_type_id ?? ''].some((value) => value.toLowerCase().includes(query))) continue;
let node: CatalogCategory | undefined = category;
while (node && !visible.has(node.id)) {
visible.add(node.id);
node = node.parent_id ? categoryById.get(node.parent_id) : undefined;
}
}
}
const childrenByParent = new Map<string | null, CatalogCategory[]>();
for (const category of categories) {
if (query && !visible.has(category.id)) continue;
const parentId = category.parent_id && categoryById.has(category.parent_id)
? category.parent_id : null;
const group = childrenByParent.get(parentId) ?? [];
group.push(category);
childrenByParent.set(parentId, group);
}
const toNodes = (parentId: string | null): DataNode[] =>
(childrenByParent.get(parentId) ?? []).map((category) => ({
key: category.id,
selectable: category.is_active && category.external_type_id !== null &&
category.external_type_id !== '0',
title: (
<span className="system-catalog-tree-label">
<span>{category.name}</span>
{category.external_type_id && category.external_type_id !== '0' &&
<Typography.Text type="secondary">#{category.external_type_id}</Typography.Text>}
{!category.is_active && category.external_type_id !== '0' &&
<Tag color="default">不可建品</Tag>}
</span>
),
children: toNodes(category.id),
}));
return toNodes(null);
}, [categories, categoryById, search]);
return (
<div className="system-catalog-tree-panel">
<Input.Search
allowClear
placeholder="搜索类目、类型或 ID"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
: !treeData.length ? <Empty description="暂无类目;请先同步类目树" />
: <Tree
showLine={{ showLeafIcon: false }}
treeData={treeData}
selectedKeys={selectedId ? [selectedId] : []}
defaultExpandParent={false}
autoExpandParent={!!search}
expandedKeys={search ? [...categoryById.keys()] : undefined}
onSelect={(keys) => {
const category = categoryById.get(String(keys[0] ?? ''));
if (category) onSelect(category);
}}
/>}
</div>
);
}
+10
View File
@@ -16,6 +16,8 @@ import CategoryTreeDebugPage from '@/pages/shops/CategoryTreeDebugPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
import { PlatformProvider } from '@/services/platform';
import { useSession } from '@/services/session';
import SystemAdminLayout from '@/layouts/SystemAdminLayout';
import SystemCatalogPage from '@/pages/system/SystemCatalogPage';
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
function RequireSession({ children }: { children: ReactNode }) {
@@ -71,6 +73,14 @@ export const router = createBrowserRouter([
{ path: 'settings/password', element: <PasswordSettingsPage /> },
],
},
{
path: '/system',
element: <RequireSession><SystemAdminLayout /></RequireSession>,
children: [
{ index: true, element: <Navigate to="/system/platform-catalog" replace /> },
{ path: 'platform-catalog', element: <SystemCatalogPage /> },
],
},
{
path: 'login',
element: (
@@ -0,0 +1,68 @@
import { api } from '@/services/api';
export type AdminPlatform = {
id: string; code: string; name: string; status: string;
source: { configured: boolean; client_id_hint: string | null; updated_at: string | null };
summary: { category_count: number; last_tree_sync_at: string | null; last_error: string | null };
};
export type CatalogCategory = {
id: string; parent_id: string | null; external_category_id: string;
external_type_id: string | null; name: string; path: string;
is_active: boolean; synced_at: string | null;
};
export type CatalogAttribute = {
id: string; platform_attribute_id: string; complex_id: string;
name: string; description: string | null; group_id: string | null;
group_name: string | null; value_type: string; is_required: boolean;
is_aspect: boolean; is_collection: boolean; max_value_count: number;
dictionary_id: string | null; category_dependent: boolean;
is_active: boolean; synced_at: string | null; dictionary_value_count: number;
};
export type CatalogValue = {
id: string; external_value_id: string; value: string;
info: string | null; picture: string | null;
};
export type CatalogRun = {
id: string; platform_id: string; scope: 'tree' | 'attributes' | 'dictionary';
scope_key: string; category_id: string | null; attribute_id: string | null;
trigger: string; status: string; result: Record<string, unknown>;
error_code: string | null; error_message: string | null;
attempt_count: number; created_at: string;
started_at: string | null; completed_at: string | null;
};
export type SyncRequest =
| { scope: 'tree' }
| { scope: 'attributes'; category_id: string }
| { scope: 'dictionary'; attribute_id: string };
export const platformCatalogAdminApi = {
access: () => api.get<{ is_system_admin: boolean }>('/system/access'),
platforms: () => api.get<AdminPlatform[]>('/system/platform-catalog/platforms'),
configureSource: (platformId: string, input: { client_id: string; api_key: string }) =>
api.put<{ configured: boolean; client_id_hint: string; revision: number; changed: boolean }>(
`/system/platform-catalog/platforms/${platformId}/source`, input,
),
categories: (platformId: string) =>
api.get<CatalogCategory[]>(`/system/platform-catalog/platforms/${platformId}/categories`),
attributes: (platformId: string, categoryId: string) =>
api.get<CatalogAttribute[]>(
`/system/platform-catalog/platforms/${platformId}/categories/${categoryId}/attributes`,
),
values: (platformId: string, attributeId: string, page: number, keyword: string) =>
api.getList<CatalogValue, { page: number; page_size: number; total: number; is_complete: boolean }>(
`/system/platform-catalog/platforms/${platformId}/attributes/${attributeId}/values`,
{ params: { page, q: keyword } },
),
sync: (platformId: string, input: SyncRequest) =>
api.post<{ id: string; status: string }>(
`/system/platform-catalog/platforms/${platformId}/syncs`, input,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } },
),
runs: (platformId: string) =>
api.get<CatalogRun[]>(`/system/platform-catalog/platforms/${platformId}/syncs?limit=20`),
};
+27 -4
View File
@@ -4,6 +4,7 @@ import type {
ProductEditorCategoryOption,
ProductEditorContext,
ProductEditorSnapshot,
ProductEditorValidation,
Operation,
SaveProductEditorDraftRequest,
SaveProductEditorDraftResponse,
@@ -39,22 +40,42 @@ export const productEditorApi = {
'If-Match': `"${revisions.product}:${revisions.profile}"`,
},
}),
categoryOptions: (profileId: string, platformId: string, search?: string, storeId?: string | null) =>
categoryOptions: (
profileId: string,
platformId: string,
search?: string,
storeId?: string | null
) =>
api.get<ProductEditorCategoryOption[]>(
`/product-platform-profiles/${profileId}/category-options`,
{
params: { ...(search ? { q: search } : {}), ...(storeId ? { store_id: storeId } : {}) },
params: {
...(search ? { q: search } : {}),
...(storeId ? { store_id: storeId } : {}),
},
headers: { 'X-Atlas-Platform-Id': platformId },
}
),
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null, storeId?: string | null) =>
attributeSchema: (
profileId: string,
platformId: string,
categoryId?: string | null,
storeId?: string | null
) =>
api.get<ProductAttributeSchema[]>(
`/product-platform-profiles/${profileId}/attribute-schema`,
{
params: { ...(categoryId ? { category_id: categoryId } : {}), ...(storeId ? { store_id: storeId } : {}) },
params: {
...(categoryId ? { category_id: categoryId } : {}),
...(storeId ? { store_id: storeId } : {}),
},
headers: { 'X-Atlas-Platform-Id': platformId },
}
),
validation: (profileId: string, platformId: string) =>
api.get<ProductEditorValidation>(`/product-platform-profiles/${profileId}/validation`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
api.post<{ operation: Operation }>(
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
@@ -74,6 +95,7 @@ export const productEditorApi = {
search?: string,
categoryId?: string | null,
storeId?: string | null,
cursor?: string | null
) =>
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
@@ -82,6 +104,7 @@ export const productEditorApi = {
...(categoryId ? { category_id: categoryId } : {}),
...(storeId ? { store_id: storeId } : {}),
...(search ? { q: search } : {}),
...(cursor ? { cursor } : {}),
},
headers: { 'X-Atlas-Platform-Id': platformId },
}
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { platformCatalogAdminApi } from './platformCatalogAdmin';
import { useSession } from './session';
type AccessState = 'checking' | 'allowed' | 'denied';
/** 菜单可见性使用只读探测;后台 API 仍独立校验数据库授权。 */
export function useSystemAdminAccess(): AccessState {
const { status } = useSession();
const [access, setAccess] = useState<AccessState>('checking');
useEffect(() => {
if (status !== 'authenticated') {
setAccess('checking');
return;
}
let active = true;
void platformCatalogAdminApi.access()
.then((result) => { if (active) setAccess(result.is_system_admin ? 'allowed' : 'denied'); })
.catch(() => { if (active) setAccess('denied'); });
return () => { active = false; };
}, [status]);
return access;
}
+36 -6
View File
@@ -19,17 +19,25 @@ import {
type CatalogSyncScope,
} from './ozon-catalog-sync.js';
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.js';
import { PlatformCatalogDispatcher, type PlatformCatalogJob } from './platform-catalog-dispatcher.js';
import { PlatformCatalogScheduler } from './platform-catalog-scheduler.js';
import { markPlatformCatalogUnavailable, syncPlatformCatalog } from './platform-catalog-sync.js';
const connection = new Redis(workerConfig.redisUrl, {
maxRetriesPerRequest: null,
});
const database = createApplicationDatabase(workerConfig.databaseUrl);
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
const platformQueue = new Queue<PlatformCatalogJob>('atlas-dispatch', { connection });
const dispatcher = new OutboxDispatcher(
database,
queue,
`worker_${randomUUID()}`,
);
const platformDispatcher = new PlatformCatalogDispatcher(
database, platformQueue, `platform_worker_${randomUUID()}`,
);
const platformScheduler = new PlatformCatalogScheduler(database);
const objectStorage = new S3Client({
endpoint: workerConfig.objectStorageEndpoint,
region: 'us-east-1',
@@ -54,15 +62,17 @@ type CatalogSyncJob = OutboxJob & {
};
};
const worker = new Worker<OutboxJob>(
const worker = new Worker<OutboxJob | PlatformCatalogJob>(
'atlas-dispatch',
async (job) => {
if (job.name === 'store_credential_verification.requested') {
if (job.name === 'platform_catalog_sync.requested') {
await syncPlatformCatalog(database, job.data as PlatformCatalogJob);
} else 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[] } });
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data as OutboxJob);
} else if (
job.name === 'store_category_tree_sync.requested' ||
job.name === 'store_category_attribute_sync.requested' ||
@@ -84,7 +94,9 @@ async function cleanupCollection(database: Database, job: OutboxJob & { payload:
worker.on('failed', (job, error) => {
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
if (job.name === 'store_credential_verification.requested') {
if (job.name === 'platform_catalog_sync.requested') {
void markPlatformCatalogUnavailable(database, job.data as PlatformCatalogJob);
} else if (job.name === 'store_credential_verification.requested') {
void markUnavailable(database, job.data as StoreVerificationJob, error);
} else if (
job.name === 'store_category_tree_sync.requested' ||
@@ -285,20 +297,36 @@ async function markUnavailable(
let dispatcherTimer: ReturnType<typeof setInterval> | undefined;
let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
let platformCatalogTimer: ReturnType<typeof setInterval> | undefined;
async function bootstrap() {
await queue.waitUntilReady();
await worker.waitUntilReady();
await dispatcher.dispatchBatch();
await platformDispatcher.dispatchBatch();
await platformScheduler.run();
dispatcherTimer = setInterval(
() =>
() => {
void dispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
}),
});
void platformDispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'platform_outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
},
2_000,
);
platformCatalogTimer = setInterval(
() => void platformScheduler.run().catch((error: unknown) => {
console.error('platform_catalog_schedule_failed', error instanceof Error ? error.message : error);
}),
60_000,
);
void mediaUploadCleanup.run().catch((error: unknown) => {
console.error(
'media_upload_cleanup_startup_failed',
@@ -329,8 +357,10 @@ void bootstrap().catch((error: unknown) => {
async function shutdown() {
if (dispatcherTimer) clearInterval(dispatcherTimer);
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
if (platformCatalogTimer) clearInterval(platformCatalogTimer);
await worker.close();
await queue.close();
await platformQueue.close();
objectStorage.destroy();
await database.close();
await connection.quit();
+75 -8
View File
@@ -87,18 +87,85 @@ export async function fetchOzonDictionaryValues(
},
) {
const query = input.query?.trim() ?? '';
const path = query
? '/v1/description-category/attribute/values/search'
: '/v1/description-category/attribute/values';
const body = await requestOzon(clientId, apiKey, path, {
const request = {
attribute_id: numberOrText(input.attributeId),
description_category_id: numberOrText(input.categoryId),
type_id: numberOrText(input.typeId),
language: 'ZH_HANS',
limit: query ? 100 : 1_000,
...(query ? { value: query } : { last_value_id: 0 }),
});
return readResultArray<OzonDictionaryValue>(body, '属性字典');
};
if (query) {
const body = await requestOzon(
clientId, apiKey, '/v1/description-category/attribute/values/search',
{ ...request, limit: 100, value: query },
);
return readResultArray<OzonDictionaryValue>(body, '属性字典');
}
const values: OzonDictionaryValue[] = [];
const seen = new Set<string>();
let lastValueId: number | string = 0;
// 大字典仅预取首批 1000 项;后续值由关键词搜索补充,重复游标不得造成无限循环。
for (let pageIndex = 0; pageIndex < 20 && values.length < 1_000; pageIndex += 1) {
const body = await requestOzon(
clientId, apiKey, '/v1/description-category/attribute/values',
{ ...request, limit: 1_000 - values.length, last_value_id: lastValueId },
);
const page = readResultArray<OzonDictionaryValue>(body, '属性字典');
let added = 0;
for (const value of page) {
const id = String(value.id ?? '');
if (!id || seen.has(id)) continue;
seen.add(id);
values.push(value);
added += 1;
}
const nextId = page.at(-1)?.id;
if (
(body as { has_next?: unknown }).has_next !== true || !page.length ||
!added || nextId == null || String(nextId) === String(lastValueId)
) break;
lastValueId = typeof nextId === 'string' ? numberOrText(nextId) : nextId;
}
return values;
}
/** 平台级字典按页获取,明确返回完整性;大字典不会伪装成已全量同步。 */
export async function fetchOzonDictionaryBatch(
clientId: string,
apiKey: string,
input: { attributeId: string; categoryId: string; typeId: string },
): Promise<{ values: OzonDictionaryValue[]; isComplete: boolean }> {
const values: OzonDictionaryValue[] = [];
const seen = new Set<string>();
let lastValueId: number | string = 0;
for (let pageIndex = 0; pageIndex < 5; pageIndex += 1) {
const body = await requestOzon(
clientId, apiKey, '/v1/description-category/attribute/values',
{
attribute_id: numberOrText(input.attributeId),
description_category_id: numberOrText(input.categoryId),
type_id: numberOrText(input.typeId),
language: 'ZH_HANS',
limit: 2000,
last_value_id: lastValueId,
},
);
const page = readResultArray<OzonDictionaryValue>(body, '属性字典');
for (const value of page) {
const id = String(value.id ?? '').trim();
if (id && !seen.has(id)) {
seen.add(id);
values.push(value);
}
}
const hasNext = (body as { has_next?: unknown }).has_next === true;
if (!hasNext) return { values, isComplete: true };
const nextId = page.at(-1)?.id;
if (!page.length || nextId == null || String(nextId) === String(lastValueId)) {
throw new OzonRetryableError('Ozon 字典分页游标没有前进');
}
lastValueId = nextId;
}
return { values, isComplete: false };
}
async function requestOzon(
@@ -0,0 +1,68 @@
import type { Queue } from 'bullmq';
import type { Database } from '@atlas/database';
export type PlatformCatalogJob = {
eventId: string;
runId: string;
platformId: string;
};
export class PlatformCatalogDispatcher {
public constructor(
private readonly database: Database,
private readonly queue: Queue<PlatformCatalogJob>,
private readonly workerId: string,
) {}
public async dispatchBatch() {
const events = await this.database.transaction((tx) => tx.query<{
id: string; run_id: string; platform_id: string;
}>(
`WITH candidates AS (
SELECT id FROM atlas.platform_catalog_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.platform_catalog_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.run_id, event.platform_id`,
[this.workerId],
));
for (const event of events.rows) {
try {
await this.queue.add('platform_catalog_sync.requested', {
eventId: event.id,
runId: event.run_id,
platformId: event.platform_id,
}, {
jobId: event.id,
attempts: 5,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: 1000,
removeOnFail: 1000,
});
await this.database.query(
`UPDATE atlas.platform_catalog_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.id, this.workerId],
);
} catch (error) {
await this.database.query(
`UPDATE atlas.platform_catalog_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', updated_at = now()
WHERE id = $1 AND status = 'dispatching' AND locked_by = $2`,
[event.id, this.workerId],
);
throw error;
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { OzonCategoryAttribute, OzonCategoryNode } from './ozon-client.js';
export type PlatformCategoryRecord = {
external_category_id: string;
external_type_id: string;
parent_key: string | null;
name: string;
path: string;
is_active: boolean;
source_payload: OzonCategoryNode;
};
/** Ozon 类型叶子可能省略类目 ID,需要继承最近的父类目。 */
export function flattenPlatformCategories(
nodes: OzonCategoryNode[],
path: string[] = [],
parentKey: string | null = null,
inheritedCategory?: { id: string; name: string },
): PlatformCategoryRecord[] {
const records: PlatformCategoryRecord[] = [];
for (const node of nodes) {
const explicitId = String(node.description_category_id ?? '').trim();
const explicitName = node.category_name?.trim() ?? '';
const categoryId = explicitId || inheritedCategory?.id || '';
const categoryName = explicitName || inheritedCategory?.name || '';
const typeId = String(node.type_id ?? 0);
const name = explicitName || node.type_name?.trim() || categoryName;
if (!categoryId || !name) continue;
const nextPath = [...path, name];
const key = `${categoryId}:${typeId}`;
records.push({
external_category_id: categoryId,
external_type_id: typeId,
parent_key: parentKey,
name,
path: nextPath.join(' › '),
is_active: node.disabled !== true && typeId !== '0',
source_payload: node,
});
records.push(...flattenPlatformCategories(
node.children ?? [],
nextPath,
key,
explicitId && explicitName ? { id: explicitId, name: explicitName } : inheritedCategory,
));
}
return records;
}
export type PlatformAttributeRecord = {
platform_attribute_id: string;
complex_id: string;
name: string;
description: string | null;
group_id: string | null;
group_name: string | null;
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
is_required: boolean;
is_collection: boolean;
max_value_count: number;
dictionary_id: string | null;
is_aspect: boolean;
category_dependent: boolean;
source_payload: OzonCategoryAttribute;
};
export function mapPlatformAttributes(values: OzonCategoryAttribute[]): PlatformAttributeRecord[] {
const byKey = new Map<string, PlatformAttributeRecord>();
for (const value of values) {
const attributeId = String(value.id ?? '').trim();
const name = value.name?.trim();
if (!attributeId || !name) continue;
const complexId = String(value.attribute_complex_id ?? value.complex_id ?? 0);
const dictionaryId = String(value.dictionary_id ?? '').trim();
const hasDictionary = !!dictionaryId && dictionaryId !== '0';
const isCollection = value.is_collection === true;
const requestedMax = Number(value.max_value_count ?? (isCollection ? 100 : 1));
const type = String(value.type ?? '').toLowerCase();
const record: PlatformAttributeRecord = {
platform_attribute_id: attributeId,
complex_id: complexId,
name,
description: stringOrNull(value.description),
group_id: value.group_id == null ? null : String(value.group_id),
group_name: stringOrNull(value.group_name),
value_type: hasDictionary ? 'dictionary'
: type === 'integer' ? 'integer'
: type === 'decimal' || type === 'number' ? 'decimal'
: type === 'boolean' ? 'boolean' : 'text',
is_required: value.is_required === true,
is_collection: isCollection,
max_value_count: isCollection
? Math.max(1, Math.min(100, Number.isFinite(requestedMax) ? requestedMax : 100))
: 1,
dictionary_id: hasDictionary ? dictionaryId : null,
is_aspect: value.is_aspect === true,
category_dependent: value.category_dependent === true,
source_payload: value,
};
byKey.set(`${attributeId}:${complexId}`, record);
}
return [...byKey.values()];
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
@@ -0,0 +1,103 @@
import type { Database, DatabaseTransaction } from '@atlas/database';
type Candidate = { scope: 'tree' | 'attributes' | 'dictionary'; scope_key: string;
category_id: string | null; attribute_id: string | null };
/** 定时任务逐平台限量排队,避免启动时一次扫完全部类目和大字典。 */
export class PlatformCatalogScheduler {
public constructor(private readonly database: Database) {}
public async run() {
const sources = await this.database.query<{ platform_id: string }>(
`SELECT source.platform_id FROM atlas.platform_catalog_sources source
JOIN atlas.platforms platform ON platform.id = source.platform_id
WHERE platform.status = 'active' AND upper(platform.code) = 'OZON'`,
);
for (const source of sources.rows) {
await this.schedulePlatform(source.platform_id);
}
}
private async schedulePlatform(platformId: string) {
await this.database.transaction(async (tx) => {
const source = await tx.query<{ revision: number }>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
if (!source.rows[0]) return;
const pending = await tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
[platformId],
);
if (Number(pending.rows[0]?.count ?? 0) >= 6) return;
const candidates = await tx.query<Candidate>(
`WITH possible AS (
SELECT 'tree'::text AS scope, 'tree'::text AS scope_key,
NULL::uuid AS category_id, NULL::uuid AS attribute_id,
interval '24 hours' AS refresh_interval, 0 AS priority
UNION ALL
SELECT 'attributes', 'attributes:' || category.id, category.id, NULL::uuid,
interval '24 hours', 1
FROM atlas.platform_product_categories category
WHERE category.platform_id = $1 AND category.is_active
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
UNION ALL
SELECT 'dictionary', 'dictionary:' || attribute.id,
attribute.platform_product_category_id, attribute.id,
interval '7 days', 2
FROM atlas.platform_product_category_attributes attribute
JOIN atlas.platform_product_categories category
ON category.id = attribute.platform_product_category_id
WHERE attribute.platform_id = $1 AND attribute.is_active
AND attribute.dictionary_id IS NOT NULL AND category.is_active
)
SELECT possible.scope, possible.scope_key, possible.category_id, possible.attribute_id
FROM possible
WHERE NOT EXISTS (
SELECT 1 FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = $1 AND run.scope_key = possible.scope_key
AND run.status = 'succeeded'
AND run.completed_at > now() - possible.refresh_interval
) AND NOT EXISTS (
SELECT 1 FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = $1 AND run.scope_key = possible.scope_key
AND run.created_at > now() - interval '1 hour'
) AND NOT EXISTS (
SELECT 1 FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = $1 AND run.scope_key = possible.scope_key
AND run.status IN ('queued', 'running')
)
ORDER BY possible.priority, possible.scope_key LIMIT 6`,
[platformId],
);
const capacity = 6 - Number(pending.rows[0]?.count ?? 0);
for (const candidate of candidates.rows.slice(0, capacity)) {
await enqueueScheduled(tx, platformId, source.rows[0].revision, candidate);
}
});
}
}
async function enqueueScheduled(
tx: DatabaseTransaction,
platformId: string,
sourceRevision: number,
candidate: Candidate,
) {
const run = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_catalog_sync_runs
(platform_id, scope, scope_key, category_id, attribute_id,
trigger_kind, source_revision)
VALUES ($1, $2, $3, $4, $5, 'scheduled', $6)
RETURNING id`,
[platformId, candidate.scope, candidate.scope_key,
candidate.category_id, candidate.attribute_id, sourceRevision],
);
await tx.query(
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id)
VALUES ($1, $2)`,
[run.rows[0].id, platformId],
);
}
+337
View File
@@ -0,0 +1,337 @@
import type { Database, DatabaseTransaction } from '@atlas/database';
import {
fetchOzonCategoryAttributes, fetchOzonCategoryTree, fetchOzonDictionaryBatch,
OzonCredentialError, OzonRequestError, type OzonCategoryNode,
type OzonDictionaryValue,
} from './ozon-client.js';
import { flattenPlatformCategories, mapPlatformAttributes } from './platform-catalog-mappers.js';
import type { PlatformCatalogJob } from './platform-catalog-dispatcher.js';
import { decryptStoreSecret } from './store-secret.crypto.js';
import { workerConfig } from './config.js';
type RunSource = {
id: string; platform_id: string; scope: 'tree' | 'attributes' | 'dictionary';
category_id: string | null; attribute_id: string | null;
source_revision: number; platform_code: string;
client_id: string; secret_ciphertext: Buffer; secret_nonce: Buffer;
current_revision: number;
external_category_id: string | null; external_type_id: string | null;
platform_attribute_id: string | null;
};
type SyncPayload =
| { scope: 'tree'; nodes: OzonCategoryNode[] }
| { scope: 'attributes'; attributes: Awaited<ReturnType<typeof fetchOzonCategoryAttributes>> }
| { scope: 'dictionary'; batch: Awaited<ReturnType<typeof fetchOzonDictionaryBatch>> };
export async function syncPlatformCatalog(database: Database, job: PlatformCatalogJob) {
const source = await claimRun(database, job);
if (!source) return;
if (source.source_revision !== source.current_revision) {
await finishRun(database, job.runId, 'cancelled', 'CATALOG_SOURCE_REPLACED', '同步来源已更新');
return;
}
if (source.platform_code.toUpperCase() !== 'OZON') {
await finishRun(database, job.runId, 'failed', 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
return;
}
const apiKey = decryptStoreSecret(
source.secret_ciphertext, source.secret_nonce, workerConfig.storeSecretEncryptionKey,
);
try {
let payload: SyncPayload;
if (source.scope === 'tree') {
payload = { scope: 'tree', nodes: await fetchOzonCategoryTree(source.client_id, apiKey) };
} else {
if (!source.external_category_id || !source.external_type_id || source.external_type_id === '0') {
await finishRun(database, job.runId, 'failed', 'CATEGORY_TYPE_UNAVAILABLE', '商品类型不存在或已停用');
return;
}
if (source.scope === 'attributes') {
payload = {
scope: 'attributes',
attributes: await fetchOzonCategoryAttributes(
source.client_id, apiKey,
source.external_category_id, source.external_type_id,
),
};
} else {
if (!source.platform_attribute_id) {
await finishRun(database, job.runId, 'failed', 'ATTRIBUTE_NOT_FOUND', '字典属性已失效');
return;
}
payload = {
scope: 'dictionary',
batch: await fetchOzonDictionaryBatch(source.client_id, apiKey, {
attributeId: source.platform_attribute_id,
categoryId: source.external_category_id,
typeId: source.external_type_id,
}),
};
}
}
await persistAndComplete(database, source, payload);
} catch (error) {
if (error instanceof OzonCredentialError) {
await finishRun(
database, job.runId, 'failed', 'OZON_CATALOG_CREDENTIAL_INVALID',
error.status === 403 ? 'Ozon 拒绝读取目录,请检查密钥权限' : 'Ozon 未接受同步来源凭据',
);
return;
}
if (error instanceof OzonRequestError) {
await finishRun(
database, job.runId, 'failed', 'OZON_CATALOG_REQUEST_REJECTED',
`Ozon 拒绝目录同步请求(HTTP ${error.status})`,
);
return;
}
throw error;
}
}
export async function markPlatformCatalogUnavailable(database: Database, job: PlatformCatalogJob) {
await finishRun(
database, job.runId, 'failed', 'PLATFORM_CATALOG_SYNC_UNAVAILABLE',
'暂时无法同步平台目录,请稍后重试',
);
}
async function claimRun(database: Database, job: PlatformCatalogJob): Promise<RunSource | null> {
return database.transaction(async (tx) => {
const result = await tx.query<RunSource>(
`SELECT run.id, run.platform_id, run.scope, run.category_id, run.attribute_id,
run.source_revision, platform.code AS platform_code,
source.client_id, source.secret_ciphertext, source.secret_nonce,
source.revision AS current_revision,
category.external_category_id, category.external_type_id,
attribute.platform_attribute_id
FROM atlas.platform_catalog_sync_runs run
JOIN atlas.platforms platform ON platform.id = run.platform_id
JOIN atlas.platform_catalog_sources source ON source.platform_id = run.platform_id
LEFT JOIN atlas.platform_product_categories category
ON category.id = run.category_id AND category.platform_id = run.platform_id
LEFT JOIN atlas.platform_product_category_attributes attribute
ON attribute.id = run.attribute_id AND attribute.platform_id = run.platform_id
WHERE run.id = $1 AND run.platform_id = $2
AND run.status IN ('queued', 'running')
FOR UPDATE OF run`,
[job.runId, job.platformId],
);
const source = result.rows[0];
if (!source) return null;
await tx.query(
`UPDATE atlas.platform_catalog_sync_runs
SET status = 'running', started_at = coalesce(started_at, now()),
attempt_count = attempt_count + 1
WHERE id = $1`,
[job.runId],
);
return source;
});
}
async function persistAndComplete(database: Database, source: RunSource, payload: SyncPayload) {
await database.transaction(async (tx) => {
// 凭据更新与结果发布共用来源行锁,旧任务不能覆盖新来源的目录。
const revision = await tx.query<{ revision: number }>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[source.platform_id],
);
const run = await tx.query<{ status: string }>(
`SELECT status FROM atlas.platform_catalog_sync_runs WHERE id = $1 FOR UPDATE`,
[source.id],
);
if (run.rows[0]?.status !== 'running') return;
if (revision.rows[0]?.revision !== source.source_revision) {
await updateRun(tx, source.id, 'cancelled', null, 'CATALOG_SOURCE_REPLACED', '同步来源已更新');
return;
}
let result: Record<string, unknown>;
if (payload.scope === 'tree') {
result = await persistTree(tx, source.platform_id, payload.nodes);
} else if (payload.scope === 'attributes') {
result = await persistAttributes(tx, source.platform_id, source.category_id ?? '', payload.attributes);
} else {
result = await persistDictionary(
tx, source.platform_id, source.category_id ?? '',
source.attribute_id ?? '', payload.batch.values, payload.batch.isComplete,
);
}
await updateRun(tx, source.id, 'succeeded', result);
});
}
async function persistTree(tx: DatabaseTransaction, platformId: string, nodes: OzonCategoryNode[]) {
const records = [...new Map(flattenPlatformCategories(nodes).map((node) => [
`${node.external_category_id}:${node.external_type_id}`, node,
])).values()];
if (!records.length) throw new Error('Ozon 返回了空类目树');
const syncTime = new Date();
await tx.query(
`INSERT INTO atlas.platform_product_categories
(platform_id, external_category_id, external_type_id, name, path,
is_active, source_payload, synced_at)
SELECT $1, external_category_id, external_type_id, name, path,
is_active, source_payload, $3::timestamptz
FROM jsonb_to_recordset($2::jsonb) AS row(
external_category_id text, external_type_id text, parent_key text,
name text, path text, is_active boolean, source_payload jsonb)
ON CONFLICT (platform_id, external_category_id, external_type_id) DO UPDATE
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
updated_at = now()`,
[platformId, JSON.stringify(records), syncTime],
);
await tx.query(
`WITH incoming AS (
SELECT * FROM jsonb_to_recordset($2::jsonb) AS row(
external_category_id text, external_type_id text, parent_key text,
name text, path text, is_active boolean, source_payload jsonb)
), resolved AS (
SELECT child.id AS child_id, parent.id AS parent_id
FROM incoming
JOIN atlas.platform_product_categories child
ON child.platform_id = $1
AND child.external_category_id = incoming.external_category_id
AND child.external_type_id = incoming.external_type_id
LEFT JOIN atlas.platform_product_categories parent
ON parent.platform_id = $1
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
)
UPDATE atlas.platform_product_categories category
SET parent_id = resolved.parent_id, updated_at = now()
FROM resolved WHERE category.id = resolved.child_id`,
[platformId, JSON.stringify(records)],
);
await tx.query(
`UPDATE atlas.platform_product_categories
SET is_active = false, updated_at = now()
WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
[platformId, syncTime],
);
return { category_count: records.length };
}
async function persistAttributes(
tx: DatabaseTransaction, platformId: string, categoryId: string,
attributes: Awaited<ReturnType<typeof fetchOzonCategoryAttributes>>,
) {
const records = mapPlatformAttributes(attributes);
if (!records.length) throw new Error('Ozon 返回了空属性定义');
const syncTime = new Date();
await tx.query(
`INSERT INTO atlas.platform_product_category_attributes
(platform_id, platform_product_category_id, platform_attribute_id, complex_id,
name, description, group_id, group_name, value_type, is_required,
is_collection, max_value_count, dictionary_id, is_aspect,
category_dependent, is_active, source_payload, synced_at)
SELECT $1, $2, platform_attribute_id, complex_id,
name, description, group_id, group_name, value_type, is_required,
is_collection, max_value_count, dictionary_id, is_aspect,
category_dependent, true, source_payload, $4::timestamptz
FROM jsonb_to_recordset($3::jsonb) AS row(
platform_attribute_id text, complex_id text, name text, description text,
group_id text, group_name text, value_type text, is_required boolean,
is_collection boolean, max_value_count integer, dictionary_id text,
is_aspect boolean, category_dependent boolean, source_payload jsonb)
ON CONFLICT (platform_id, platform_product_category_id, platform_attribute_id, complex_id)
DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description,
group_id = EXCLUDED.group_id, group_name = EXCLUDED.group_name,
value_type = EXCLUDED.value_type, is_required = EXCLUDED.is_required,
is_collection = EXCLUDED.is_collection, max_value_count = EXCLUDED.max_value_count,
dictionary_id = EXCLUDED.dictionary_id, is_aspect = EXCLUDED.is_aspect,
category_dependent = EXCLUDED.category_dependent, is_active = true,
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
updated_at = now()`,
[platformId, categoryId, JSON.stringify(records), syncTime],
);
await tx.query(
`UPDATE atlas.platform_product_category_attributes
SET is_active = false, updated_at = now()
WHERE platform_id = $1 AND platform_product_category_id = $2
AND synced_at IS DISTINCT FROM $3::timestamptz`,
[platformId, categoryId, syncTime],
);
return { attribute_count: records.length };
}
async function persistDictionary(
tx: DatabaseTransaction, platformId: string, categoryId: string,
attributeId: string, values: OzonDictionaryValue[], isComplete: boolean,
) {
const attribute = await tx.query<{ dictionary_id: string }>(
`SELECT dictionary_id FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND platform_product_category_id = $3
AND is_active AND dictionary_id IS NOT NULL`,
[attributeId, platformId, categoryId],
);
if (!attribute.rows[0]) throw new Error('字典属性已失效');
const byId = new Map<string, {
external_value_id: string; value: string; info: string | null;
picture: string | null; source_payload: OzonDictionaryValue;
}>();
for (const value of values) {
const id = String(value.id ?? '').trim();
const label = value.value?.trim() ?? '';
if (!id || !label) continue;
byId.set(id, {
external_value_id: id,
value: label,
info: typeof value.info === 'string' ? value.info : null,
picture: typeof value.picture === 'string' ? value.picture : null,
source_payload: value,
});
}
const records = [...byId.values()];
const syncTime = new Date();
if (records.length) {
await tx.query(
`INSERT INTO atlas.platform_catalog_dictionary_values
(platform_id, platform_product_category_id, attribute_id, dictionary_id,
external_value_id, value, info, picture, source_payload, synced_at)
SELECT $1, $2, $3, $4, external_value_id, value, info, picture,
source_payload, $6::timestamptz
FROM jsonb_to_recordset($5::jsonb) AS row(
external_value_id text, value text, info text, picture text, source_payload jsonb)
ON CONFLICT (attribute_id, external_value_id) DO UPDATE
SET value = EXCLUDED.value, info = EXCLUDED.info,
picture = EXCLUDED.picture, source_payload = EXCLUDED.source_payload,
is_active = true, synced_at = EXCLUDED.synced_at, updated_at = now()`,
[platformId, categoryId, attributeId, attribute.rows[0].dictionary_id,
JSON.stringify(records), syncTime],
);
}
if (isComplete) {
await tx.query(
`UPDATE atlas.platform_catalog_dictionary_values
SET is_active = false, updated_at = now()
WHERE attribute_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
[attributeId, syncTime],
);
}
return { dictionary_value_count: records.length, is_complete: isComplete };
}
async function finishRun(
database: Database, runId: string,
status: 'failed' | 'cancelled', code: string, message: string,
) {
await database.transaction((tx) => updateRun(tx, runId, status, null, code, message));
}
async function updateRun(
tx: DatabaseTransaction, runId: string,
status: 'succeeded' | 'failed' | 'cancelled',
result: Record<string, unknown> | null,
code?: string, message?: string,
) {
await tx.query(
`UPDATE atlas.platform_catalog_sync_runs
SET status = $2::atlas.operation_status, completed_at = now(),
result = $3::jsonb, error_code = $4, error_message = $5
WHERE id = $1 AND status IN ('queued', 'running')`,
[runId, status, JSON.stringify(result ?? {}), code ?? null, message ?? null],
);
}
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fetchOzonDictionaryBatch, fetchOzonDictionaryValues } from '../src/ozon-client.ts';
const input = {
attributeId: '123',
categoryId: '456',
typeId: '789',
};
test('属性字典按 Ozon 游标读取多页', async () => {
const previousFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push({ url, body: JSON.parse(options.body) });
const data = requests.length === 1
? { result: [{ id: 1, value: '中国' }], has_next: true }
: { result: [{ id: 2, value: '法国' }], has_next: false };
return new Response(JSON.stringify(data), { status: 200 });
};
try {
const values = await fetchOzonDictionaryValues('client', 'key', input);
assert.deepEqual(values.map((value) => value.value), ['中国', '法国']);
assert.equal(requests.length, 2);
assert.equal(requests[0].body.last_value_id, 0);
assert.equal(requests[1].body.last_value_id, 1);
assert.ok(requests.every((request) => request.url.endsWith('/attribute/values')));
} finally {
globalThis.fetch = previousFetch;
}
});
test('关键词使用 Ozon 字典搜索接口', async () => {
const previousFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push({ url, body: JSON.parse(options.body) });
return new Response(JSON.stringify({ result: [{ id: 3, value: '儿童' }] }), {
status: 200,
});
};
try {
const values = await fetchOzonDictionaryValues('client', 'key', {
...input,
query: '儿童',
});
assert.equal(values.length, 1);
assert.equal(requests.length, 1);
assert.ok(requests[0].url.endsWith('/attribute/values/search'));
assert.equal(requests[0].body.value, '儿童');
} finally {
globalThis.fetch = previousFetch;
}
});
test('Ozon 返回重复游标时停止翻页', async () => {
const previousFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(JSON.stringify({
result: [{ id: 1, value: '中国' }],
has_next: true,
}), { status: 200 });
};
try {
const values = await fetchOzonDictionaryValues('client', 'key', input);
assert.equal(values.length, 1);
assert.equal(calls, 2);
} finally {
globalThis.fetch = previousFetch;
}
});
test('平台字典批量同步会报告分页是否完整', async () => {
const previousFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (_url, options) => {
requests.push(JSON.parse(options.body));
const page = requests.length;
return new Response(JSON.stringify({
result: [{ id: page, value: `候选值 ${page}` }],
has_next: page < 2,
}), { status: 200 });
};
try {
const batch = await fetchOzonDictionaryBatch('client', 'key', input);
assert.equal(batch.isComplete, true);
assert.deepEqual(batch.values.map((value) => value.id), [1, 2]);
assert.equal(requests[0].limit, 2000);
assert.equal(requests[1].last_value_id, 1);
} finally {
globalThis.fetch = previousFetch;
}
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
flattenPlatformCategories,
mapPlatformAttributes,
} from '../src/platform-catalog-mappers.ts';
test('类目树叶子继承 description_category_id 并保留 type_id', () => {
const rows = flattenPlatformCategories([{
description_category_id: 17027922,
category_name: '儿童玩具',
children: [{ type_id: 8229, type_name: '玩具类型' }],
}]);
assert.equal(rows.length, 2);
assert.deepEqual(rows.map((row) => [row.external_category_id, row.external_type_id]), [
['17027922', '0'],
['17027922', '8229'],
]);
assert.equal(rows[1].parent_key, '17027922:0');
assert.equal(rows[1].path, '儿童玩具 › 玩具类型');
assert.equal(rows[1].is_active, true);
});
test('属性定义按 Ozon 属性 ID 和 complex ID 去重并保留约束', () => {
const rows = mapPlatformAttributes([{
id: 85,
attribute_complex_id: 0,
name: '品牌',
type: 'String',
dictionary_id: 321,
is_required: true,
is_collection: true,
max_value_count: 1000,
category_dependent: false,
}, {
id: 85,
attribute_complex_id: 0,
name: '品牌(更新)',
type: 'String',
dictionary_id: 321,
is_required: true,
is_collection: true,
max_value_count: 2,
}]);
assert.equal(rows.length, 1);
assert.equal(rows[0].platform_attribute_id, '85');
assert.equal(rows[0].complex_id, '0');
assert.equal(rows[0].value_type, 'dictionary');
assert.equal(rows[0].max_value_count, 2);
assert.equal(rows[0].name, '品牌(更新)');
});