feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据
This commit is contained in:
@@ -12,6 +12,7 @@ import { MediaModule } from './media/media.module.js';
|
|||||||
import { CollectionModule } from './collection/collection.module.js';
|
import { CollectionModule } from './collection/collection.module.js';
|
||||||
import { ExtensionModule } from './extension/extension.module.js';
|
import { ExtensionModule } from './extension/extension.module.js';
|
||||||
import { ProductEditorModule } from './product-editor/product-editor.module.js';
|
import { ProductEditorModule } from './product-editor/product-editor.module.js';
|
||||||
|
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
class HealthController {
|
class HealthController {
|
||||||
@@ -51,6 +52,7 @@ class HealthController {
|
|||||||
CollectionModule,
|
CollectionModule,
|
||||||
ExtensionModule,
|
ExtensionModule,
|
||||||
ProductEditorModule,
|
ProductEditorModule,
|
||||||
|
PlatformCatalogAdminModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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;
|
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 = {
|
type CatalogAccess = {
|
||||||
requireProfileForPlatform: (
|
requireProfileForPlatform: (
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
@@ -155,9 +175,11 @@ export class ProductEditorCatalogOperations {
|
|||||||
platformId: string,
|
platformId: string,
|
||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
|
cursor: string | undefined,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
|
const after = parseDictionaryCursor(cursor);
|
||||||
return this.database.workspaceTransaction(
|
return this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
async (transaction) => {
|
||||||
@@ -202,6 +224,8 @@ export class ProductEditorCatalogOperations {
|
|||||||
'字典不属于当前类目的属性',
|
'字典不属于当前类目的属性',
|
||||||
);
|
);
|
||||||
const needle = search?.trim() ?? '';
|
const needle = search?.trim() ?? '';
|
||||||
|
if (after && after.query !== needle)
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标与搜索条件不匹配');
|
||||||
const values = await transaction.query<{
|
const values = await transaction.query<{
|
||||||
value_id: string;
|
value_id: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -211,11 +235,20 @@ export class ProductEditorCatalogOperations {
|
|||||||
WHERE store_id = $1 AND store_product_category_id = $2
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
AND dictionary_id = $3 AND is_active
|
AND dictionary_id = $3 AND is_active
|
||||||
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
||||||
|
AND ($5::text IS NULL OR (value, external_value_id) > ($5, $6))
|
||||||
ORDER BY value, external_value_id
|
ORDER BY value, external_value_id
|
||||||
LIMIT 100`,
|
LIMIT 201`,
|
||||||
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
|
[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(
|
async requestAttributeDictionarySync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -252,7 +285,7 @@ export class ProductEditorCatalogOperations {
|
|||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
query: string,
|
query: string,
|
||||||
) {
|
) {
|
||||||
if (query.trim().length < 2)
|
if (query.trim().length === 1)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'DICTIONARY_SEARCH_REQUIRED',
|
'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')
|
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||||
async attributeSchemaSync(
|
async attributeSchemaSync(
|
||||||
@@ -139,6 +155,7 @@ export class ProductEditorController {
|
|||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Param('dictionaryId') dictionaryId: string,
|
@Param('dictionaryId') dictionaryId: string,
|
||||||
@Query('q') query: string | undefined,
|
@Query('q') query: string | undefined,
|
||||||
|
@Query('cursor') cursor: string | undefined,
|
||||||
@Query('category_id') categoryId: string | undefined,
|
@Query('category_id') categoryId: string | undefined,
|
||||||
@Query('store_id') storeId: string | undefined,
|
@Query('store_id') storeId: string | undefined,
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
@@ -153,6 +170,7 @@ export class ProductEditorController {
|
|||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
dictionaryId,
|
dictionaryId,
|
||||||
query,
|
query,
|
||||||
|
cursor,
|
||||||
optionalUuid('category_id', categoryId),
|
optionalUuid('category_id', categoryId),
|
||||||
optionalUuid('store_id', storeId),
|
optionalUuid('store_id', storeId),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type ProductRow = {
|
|||||||
/** 商品在一个发布平台下的编辑配置行。 */
|
/** 商品在一个发布平台下的编辑配置行。 */
|
||||||
export type ProfileRow = {
|
export type ProfileRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
product_id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
platform_id: string;
|
platform_id: string;
|
||||||
market_code: string | null;
|
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.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
|
||||||
p.platform_type_id, p.category_source, category.name AS category_name,
|
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,
|
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
profileSelect,
|
profileSelect,
|
||||||
} from './product-editor.models.js';
|
} from './product-editor.models.js';
|
||||||
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.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 {
|
import {
|
||||||
latestProductAiSourceMaterial,
|
latestProductAiSourceMaterial,
|
||||||
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductEditorService {
|
export class ProductEditorService {
|
||||||
private readonly catalog: ProductEditorCatalogOperations;
|
private readonly catalog: ProductEditorCatalogOperations;
|
||||||
|
private readonly validationOperations: ProductEditorValidationOperations;
|
||||||
|
|
||||||
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
||||||
public constructor(
|
public constructor(
|
||||||
@@ -45,12 +48,24 @@ export class ProductEditorService {
|
|||||||
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
||||||
requireProfileForPlatform: (...args) =>
|
requireProfileForPlatform: (...args) =>
|
||||||
this.requireProfileForPlatform(...args),
|
this.requireProfileForPlatform(...args),
|
||||||
requireEditableStore: (...args) => this.requireEditableStore(...args),
|
requireEditableStore: (...args) =>
|
||||||
requireActiveCategory: (...args) => this.requireActiveCategory(...args),
|
this.requireEditableStore(...args),
|
||||||
|
requireActiveCategory: (...args) =>
|
||||||
|
this.requireActiveCategory(...args),
|
||||||
attributeSchemaForCategory: (...args) =>
|
attributeSchemaForCategory: (...args) =>
|
||||||
this.attributeSchemaForCategory(...args),
|
this.attributeSchemaForCategory(...args),
|
||||||
requestCatalogSync: (input) => this.requestCatalogSync(input),
|
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(
|
async attributeDictionary(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -494,6 +522,7 @@ export class ProductEditorService {
|
|||||||
platformId: string,
|
platformId: string,
|
||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
|
cursor: string | undefined,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
@@ -503,6 +532,7 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
dictionaryId,
|
dictionaryId,
|
||||||
search,
|
search,
|
||||||
|
cursor,
|
||||||
categoryId,
|
categoryId,
|
||||||
storeId,
|
storeId,
|
||||||
);
|
);
|
||||||
@@ -1002,7 +1032,10 @@ export class ProductEditorService {
|
|||||||
'SKU 发布记录已变化,请重新加载编辑器',
|
'SKU 发布记录已变化,请重新加载编辑器',
|
||||||
);
|
);
|
||||||
const listingByVariant = new Map(
|
const listingByVariant = new Map(
|
||||||
listings.rows.map((listing) => [listing.product_variant_id, listing]),
|
listings.rows.map((listing) => [
|
||||||
|
listing.product_variant_id,
|
||||||
|
listing,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
const assetIds = [
|
const assetIds = [
|
||||||
...new Set(input.variants.flatMap((variant) => variant.assetIds)),
|
...new Set(input.variants.flatMap((variant) => variant.assetIds)),
|
||||||
@@ -1020,7 +1053,9 @@ export class ProductEditorService {
|
|||||||
[assetIds],
|
[assetIds],
|
||||||
)
|
)
|
||||||
: { rows: [] };
|
: { 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<{
|
const existing = await transaction.query<{
|
||||||
product_variant_id: string;
|
product_variant_id: string;
|
||||||
asset_id: string;
|
asset_id: string;
|
||||||
@@ -1033,7 +1068,8 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
const existingByVariant = new Map<string, Set<string>>();
|
const existingByVariant = new Map<string, Set<string>>();
|
||||||
for (const item of existing.rows) {
|
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);
|
ids.add(item.asset_id);
|
||||||
existingByVariant.set(item.product_variant_id, ids);
|
existingByVariant.set(item.product_variant_id, ids);
|
||||||
}
|
}
|
||||||
@@ -1058,7 +1094,8 @@ export class ProductEditorService {
|
|||||||
: { rows: [] };
|
: { rows: [] };
|
||||||
const candidateVariants = new Map<string, Set<string | null>>();
|
const candidateVariants = new Map<string, Set<string | null>>();
|
||||||
for (const candidate of candidates.rows) {
|
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);
|
variants.add(candidate.source_variant_id);
|
||||||
candidateVariants.set(candidate.asset_id, variants);
|
candidateVariants.set(candidate.asset_id, variants);
|
||||||
}
|
}
|
||||||
@@ -1094,7 +1131,10 @@ export class ProductEditorService {
|
|||||||
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
||||||
continue;
|
continue;
|
||||||
const allowedFor = candidateVariants.get(assetId);
|
const allowedFor = candidateVariants.get(assetId);
|
||||||
if (!allowedFor?.has(null) && !allowedFor?.has(variant.variantId))
|
if (
|
||||||
|
!allowedFor?.has(null) &&
|
||||||
|
!allowedFor?.has(variant.variantId)
|
||||||
|
)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'LISTING_MEDIA_NOT_SELECTABLE',
|
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||||
@@ -1252,6 +1292,16 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
continue;
|
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.scope === 'variant') {
|
||||||
if (!attribute.variant_id)
|
if (!attribute.variant_id)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
@@ -1441,7 +1491,7 @@ export class ProductEditorService {
|
|||||||
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
||||||
[storeId, categoryId],
|
[storeId, categoryId],
|
||||||
);
|
);
|
||||||
return result.rows;
|
return bindEditorFields(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
|
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
|
||||||
@@ -1601,7 +1651,8 @@ export class ProductEditorService {
|
|||||||
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
||||||
[product.collection_item_id],
|
[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<{
|
const category = await transaction.query<{
|
||||||
id: string;
|
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',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,7 +68,14 @@ async function seedStoreCategory(
|
|||||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
|
(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)
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[storeId, platformId, externalCategoryId, externalTypeId, name, path],
|
[
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
externalCategoryId,
|
||||||
|
externalTypeId,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
return { id: category.rows[0].id, storeId };
|
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',
|
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [
|
await tx.query(
|
||||||
workspaceId,
|
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
||||||
]);
|
[workspaceId],
|
||||||
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
'DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -161,9 +169,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
|
await tx.query(
|
||||||
workspaceId,
|
'DELETE FROM atlas.media_assets WHERE workspace_id = $1',
|
||||||
]);
|
[workspaceId],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await database.query(
|
await database.query(
|
||||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
'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 dictionarySuffix = randomUUID();
|
||||||
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
||||||
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
||||||
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
||||||
const categoryA = await seedStoreCategory(`m34-a-${randomUUID()}`, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱');
|
const categoryA = await seedStoreCategory(
|
||||||
const categoryB = await seedStoreCategory(`m34-b-${randomUUID()}`, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包');
|
`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);
|
seededCategoryIds.push(categoryA.id, categoryB.id);
|
||||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
`INSERT INTO atlas.store_product_category_attributes
|
tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_category_attributes
|
||||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
(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)
|
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||||
VALUES
|
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, '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, '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)`,
|
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||||||
[
|
[
|
||||||
categoryA.storeId,
|
categoryA.storeId,
|
||||||
platformId,
|
platformId,
|
||||||
categoryA.id,
|
categoryA.id,
|
||||||
categoryB.id,
|
categoryB.id,
|
||||||
materialDictionaryId,
|
materialDictionaryId,
|
||||||
colorDictionaryId,
|
colorDictionaryId,
|
||||||
],
|
],
|
||||||
));
|
),
|
||||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
);
|
||||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
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)
|
(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', '棉'),
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
|
(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(
|
const categories = await request(
|
||||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
@@ -900,6 +935,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
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) => ({
|
const variants = before.body.data.variants.map((variant: any) => ({
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
client_key: null,
|
client_key: null,
|
||||||
@@ -945,7 +984,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scope: 'variant',
|
scope: 'variant',
|
||||||
variant_id: variants[0].id,
|
variant_id: attributeVariantId,
|
||||||
platform_attribute_id: 'color',
|
platform_attribute_id: 'color',
|
||||||
complex_id: '0',
|
complex_id: '0',
|
||||||
values: ['black'],
|
values: ['black'],
|
||||||
@@ -953,6 +992,16 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
source: 'manual',
|
source: 'manual',
|
||||||
confirmation_status: 'confirmed',
|
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,
|
variants,
|
||||||
};
|
};
|
||||||
@@ -981,6 +1030,48 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
).is_aspect,
|
).is_aspect,
|
||||||
true,
|
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(
|
const dictionary = await request(
|
||||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
||||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
@@ -988,19 +1079,174 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
assert.deepEqual(dictionary.body.data.items, [
|
assert.deepEqual(dictionary.body.data.items, [
|
||||||
{ value_id: 'cotton', value: '棉' },
|
{ 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(
|
const afterSave = await request(
|
||||||
`/api/products/${first.productId}/editor`,
|
`/api/products/${first.productId}/editor`,
|
||||||
{
|
{
|
||||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(afterSave.body.data.profile.category.id, categoryA.id);
|
||||||
afterSave.body.data.profile.category.id,
|
|
||||||
categoryA.id,
|
|
||||||
);
|
|
||||||
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
|
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
|
||||||
assert.equal(afterSave.body.data.attributes.length, 2);
|
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 = {
|
const categoryChanged = {
|
||||||
...draft,
|
...draft,
|
||||||
ai_source_material: afterSave.body.data.source_material,
|
ai_source_material: afterSave.body.data.source_material,
|
||||||
@@ -1059,8 +1305,18 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
|
|
||||||
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
|
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
|
||||||
const suffix = randomUUID();
|
const suffix = randomUUID();
|
||||||
const captured = await seedStoreCategory(`m35-source-${suffix}`, 'source-type', 'M3.5 采集叶', 'M3.5 来源 › M3.5 采集叶');
|
const captured = await seedStoreCategory(
|
||||||
const manual = await seedStoreCategory(`m35-manual-${suffix}`, 'manual-type', 'M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶');
|
`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);
|
seededCategoryIds.push(captured.id, manual.id);
|
||||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
@@ -1089,14 +1345,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
[first.profileId],
|
[first.profileId],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const resolved = await request(`/api/products/${first.productId}/editor`, {
|
const resolved = await request(
|
||||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
`/api/products/${first.productId}/editor`,
|
||||||
});
|
{
|
||||||
assert.equal(resolved.response.status, 200);
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
assert.equal(
|
},
|
||||||
resolved.body.data.profile.category.id,
|
|
||||||
captured.id,
|
|
||||||
);
|
);
|
||||||
|
assert.equal(resolved.response.status, 200);
|
||||||
|
assert.equal(resolved.body.data.profile.category.id, captured.id);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolved.body.data.profile.category.source,
|
resolved.body.data.profile.category.source,
|
||||||
'source_capture',
|
'source_capture',
|
||||||
@@ -1111,29 +1367,36 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
[manual.id, first.profileId],
|
[manual.id, first.profileId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const preserved = await request(`/api/products/${first.productId}/editor`, {
|
const preserved = await request(
|
||||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
`/api/products/${first.productId}/editor`,
|
||||||
});
|
{
|
||||||
assert.equal(preserved.response.status, 200);
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
assert.equal(
|
},
|
||||||
preserved.body.data.profile.category.id,
|
|
||||||
manual.id,
|
|
||||||
);
|
);
|
||||||
|
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');
|
assert.equal(preserved.body.data.profile.category.source, 'manual');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('M3.5 为类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
|
it('M3.5 为类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
|
||||||
const dictionaryId = `m35-dictionary-${randomUUID()}`;
|
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);
|
seededCategoryIds.push(category.id);
|
||||||
seededDictionaryIds.push(dictionaryId);
|
seededDictionaryIds.push(dictionaryId);
|
||||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
`INSERT INTO atlas.store_product_category_attributes
|
tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_category_attributes
|
||||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
(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)
|
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)`,
|
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) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
const profile = await tx.query<{ target_store_id: string }>(
|
const profile = await tx.query<{ target_store_id: string }>(
|
||||||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
'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.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(
|
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`,
|
`/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.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) => {
|
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
|
`SELECT event_type, event_payload FROM atlas.outbox_events
|
||||||
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
|
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
|
||||||
ORDER BY created_at`,
|
ORDER BY created_at`,
|
||||||
[first.profileId],
|
[first.profileId],
|
||||||
);
|
);
|
||||||
assert.equal(events.rows.length, 2);
|
assert.equal(events.rows.length, 3);
|
||||||
assert.deepEqual(
|
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_attribute_dictionary_sync.requested',
|
||||||
|
'store_category_attribute_sync.requested',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
events.rows.every(
|
events.rows.every(
|
||||||
(event) =>
|
(event) =>
|
||||||
!JSON.stringify(event.event_payload).includes('m35-test-ciphertext'),
|
!JSON.stringify(event.event_payload).includes(
|
||||||
|
'm35-test-ciphertext',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||||
const before = await request(`/api/products/${first.productId}/editor`, {
|
const before = await request(
|
||||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
`/api/products/${first.productId}/editor`,
|
||||||
});
|
{
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
const variant = before.body.data.variants[0];
|
const variant = before.body.data.variants[0];
|
||||||
const asset = await database.workspaceTransaction(workspaceId, async (tx) => {
|
const asset = await database.workspaceTransaction(
|
||||||
const created = await tx.query<{ id: string }>(
|
workspaceId,
|
||||||
`INSERT INTO atlas.media_assets
|
async (tx) => {
|
||||||
|
const created = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||||
byte_size, checksum_sha256, width_px, height_px)
|
byte_size, checksum_sha256, width_px, height_px)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
||||||
'image/jpeg', 1024, $2, 1200, 900)
|
'image/jpeg', 1024, $2, 1200, 900)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[`m36/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '')],
|
[
|
||||||
);
|
`m36/${randomUUID()}.jpg`,
|
||||||
const snapshot = await tx.query<{ source_snapshot_revision: string }>(
|
randomUUID().replaceAll('-', ''),
|
||||||
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
|
],
|
||||||
[first.collectionItemId],
|
);
|
||||||
);
|
const snapshot = await tx.query<{
|
||||||
await tx.query(
|
source_snapshot_revision: string;
|
||||||
`INSERT INTO atlas.collection_asset_transfers
|
}>(
|
||||||
|
`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,
|
(workspace_id, collection_item_id, product_id, source_variant_id, role,
|
||||||
source_url, status, asset_id, source_snapshot_revision, completed_at)
|
source_url, status, asset_id, source_snapshot_revision, completed_at)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
||||||
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
|
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
|
||||||
[
|
[
|
||||||
first.collectionItemId,
|
first.collectionItemId,
|
||||||
first.productId,
|
first.productId,
|
||||||
variant.id,
|
variant.id,
|
||||||
created.rows[0].id,
|
created.rows[0].id,
|
||||||
snapshot.rows[0].source_snapshot_revision,
|
snapshot.rows[0].source_snapshot_revision,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return created.rows[0].id;
|
return created.rows[0].id;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const payload = {
|
const payload = {
|
||||||
ai_source_material: before.body.data.source_material,
|
ai_source_material: before.body.data.source_material,
|
||||||
ai_source_overridden_fields:
|
ai_source_overridden_fields:
|
||||||
before.body.data.source_material_state.overridden_fields,
|
before.body.data.source_material_state.overridden_fields,
|
||||||
common: {
|
common: {
|
||||||
...before.body.data.product.common,
|
...before.body.data.product.common,
|
||||||
default_physical_spec: before.body.data.product.default_physical_spec,
|
default_physical_spec:
|
||||||
default_package_spec: before.body.data.product.default_package_spec,
|
before.body.data.product.default_physical_spec,
|
||||||
|
default_package_spec:
|
||||||
|
before.body.data.product.default_package_spec,
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
target_store_id: before.body.data.profile.target_store.id,
|
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] : [],
|
media_asset_ids: item.id === variant.id ? [asset] : [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
const saved = await request(
|
||||||
method: 'PUT',
|
`/api/products/${first.productId}/editor-draft`,
|
||||||
headers: headers({
|
{
|
||||||
'X-Atlas-Platform-Id': platformId,
|
method: 'PUT',
|
||||||
'Idempotency-Key': randomUUID(),
|
headers: headers({
|
||||||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
'X-Atlas-Platform-Id': platformId,
|
||||||
}),
|
'Idempotency-Key': randomUUID(),
|
||||||
body: JSON.stringify(payload),
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||||
});
|
}),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.equal(saved.response.status, 200);
|
assert.equal(saved.response.status, 200);
|
||||||
const after = await request(`/api/products/${first.productId}/editor`, {
|
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
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],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import SidebarMenu from './SidebarMenu';
|
import SidebarMenu from './SidebarMenu';
|
||||||
import PlatformSelector from './PlatformSelector';
|
import PlatformSelector from './PlatformSelector';
|
||||||
import { getBreadcrumbItems } from './menuConfig';
|
import { getBreadcrumbItems } from './menuConfig';
|
||||||
import { useSession } from '@/services/session';
|
import { useSession } from '@/services/session';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
|
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ const MainLayout = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { session, logout } = useSession();
|
const { session, logout } = useSession();
|
||||||
|
const systemAdminAccess = useSystemAdminAccess();
|
||||||
|
|
||||||
const breadcrumbItems = useMemo(
|
const breadcrumbItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -60,12 +63,17 @@ const MainLayout = () => {
|
|||||||
navigate('/settings/password');
|
navigate('/settings/password');
|
||||||
} else if (key === 'logout') {
|
} else if (key === 'logout') {
|
||||||
void handleLogout();
|
void handleLogout();
|
||||||
|
} else if (key === 'system') {
|
||||||
|
navigate('/system/platform-catalog');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const userMenuItems: MenuProps['items'] = [
|
const userMenuItems: MenuProps['items'] = [
|
||||||
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
|
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
|
||||||
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
|
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
|
||||||
|
...(systemAdminAccess === 'allowed'
|
||||||
|
? [{ key: 'system', icon: <SafetyCertificateOutlined />, label: '系统管理' }]
|
||||||
|
: []),
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'logout',
|
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;卡片变窄时先吃掉两侧留白,再收缩控件列。
|
* 宽度充足时控件不超过 680px;卡片变窄时先吃掉两侧留白,再收缩控件列。
|
||||||
* 容器查询以卡片实际宽度为准,不受左侧菜单或采集面板布局的间接影响。
|
* 容器查询以卡片实际宽度为准,不受左侧菜单或采集面板布局的间接影响。
|
||||||
*/
|
*/
|
||||||
@@ -99,18 +99,18 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.editor-horizontal-section .ant-form-item-label {
|
.editor-horizontal-section .ant-form-item-label {
|
||||||
flex: 0 0 100px !important;
|
flex: 0 0 170px !important;
|
||||||
max-width: 100px !important;
|
max-width: 170px !important;
|
||||||
}
|
}
|
||||||
.editor-horizontal-section .ant-form-item-control {
|
.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;
|
width: auto;
|
||||||
max-width: min(680px, calc(100% - 100px)) !important;
|
max-width: min(680px, calc(100% - 170px)) !important;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 100px 标签列与包装尺寸输入组在 480px 以下已没有足够可读空间;此时才
|
* 170px 标签列与包装尺寸输入组在 480px 以下已没有足够可读空间;此时才
|
||||||
* 切换为纵向表单。该断点读取卡片宽度,非浏览器窗口宽度。
|
* 切换为纵向表单。该断点读取卡片宽度,非浏览器窗口宽度。
|
||||||
*/
|
*/
|
||||||
@container (max-width: 479px) {
|
@container (max-width: 479px) {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function missingAttributeDrafts(
|
|||||||
) {
|
) {
|
||||||
const missing: AttributeDraft[] = [];
|
const missing: AttributeDraft[] = [];
|
||||||
for (const definition of schema) {
|
for (const definition of schema) {
|
||||||
|
if (definition.editor_field) continue;
|
||||||
const scopes = definition.is_aspect
|
const scopes = definition.is_aspect
|
||||||
? snapshot.variants.map((variant) => ({
|
? snapshot.variants.map((variant) => ({
|
||||||
scope: 'variant' as const,
|
scope: 'variant' as const,
|
||||||
@@ -236,9 +237,8 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
|||||||
description: nullable(values.profile.description),
|
description: nullable(values.profile.description),
|
||||||
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
|
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
|
||||||
},
|
},
|
||||||
// Schema fields are rendered eagerly so the user can fill them immediately.
|
// schema 字段会预先加入表单供用户直接填写;未触碰的空选填项不落库,
|
||||||
// Do not persist untouched optional fields: the API correctly requires every
|
// 因为服务端要求每条已提交属性至少包含一个非空值。
|
||||||
// stored attribute to contain at least one non-empty value.
|
|
||||||
attributes: (values.attributes ?? []).flatMap((attribute) => {
|
attributes: (values.attributes ?? []).flatMap((attribute) => {
|
||||||
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
|
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
|
||||||
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
|
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
|
||||||
@@ -376,7 +376,7 @@ const ProductEditPage = () => {
|
|||||||
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
|
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
|
||||||
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
||||||
const [categorySearch, setCategorySearch] = useState('');
|
const [categorySearch, setCategorySearch] = useState('');
|
||||||
// 类目属性 schema 与字典值:仅在展开"更多特征"后按需加载。
|
// 类目属性 schema 与字典值:选定类目后加载,必填属性始终展示。
|
||||||
const [attributeSchema, setAttributeSchema] = useState<ProductAttributeSchema[]>([]);
|
const [attributeSchema, setAttributeSchema] = useState<ProductAttributeSchema[]>([]);
|
||||||
const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState<string | null>(null);
|
const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState<string | null>(null);
|
||||||
const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState<
|
const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState<
|
||||||
@@ -387,8 +387,16 @@ const ProductEditPage = () => {
|
|||||||
const [dictionaryValues, setDictionaryValues] = useState<
|
const [dictionaryValues, setDictionaryValues] = useState<
|
||||||
Record<string, ProductAttributeDictionaryValue[]>
|
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 [catalogRefresh, setCatalogRefresh] = useState(0);
|
||||||
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
|
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
|
||||||
|
const catalogSyncToken = useRef<symbol | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
||||||
@@ -412,6 +420,11 @@ const ProductEditPage = () => {
|
|||||||
: ([] as ProductAttributeSchema[]),
|
: ([] as ProductAttributeSchema[]),
|
||||||
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
|
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
|
||||||
);
|
);
|
||||||
|
// 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。
|
||||||
|
const editableAttributeSchema = useMemo(
|
||||||
|
() => activeAttributeSchema.filter((attribute) => !attribute.editor_field),
|
||||||
|
[activeAttributeSchema]
|
||||||
|
);
|
||||||
|
|
||||||
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
|
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -460,8 +473,14 @@ const ProductEditPage = () => {
|
|||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
|
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
|
||||||
// 属性 schema:展开"更多特征"时才加载,同一类目与刷新版本只请求一次;
|
// 切换店铺或类目后,不复用旧类目的字典搜索、游标和自动同步记录。
|
||||||
// 未选中类目时清空全部属性相关状态。
|
useEffect(() => {
|
||||||
|
dictionaryQueries.current = {};
|
||||||
|
dictionaryInitialSyncs.current.clear();
|
||||||
|
dictionaryPageLoads.current.clear();
|
||||||
|
setDictionaryCursors({});
|
||||||
|
}, [selectedCategoryId, selectedStoreId, snapshot?.profile.id]);
|
||||||
|
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
||||||
setAttributeSchema([]);
|
setAttributeSchema([]);
|
||||||
@@ -470,14 +489,12 @@ const ProductEditPage = () => {
|
|||||||
setAttributeSchemaLoading(false);
|
setAttributeSchemaLoading(false);
|
||||||
setAttributeSchemaError(null);
|
setAttributeSchemaError(null);
|
||||||
setDictionaryValues({});
|
setDictionaryValues({});
|
||||||
|
setDictionaryCursors({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Attributes are an on-demand panel. Once loaded for the selected
|
|
||||||
// category, collapsing and reopening it must reuse the same schema.
|
|
||||||
if (
|
if (
|
||||||
!moreFeaturesExpanded ||
|
attributeSchemaCategoryId === selectedCategoryId &&
|
||||||
(attributeSchemaCategoryId === selectedCategoryId &&
|
attributeSchemaCatalogRefresh === catalogRefresh
|
||||||
attributeSchemaCatalogRefresh === catalogRefresh)
|
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -486,6 +503,7 @@ const ProductEditPage = () => {
|
|||||||
setAttributeSchema([]);
|
setAttributeSchema([]);
|
||||||
setAttributeSchemaCategoryId(null);
|
setAttributeSchemaCategoryId(null);
|
||||||
setDictionaryValues({});
|
setDictionaryValues({});
|
||||||
|
setDictionaryCursors({});
|
||||||
void productEditorApi
|
void productEditorApi
|
||||||
.attributeSchema(
|
.attributeSchema(
|
||||||
snapshot.profile.id,
|
snapshot.profile.id,
|
||||||
@@ -525,7 +543,6 @@ const ProductEditPage = () => {
|
|||||||
catalogRefresh,
|
catalogRefresh,
|
||||||
currentPlatform,
|
currentPlatform,
|
||||||
form,
|
form,
|
||||||
moreFeaturesExpanded,
|
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
selectedStoreId,
|
selectedStoreId,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -535,13 +552,14 @@ const ProductEditPage = () => {
|
|||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||||
const dictionaryIds = [
|
const dictionaryIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
activeAttributeSchema
|
editableAttributeSchema
|
||||||
.map((attribute) => attribute.dictionary_id)
|
.map((attribute) => attribute.dictionary_id)
|
||||||
.filter((dictionaryId): dictionaryId is string => !!dictionaryId)
|
.filter((dictionaryId): dictionaryId is string => !!dictionaryId)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (!dictionaryIds.length) {
|
if (!dictionaryIds.length) {
|
||||||
setDictionaryValues({});
|
setDictionaryValues({});
|
||||||
|
setDictionaryCursors({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -563,20 +581,36 @@ const ProductEditPage = () => {
|
|||||||
)
|
)
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDictionaryValues(
|
setDictionaryValues((current) =>
|
||||||
Object.fromEntries(results.map(([id, page]) => [id, page.items]))
|
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(() => {
|
.catch(() => {
|
||||||
if (active) setDictionaryValues({});
|
if (active) {
|
||||||
|
setDictionaryValues({});
|
||||||
|
setDictionaryCursors({});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeAttributeSchema,
|
|
||||||
catalogRefresh,
|
catalogRefresh,
|
||||||
currentPlatform,
|
currentPlatform,
|
||||||
|
editableAttributeSchema,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
selectedStoreId,
|
selectedStoreId,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -773,19 +807,23 @@ const ProductEditPage = () => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
/** 发起一次 Ozon 目录同步并跟踪其异步 operation;成功后递增 catalogRefresh 触发缓存重拉。 */
|
/** 跟踪 Ozon 目录同步;schema 成功后刷新目录,字典只重读对应选项。 */
|
||||||
const runCatalogSync = async (
|
const runCatalogSync = async (
|
||||||
syncKey: string,
|
syncKey: string,
|
||||||
action: () => Promise<{ operation: { id: string } }>,
|
action: () => Promise<{ operation: { id: string } }>,
|
||||||
successMessage: string
|
successMessage: string | null,
|
||||||
|
refreshCatalog = true
|
||||||
) => {
|
) => {
|
||||||
|
const token = Symbol(syncKey);
|
||||||
|
catalogSyncToken.current = token;
|
||||||
setCatalogSyncing(syncKey);
|
setCatalogSyncing(syncKey);
|
||||||
try {
|
try {
|
||||||
const queued = await action();
|
const queued = await action();
|
||||||
const completed = await waitForCatalogOperation(queued.operation.id);
|
const completed = await waitForCatalogOperation(queued.operation.id);
|
||||||
if (completed?.status === 'succeeded') {
|
if (completed?.status === 'succeeded') {
|
||||||
message.success(successMessage);
|
if (successMessage) message.success(successMessage);
|
||||||
setCatalogRefresh((version) => version + 1);
|
if (refreshCatalog) setCatalogRefresh((version) => version + 1);
|
||||||
|
return true;
|
||||||
} else if (completed?.status === 'failed') {
|
} else if (completed?.status === 'failed') {
|
||||||
message.error(completed.error?.message ?? 'Ozon 同步失败,请稍后重试');
|
message.error(completed.error?.message ?? 'Ozon 同步失败,请稍后重试');
|
||||||
} else if (completed?.status === 'cancelled') {
|
} else if (completed?.status === 'cancelled') {
|
||||||
@@ -796,8 +834,12 @@ const ProductEditPage = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(apiErrorMessage(error));
|
message.error(apiErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
setCatalogSyncing(null);
|
if (catalogSyncToken.current === token) {
|
||||||
|
catalogSyncToken.current = null;
|
||||||
|
setCatalogSyncing(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
const syncAttributeSchema = () => {
|
const syncAttributeSchema = () => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
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;
|
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||||
const page = await productEditorApi.attributeDictionary(
|
const requestContext = dictionaryContext;
|
||||||
snapshot.profile.id,
|
const requestKey = `${requestContext}:${dictionaryId}:${query}:${cursor ?? ''}`;
|
||||||
currentPlatform.id,
|
if (dictionaryPageLoads.current.has(requestKey)) return;
|
||||||
dictionaryId,
|
dictionaryPageLoads.current.add(requestKey);
|
||||||
query,
|
if (!cursor) dictionaryQueries.current[dictionaryId] = query;
|
||||||
selectedCategoryId,
|
try {
|
||||||
selectedStoreId
|
const page = await productEditorApi.attributeDictionary(
|
||||||
);
|
snapshot.profile.id,
|
||||||
setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items }));
|
currentPlatform.id,
|
||||||
};
|
dictionaryId,
|
||||||
const syncDictionaryValues = (dictionaryId: string, query: string) => {
|
query,
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
selectedCategoryId,
|
||||||
if (query.length < 2) {
|
selectedStoreId,
|
||||||
message.info('请输入至少 2 个字符后再从 Ozon 搜索');
|
cursor
|
||||||
return;
|
);
|
||||||
|
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}`,
|
`dictionary:${dictionaryId}:${query}`,
|
||||||
() =>
|
() =>
|
||||||
productEditorApi.syncAttributeDictionary(
|
productEditorApi.syncAttributeDictionary(
|
||||||
@@ -840,8 +908,28 @@ const ProductEditPage = () => {
|
|||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
query
|
query
|
||||||
),
|
),
|
||||||
'字典值已同步'
|
null,
|
||||||
).then(() => void loadDictionaryValues(dictionaryId, query));
|
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 的图片立即可选。 */
|
/** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */
|
||||||
const materializeCandidate = async (transferId: string) => {
|
const materializeCandidate = async (transferId: string) => {
|
||||||
@@ -956,6 +1044,8 @@ const ProductEditPage = () => {
|
|||||||
void loadDictionaryValues(dictionaryId, query)
|
void loadDictionaryValues(dictionaryId, query)
|
||||||
}
|
}
|
||||||
onSyncDictionaryValues={syncDictionaryValues}
|
onSyncDictionaryValues={syncDictionaryValues}
|
||||||
|
onOpenDictionaryValues={openDictionaryValues}
|
||||||
|
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
|
||||||
onSyncAttributeSchema={syncAttributeSchema}
|
onSyncAttributeSchema={syncAttributeSchema}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -964,7 +1054,16 @@ const ProductEditPage = () => {
|
|||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
collectionRecord={collectionRecord}
|
collectionRecord={collectionRecord}
|
||||||
materializingTransferId={materializingTransferId}
|
materializingTransferId={materializingTransferId}
|
||||||
|
activeAttributeSchema={activeAttributeSchema}
|
||||||
|
catalogSyncing={catalogSyncing}
|
||||||
|
dictionaryValues={dictionaryValues}
|
||||||
onMaterialize={materializeCandidate}
|
onMaterialize={materializeCandidate}
|
||||||
|
onLoadDictionaryValues={(dictionaryId, query) =>
|
||||||
|
void loadDictionaryValues(dictionaryId, query)
|
||||||
|
}
|
||||||
|
onSyncDictionaryValues={syncDictionaryValues}
|
||||||
|
onOpenDictionaryValues={openDictionaryValues}
|
||||||
|
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
|
||||||
onDirty={() => setDirty(true)}
|
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;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
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%;
|
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 { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
|
||||||
import type { FormInstance } from 'antd';
|
import type { FormInstance } from 'antd';
|
||||||
import { EditOutlined } from '@ant-design/icons';
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
@@ -9,12 +9,10 @@ import type {
|
|||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { SectionTitle } from './SectionTitle';
|
import { SectionTitle } from './SectionTitle';
|
||||||
|
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||||
import './ProductAttributeSection.css';
|
import './ProductAttributeSection.css';
|
||||||
|
|
||||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
|
||||||
type NamePath = Array<string | number>;
|
|
||||||
|
|
||||||
/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */
|
/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */
|
||||||
function sourceValue(value: unknown): string {
|
function sourceValue(value: unknown): string {
|
||||||
if (typeof value === 'string') return value || '—';
|
if (typeof value === 'string') return value || '—';
|
||||||
@@ -36,6 +34,8 @@ type Props = {
|
|||||||
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||||
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||||
|
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||||
|
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||||
onSyncAttributeSchema: () => void;
|
onSyncAttributeSchema: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,15 +59,27 @@ export function ProductAttributeSection({
|
|||||||
dictionaryValues,
|
dictionaryValues,
|
||||||
onLoadDictionaryValues,
|
onLoadDictionaryValues,
|
||||||
onSyncDictionaryValues,
|
onSyncDictionaryValues,
|
||||||
|
onOpenDictionaryValues,
|
||||||
|
onLoadMoreDictionaryValues,
|
||||||
onSyncAttributeSchema,
|
onSyncAttributeSchema,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||||
const [richContent, setRichContent] = useState('');
|
const [richContent, setRichContent] = useState('');
|
||||||
// 各字典下拉框的当前搜索词(仅本地状态);切换类目时清空,避免残留到新类目。
|
const commonDefinitions = useMemo(
|
||||||
const [dictionarySearches, setDictionarySearches] = useState<Record<string, string>>({});
|
() =>
|
||||||
useEffect(() => {
|
activeAttributeSchema.filter(
|
||||||
setDictionarySearches({});
|
(definition) => !definition.is_aspect && !definition.editor_field
|
||||||
}, [selectedCategoryId]);
|
),
|
||||||
|
[activeAttributeSchema]
|
||||||
|
);
|
||||||
|
const requiredDefinitions = useMemo(
|
||||||
|
() => commonDefinitions.filter((definition) => definition.is_required),
|
||||||
|
[commonDefinitions]
|
||||||
|
);
|
||||||
|
const optionalDefinitions = useMemo(
|
||||||
|
() => commonDefinitions.filter((definition) => !definition.is_required),
|
||||||
|
[commonDefinitions]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
@@ -100,291 +112,92 @@ export function ProductAttributeSection({
|
|||||||
<div id="dynamic-attributes" className="dynamic-attributes">
|
<div id="dynamic-attributes" className="dynamic-attributes">
|
||||||
<div className="dynamic-attributes-heading">
|
<div className="dynamic-attributes-heading">
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={5}>更多特征</Typography.Title>
|
<Typography.Title level={5}>Ozon 类目属性</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
根据所选类目填写 Ozon 平台属性
|
根据所选类目填写商品级平台属性;变体属性在对应 SKU 中填写
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{optionalDefinitions.length > 0 && (
|
||||||
type="dashed"
|
<Button
|
||||||
icon={<EditOutlined />}
|
type="dashed"
|
||||||
onClick={() => {
|
icon={<EditOutlined />}
|
||||||
onToggleExpanded();
|
onClick={() => {
|
||||||
window.requestAnimationFrame(() =>
|
onToggleExpanded();
|
||||||
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
window.requestAnimationFrame(() =>
|
||||||
behavior: 'smooth',
|
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||||
block: 'center',
|
behavior: 'smooth',
|
||||||
})
|
block: 'center',
|
||||||
);
|
})
|
||||||
}}
|
);
|
||||||
>
|
}}
|
||||||
{expanded ? '收起更多特征' : '展开更多特征'}
|
>
|
||||||
</Button>
|
{expanded ? '收起选填属性' : '展开选填属性'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{expanded &&
|
{!selectedCategoryId ? (
|
||||||
(!selectedCategoryId ? (
|
<Empty
|
||||||
<Empty
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
description="请选择产品类目后填写平台属性"
|
||||||
description="请选择产品类目后填写平台属性"
|
/>
|
||||||
/>
|
) : attributeSchemaLoading ? (
|
||||||
) : attributeSchemaLoading ? (
|
<div className="dynamic-attributes-loading">
|
||||||
<div className="dynamic-attributes-loading">
|
<Spin size="small" />
|
||||||
<Spin size="small" />
|
<span>正在读取类目属性…</span>
|
||||||
<span>正在读取类目属性…</span>
|
</div>
|
||||||
</div>
|
) : !activeAttributeSchema.length ? (
|
||||||
) : !activeAttributeSchema.length ? (
|
<Empty
|
||||||
<Empty
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
description={
|
||||||
description={
|
<Space direction="vertical" size={4}>
|
||||||
<Space direction="vertical" size={4}>
|
<span>
|
||||||
<span>
|
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
|
||||||
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
|
</span>
|
||||||
</span>
|
<Button
|
||||||
<Button
|
type="link"
|
||||||
type="link"
|
loading={catalogSyncing === `attributes:${selectedCategoryId}`}
|
||||||
loading={
|
onClick={onSyncAttributeSchema}
|
||||||
catalogSyncing === `attributes:${selectedCategoryId}`
|
>
|
||||||
}
|
同步此类目属性
|
||||||
onClick={onSyncAttributeSchema}
|
</Button>
|
||||||
>
|
</Space>
|
||||||
同步此类目属性
|
}
|
||||||
</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 中填写。'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
{expanded && optionalDefinitions.length > 0 && (
|
||||||
<Form.List name="attributes">
|
<DynamicAttributeFields
|
||||||
{(fields) => (
|
form={form}
|
||||||
<div className="dynamic-attribute-list">
|
definitions={optionalDefinitions}
|
||||||
{activeAttributeSchema.flatMap((definition) => {
|
scope="common"
|
||||||
// aspect(变体维度)属性按每个 SKU 展开一行,其余类目级一行。
|
variantId={null}
|
||||||
const scopes = definition.is_aspect
|
dictionaryValues={dictionaryValues}
|
||||||
? snapshot.variants.map((variant) => ({
|
catalogSyncing={catalogSyncing}
|
||||||
scope: 'variant' as const,
|
onLoadDictionaryValues={onLoadDictionaryValues}
|
||||||
variantId: variant.id,
|
onSyncDictionaryValues={onSyncDictionaryValues}
|
||||||
suffix: variant.display_name || '未命名 SKU',
|
onOpenDictionaryValues={onOpenDictionaryValues}
|
||||||
}))
|
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
|
||||||
: [
|
/>
|
||||||
{
|
)}
|
||||||
scope: 'common' as const,
|
</Space>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */}
|
{/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */}
|
||||||
{snapshot.unmapped_attributes.length > 0 && (
|
{snapshot.unmapped_attributes.length > 0 && (
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ type CategoryOption = {
|
|||||||
function PackageFields({ name }: { name: NamePath }) {
|
function PackageFields({ name }: { name: NamePath }) {
|
||||||
return (
|
return (
|
||||||
<div className="package-fields">
|
<div className="package-fields">
|
||||||
<Form.Item
|
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required>
|
||||||
name={[...name, 'shipping_weight_g']}
|
|
||||||
label="包装重量"
|
|
||||||
rules={[{ required: true, message: '请填写包装重量' }]}
|
|
||||||
>
|
|
||||||
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
|
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="包装尺寸" required className="package-dimensions">
|
<Form.Item label="包装尺寸" required className="package-dimensions">
|
||||||
|
|||||||
@@ -148,6 +148,32 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.sku-card-value strong {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
@@ -314,6 +340,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.sku-attribute-panel {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
.sku-inline-dimensions {
|
.sku-inline-dimensions {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import type { FormInstance } from 'antd';
|
|||||||
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import type {
|
import type {
|
||||||
CollectionItemInspection,
|
CollectionItemInspection,
|
||||||
|
ProductAttributeDictionaryValue,
|
||||||
|
ProductAttributeSchema,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { SectionTitle } from './SectionTitle';
|
import { SectionTitle } from './SectionTitle';
|
||||||
|
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||||
import './ProductVariantSection.css';
|
import './ProductVariantSection.css';
|
||||||
|
|
||||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||||
@@ -82,7 +85,14 @@ type Props = {
|
|||||||
snapshot: ProductEditorSnapshot;
|
snapshot: ProductEditorSnapshot;
|
||||||
collectionRecord: CollectionItemInspection | null;
|
collectionRecord: CollectionItemInspection | null;
|
||||||
materializingTransferId: string | null;
|
materializingTransferId: string | null;
|
||||||
|
activeAttributeSchema: ProductAttributeSchema[];
|
||||||
|
catalogSyncing: string | null;
|
||||||
|
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||||
onMaterialize: (transferId: string) => void;
|
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;
|
onDirty: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,13 +108,26 @@ export function ProductVariantSection({
|
|||||||
snapshot,
|
snapshot,
|
||||||
collectionRecord,
|
collectionRecord,
|
||||||
materializingTransferId,
|
materializingTransferId,
|
||||||
|
activeAttributeSchema,
|
||||||
|
catalogSyncing,
|
||||||
|
dictionaryValues,
|
||||||
onMaterialize,
|
onMaterialize,
|
||||||
|
onLoadDictionaryValues,
|
||||||
|
onSyncDictionaryValues,
|
||||||
|
onOpenDictionaryValues,
|
||||||
|
onLoadMoreDictionaryValues,
|
||||||
onDirty,
|
onDirty,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
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 (
|
return (
|
||||||
<Card className="editor-section sku-section" bordered={false}>
|
<Card className="editor-section sku-section" bordered={false}>
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
@@ -345,90 +368,156 @@ export function ProductVariantSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 && (
|
{expanded && (
|
||||||
<div className="sku-card-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">
|
<div className="sku-media-panel">
|
||||||
<Typography.Title level={5}>
|
<Typography.Title level={5}>
|
||||||
SKU 图片
|
SKU 图片
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
{imageCandidates.length ? (
|
{imageCandidates.length ? (
|
||||||
<div className="sku-media-grid">
|
<div className="sku-media-grid">
|
||||||
{imageCandidates.map((candidate, imageIndex) => {
|
{imageCandidates.map(
|
||||||
// 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
|
(candidate, imageIndex) => {
|
||||||
// 避免在此处重复提供后续素材选择流程的入口。
|
// 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
|
||||||
const selectable =
|
// 避免在此处重复提供后续素材选择流程的入口。
|
||||||
candidate.status ===
|
const selectable =
|
||||||
'succeeded' &&
|
candidate.status ===
|
||||||
!!candidate.asset_id;
|
'succeeded' &&
|
||||||
const selected =
|
!!candidate.asset_id;
|
||||||
selectable &&
|
const selected =
|
||||||
selectedAssetIds.includes(
|
selectable &&
|
||||||
candidate.asset_id!
|
selectedAssetIds.includes(
|
||||||
);
|
candidate.asset_id!
|
||||||
return (
|
);
|
||||||
<div
|
return (
|
||||||
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''} ${selected ? 'selected' : ''}`}
|
<div
|
||||||
key={candidate.id}
|
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''} ${selected ? 'selected' : ''}`}
|
||||||
>
|
key={candidate.id}
|
||||||
{imageIndex === 0 && (
|
>
|
||||||
<span className="sku-media-main-badge">
|
{imageIndex === 0 && (
|
||||||
主图
|
<span className="sku-media-main-badge">
|
||||||
</span>
|
主图
|
||||||
)}
|
</span>
|
||||||
<Image
|
)}
|
||||||
src={
|
<Image
|
||||||
candidate.source_url
|
src={
|
||||||
}
|
candidate.source_url
|
||||||
alt="采集候选图片"
|
|
||||||
/>
|
|
||||||
{selectable ? (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type={
|
|
||||||
selected
|
|
||||||
? 'primary'
|
|
||||||
: 'default'
|
|
||||||
}
|
}
|
||||||
onClick={() =>
|
alt="采集候选图片"
|
||||||
toggleImage(
|
/>
|
||||||
candidate.asset_id!
|
{selectable ? (
|
||||||
)
|
<Button
|
||||||
}
|
size="small"
|
||||||
>
|
type={
|
||||||
{selected
|
selected
|
||||||
? '已选择'
|
? 'primary'
|
||||||
: '选择'}
|
: 'default'
|
||||||
</Button>
|
}
|
||||||
) : candidate.status ===
|
onClick={() =>
|
||||||
'pending_selection' ? null : (
|
toggleImage(
|
||||||
<Button
|
candidate.asset_id!
|
||||||
size="small"
|
)
|
||||||
loading={
|
}
|
||||||
materializingTransferId ===
|
>
|
||||||
candidate.id
|
{selected
|
||||||
}
|
? '已选择'
|
||||||
disabled={
|
: '选择'}
|
||||||
candidate.status ===
|
</Button>
|
||||||
'queued' ||
|
) : candidate.status ===
|
||||||
candidate.status ===
|
'pending_selection' ? null : (
|
||||||
'running' ||
|
<Button
|
||||||
candidate.status ===
|
size="small"
|
||||||
'failed'
|
loading={
|
||||||
}
|
materializingTransferId ===
|
||||||
onClick={() =>
|
|
||||||
void onMaterialize(
|
|
||||||
candidate.id
|
candidate.id
|
||||||
)
|
}
|
||||||
}
|
disabled={
|
||||||
>
|
candidate.status ===
|
||||||
{candidate.status === 'failed'
|
'queued' ||
|
||||||
? '转存失败'
|
candidate.status ===
|
||||||
: '转存中'}
|
'running' ||
|
||||||
</Button>
|
candidate.status ===
|
||||||
)}
|
'failed'
|
||||||
</div>
|
}
|
||||||
);
|
onClick={() =>
|
||||||
})}
|
void onMaterialize(
|
||||||
|
candidate.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{candidate.status ===
|
||||||
|
'failed'
|
||||||
|
? '转存失败'
|
||||||
|
: '转存中'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
{/* 仅提供素材选择入口的视觉占位,实际选择流程后续接入。 */}
|
{/* 仅提供素材选择入口的视觉占位,实际选择流程后续接入。 */}
|
||||||
<Button
|
<Button
|
||||||
className="sku-media-picker"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import CategoryTreeDebugPage from '@/pages/shops/CategoryTreeDebugPage';
|
|||||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||||
import { PlatformProvider } from '@/services/platform';
|
import { PlatformProvider } from '@/services/platform';
|
||||||
import { useSession } from '@/services/session';
|
import { useSession } from '@/services/session';
|
||||||
|
import SystemAdminLayout from '@/layouts/SystemAdminLayout';
|
||||||
|
import SystemCatalogPage from '@/pages/system/SystemCatalogPage';
|
||||||
|
|
||||||
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
||||||
function RequireSession({ children }: { children: ReactNode }) {
|
function RequireSession({ children }: { children: ReactNode }) {
|
||||||
@@ -71,6 +73,14 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
{ 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',
|
path: 'login',
|
||||||
element: (
|
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`),
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ProductEditorCategoryOption,
|
ProductEditorCategoryOption,
|
||||||
ProductEditorContext,
|
ProductEditorContext,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
|
ProductEditorValidation,
|
||||||
Operation,
|
Operation,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
SaveProductEditorDraftResponse,
|
SaveProductEditorDraftResponse,
|
||||||
@@ -39,22 +40,42 @@ export const productEditorApi = {
|
|||||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
'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[]>(
|
api.get<ProductEditorCategoryOption[]>(
|
||||||
`/product-platform-profiles/${profileId}/category-options`,
|
`/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 },
|
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[]>(
|
api.get<ProductAttributeSchema[]>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-schema`,
|
`/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 },
|
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) =>
|
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||||
api.post<{ operation: Operation }>(
|
api.post<{ operation: Operation }>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||||
@@ -74,6 +95,7 @@ export const productEditorApi = {
|
|||||||
search?: string,
|
search?: string,
|
||||||
categoryId?: string | null,
|
categoryId?: string | null,
|
||||||
storeId?: string | null,
|
storeId?: string | null,
|
||||||
|
cursor?: string | null
|
||||||
) =>
|
) =>
|
||||||
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
||||||
@@ -82,6 +104,7 @@ export const productEditorApi = {
|
|||||||
...(categoryId ? { category_id: categoryId } : {}),
|
...(categoryId ? { category_id: categoryId } : {}),
|
||||||
...(storeId ? { store_id: storeId } : {}),
|
...(storeId ? { store_id: storeId } : {}),
|
||||||
...(search ? { q: search } : {}),
|
...(search ? { q: search } : {}),
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
},
|
},
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
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
@@ -19,17 +19,25 @@ import {
|
|||||||
type CatalogSyncScope,
|
type CatalogSyncScope,
|
||||||
} from './ozon-catalog-sync.js';
|
} from './ozon-catalog-sync.js';
|
||||||
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.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, {
|
const connection = new Redis(workerConfig.redisUrl, {
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
});
|
});
|
||||||
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||||
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
||||||
|
const platformQueue = new Queue<PlatformCatalogJob>('atlas-dispatch', { connection });
|
||||||
const dispatcher = new OutboxDispatcher(
|
const dispatcher = new OutboxDispatcher(
|
||||||
database,
|
database,
|
||||||
queue,
|
queue,
|
||||||
`worker_${randomUUID()}`,
|
`worker_${randomUUID()}`,
|
||||||
);
|
);
|
||||||
|
const platformDispatcher = new PlatformCatalogDispatcher(
|
||||||
|
database, platformQueue, `platform_worker_${randomUUID()}`,
|
||||||
|
);
|
||||||
|
const platformScheduler = new PlatformCatalogScheduler(database);
|
||||||
const objectStorage = new S3Client({
|
const objectStorage = new S3Client({
|
||||||
endpoint: workerConfig.objectStorageEndpoint,
|
endpoint: workerConfig.objectStorageEndpoint,
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
@@ -54,15 +62,17 @@ type CatalogSyncJob = OutboxJob & {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const worker = new Worker<OutboxJob>(
|
const worker = new Worker<OutboxJob | PlatformCatalogJob>(
|
||||||
'atlas-dispatch',
|
'atlas-dispatch',
|
||||||
async (job) => {
|
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 } });
|
await verifyStoreCredentials(database, job.data as OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } });
|
||||||
} else if (job.name === 'collection.cleanup.requested') {
|
} else if (job.name === 'collection.cleanup.requested') {
|
||||||
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
|
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') {
|
} 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 (
|
} else if (
|
||||||
job.name === 'store_category_tree_sync.requested' ||
|
job.name === 'store_category_tree_sync.requested' ||
|
||||||
job.name === 'store_category_attribute_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) => {
|
worker.on('failed', (job, error) => {
|
||||||
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
|
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);
|
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||||
} else if (
|
} else if (
|
||||||
job.name === 'store_category_tree_sync.requested' ||
|
job.name === 'store_category_tree_sync.requested' ||
|
||||||
@@ -285,20 +297,36 @@ async function markUnavailable(
|
|||||||
|
|
||||||
let dispatcherTimer: ReturnType<typeof setInterval> | undefined;
|
let dispatcherTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
|
let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let platformCatalogTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await queue.waitUntilReady();
|
await queue.waitUntilReady();
|
||||||
await worker.waitUntilReady();
|
await worker.waitUntilReady();
|
||||||
await dispatcher.dispatchBatch();
|
await dispatcher.dispatchBatch();
|
||||||
|
await platformDispatcher.dispatchBatch();
|
||||||
|
await platformScheduler.run();
|
||||||
dispatcherTimer = setInterval(
|
dispatcherTimer = setInterval(
|
||||||
() =>
|
() => {
|
||||||
void dispatcher.dispatchBatch().catch((error: unknown) => {
|
void dispatcher.dispatchBatch().catch((error: unknown) => {
|
||||||
console.error(
|
console.error(
|
||||||
'outbox_dispatch_failed',
|
'outbox_dispatch_failed',
|
||||||
error instanceof Error ? error.message : error,
|
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,
|
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) => {
|
void mediaUploadCleanup.run().catch((error: unknown) => {
|
||||||
console.error(
|
console.error(
|
||||||
'media_upload_cleanup_startup_failed',
|
'media_upload_cleanup_startup_failed',
|
||||||
@@ -329,8 +357,10 @@ void bootstrap().catch((error: unknown) => {
|
|||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
if (dispatcherTimer) clearInterval(dispatcherTimer);
|
if (dispatcherTimer) clearInterval(dispatcherTimer);
|
||||||
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
|
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
|
||||||
|
if (platformCatalogTimer) clearInterval(platformCatalogTimer);
|
||||||
await worker.close();
|
await worker.close();
|
||||||
await queue.close();
|
await queue.close();
|
||||||
|
await platformQueue.close();
|
||||||
objectStorage.destroy();
|
objectStorage.destroy();
|
||||||
await database.close();
|
await database.close();
|
||||||
await connection.quit();
|
await connection.quit();
|
||||||
|
|||||||
@@ -87,18 +87,85 @@ export async function fetchOzonDictionaryValues(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const query = input.query?.trim() ?? '';
|
const query = input.query?.trim() ?? '';
|
||||||
const path = query
|
const request = {
|
||||||
? '/v1/description-category/attribute/values/search'
|
|
||||||
: '/v1/description-category/attribute/values';
|
|
||||||
const body = await requestOzon(clientId, apiKey, path, {
|
|
||||||
attribute_id: numberOrText(input.attributeId),
|
attribute_id: numberOrText(input.attributeId),
|
||||||
description_category_id: numberOrText(input.categoryId),
|
description_category_id: numberOrText(input.categoryId),
|
||||||
type_id: numberOrText(input.typeId),
|
type_id: numberOrText(input.typeId),
|
||||||
language: 'ZH_HANS',
|
language: 'ZH_HANS',
|
||||||
limit: query ? 100 : 1_000,
|
};
|
||||||
...(query ? { value: query } : { last_value_id: 0 }),
|
if (query) {
|
||||||
});
|
const body = await requestOzon(
|
||||||
return readResultArray<OzonDictionaryValue>(body, '属性字典');
|
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(
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, '品牌(更新)');
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
||||||
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
||||||
| 图片存储策略 | [`architecture/media-storage-strategy.md`](./architecture/media-storage-strategy.md) | 采集候选图片的按需物化、AI/水印处理与发布素材边界。 |
|
| 图片存储策略 | [`architecture/media-storage-strategy.md`](./architecture/media-storage-strategy.md) | 采集候选图片的按需物化、AI/水印处理与发布素材边界。 |
|
||||||
|
| 平台商品参考目录 | [`architecture/platform-product-reference-catalog.md`](./architecture/platform-product-reference-catalog.md) | 以平台为边界共享类目、属性和字典的架构与实施状态;门店差异层仅为未来预案。 |
|
||||||
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
||||||
| 项目结构 | [`architecture/project-structure.md`](./architecture/project-structure.md) | pnpm monorepo 的目录、依赖关系与本地运行边界。 |
|
| 项目结构 | [`architecture/project-structure.md`](./architecture/project-structure.md) | pnpm monorepo 的目录、依赖关系与本地运行边界。 |
|
||||||
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
|
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
|
||||||
|
|||||||
+2
-1
@@ -18,6 +18,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
|
|||||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||||
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.5 部分已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并,以及 Ozon 类目树、动态属性和关键词字典缓存同步;媒体、定价、AI 任务与首次上架仍待实现。 |
|
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.5 部分已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并,以及 Ozon 类目树、动态属性和关键词字典缓存同步;媒体、定价、AI 任务与首次上架仍待实现。 |
|
||||||
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
|
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
|
||||||
|
| [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、手动同步和任务状态。 |
|
||||||
|
|
||||||
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
|
|||||||
- 手机号、短信登录、邮箱验证、忘记密码、第三方登录、MFA 与设备管理;
|
- 手机号、短信登录、邮箱验证、忘记密码、第三方登录、MFA 与设备管理;
|
||||||
- 团队成员、工作空间切换和角色管理 UI;
|
- 团队成员、工作空间切换和角色管理 UI;
|
||||||
- Cookie 授权/保存;
|
- Cookie 授权/保存;
|
||||||
- 平台、货币、字体、物流商、类目、佣金和汇率的后台维护接口。
|
- 货币、字体、物流商、佣金和汇率的后台维护接口;平台类目维护已经使用独立系统管理员接口实现。
|
||||||
|
|
||||||
这些事项不会由首期客户端私自调用管理接口;运营数据由受控 seed、migration 或后台任务维护。
|
这些事项不会由首期客户端私自调用管理接口;运营数据由受控 seed、migration 或后台任务维护。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 平台类目与属性管理 API
|
||||||
|
|
||||||
|
> 2026-09-18 新增契约。平台级目录供系统管理员维护;普通商品编辑器现有门店级 API 暂不改变。平台参考目录的归属见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md)。
|
||||||
|
|
||||||
|
## 权限与安全
|
||||||
|
|
||||||
|
- 本模块接口都要求有效登录会话。除 `GET /api/system/access` 外,必须具有数据库授予且未撤销的 `system_admin` 全局权限;工作空间 `owner/admin` 不等于系统管理员。未授权返回 `403 SYSTEM_ADMIN_REQUIRED`。
|
||||||
|
- 系统管理员授权只能由受控数据库运维操作授予或撤销,不提供自助提权 HTTP 接口。权限变更在下一次请求时生效;前端菜单只是展示控制。
|
||||||
|
- 目录、同步来源和任务均按 `platform_id` 分区,不使用客户工作空间的 `workspace_id` 承载平台任务。Ozon `Client-Id` / `Api-Key` 只经 TLS 提交至 API,Key 加密落库,不写日志、Outbox 或响应。
|
||||||
|
- Wildberries 等其他平台可展示目录空状态;未实现适配器时发起同步返回 `422 PLATFORM_CATALOG_UNSUPPORTED`。
|
||||||
|
|
||||||
|
### 首位系统管理员初始化
|
||||||
|
|
||||||
|
系统管理员不能通过产品界面自助获得权限。目标用户先完成正常注册,再由数据库运维账号执行以下 SQL;应用运行账号 `atlas_app` 已被禁止修改该表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO atlas.system_admin_grants (user_id)
|
||||||
|
SELECT id FROM atlas.users WHERE lower(email) = lower('管理员邮箱')
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET revoked_at = NULL, granted_at = now();
|
||||||
|
```
|
||||||
|
|
||||||
|
撤销权限时设置 `revoked_at = now()`,不要删除用户或改变其工作空间角色。授权和撤销均在下一次请求时生效。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
`GET /api/system/access`
|
||||||
|
|
||||||
|
返回 `{ "data": { "is_system_admin": boolean } }`,供已登录 Web 决定是否展示系统管理入口;服务端权限判断仍以数据库授权为准。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms`
|
||||||
|
|
||||||
|
返回 `{ "data": [{ "id", "code", "name", "source": { "configured", "client_id_hint", "updated_at" }, "summary": { "category_count", "last_tree_sync_at", "last_error" } }] }`。`client_id_hint` 仅为脱敏末尾字符,不返回 API Key、密文或指纹。
|
||||||
|
|
||||||
|
`PUT /api/system/platform-catalog/platforms/{platform_id}/source`
|
||||||
|
|
||||||
|
Body:`{ "client_id": "...", "api_key": "..." }`。仅 Ozon 适配器接受当前配置;重复提交同一凭据不产生新版本或重复任务。首次配置或更换凭据时返回脱敏来源状态,并为该平台排队一次类目树同步;更换后旧版本的未完成任务不得用新密钥执行。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/categories`
|
||||||
|
|
||||||
|
返回当前平台的全部树节点 `{ id, parent_id, external_category_id, external_type_id, name, path, is_active, synced_at }`;按父子关系在客户端构建树,目录节点可展开,仅可发布类型叶子用于查看属性。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/categories/{category_id}/attributes`
|
||||||
|
|
||||||
|
返回该类型的属性定义 `{ id, platform_attribute_id, complex_id, name, description, group_name, value_type, is_required, is_aspect, is_collection, max_value_count, dictionary_id, is_active, synced_at, dictionary_value_count }`。`category_id` 不属于所选平台时返回 404。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/attributes/{attribute_id}/values?q={keyword}&page={number}`
|
||||||
|
|
||||||
|
仅字典属性可查;`page` 从 1 开始、每页最多 100,返回 `{ "data": [{ "id", "external_value_id", "value", "info", "picture" }], "meta": { "page", "page_size", "total", "is_complete" } }`。搜索只查 Atlas 缓存;`is_complete=false` 表示已缓存部分候选值,不表示 Ozon 字典只有这些值。
|
||||||
|
|
||||||
|
`POST /api/system/platform-catalog/platforms/{platform_id}/syncs`
|
||||||
|
|
||||||
|
Headers:`Idempotency-Key`(UUID)必填。Body:`{ "scope": "tree" | "attributes" | "dictionary", "category_id"?: "uuid", "attribute_id"?: "uuid" }`。树同步不带其他 ID;属性同步需要当前平台的可发布 `category_id`;字典同步需要该类型下带字典的 `attribute_id`。同作用域已有 queued/running 任务时返回已有任务,并将本次幂等键持久映射到该任务;同一用户重试不会在原任务完成后意外创建第二个任务。成功排队返回 `202` 与 `{ "data": { "id", "platform_id", "scope", "status", "trigger", "created_at" } }`。同步由 Worker 异步执行,失败保留上次成功缓存。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/syncs?limit={number}`
|
||||||
|
|
||||||
|
返回该平台最近的同步任务(最多 50 条),包括作用域、触发方式、状态、开始/完成时间、数量摘要及脱敏错误。系统管理员可据此查看手动和定时更新结果。
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
# 商品编辑与首次上架 API
|
# 商品编辑与首次上架 API
|
||||||
|
|
||||||
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和关键词字典仍按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
|
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和字典按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
|
||||||
|
|
||||||
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||||
|
|
||||||
|
> 架构迁移说明:2026-09-18 已决定将类目/商品类型树、属性定义和字典候选值迁至[平台级参考目录](../architecture/platform-product-reference-catalog.md),同一平台所有门店共用,不维护门店差异层。本文件以下字段、UUID 和同步端点仍描述**当前已实现的门店级契约**;迁移前不得把它们解释为已经共享。未来调整须先修订本契约、共享类型和测试。
|
||||||
|
|
||||||
## 1. 进入编辑与读取编辑器
|
## 1. 进入编辑与读取编辑器
|
||||||
|
|
||||||
`POST /api/collection-items/{collection_item_id}/editor-contexts`
|
`POST /api/collection-items/{collection_item_id}/editor-contexts`
|
||||||
@@ -130,15 +132,15 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
|||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
|
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
|
||||||
|
|
||||||
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 与 `is_aspect`。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
|
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id`、`is_aspect` 与 `editor_field`。`editor_field` 只按 Ozon 属性 ID 计算,不允许按名称或翻译文本匹配:品牌 `85 → brand`、型号名称 `9048 → model`、类型 `8229 → category`、主题标签 `23171 → tags`、JSON 富内容 `11254 → rich_content`、简介/产品描述 `4191 → description`,其他 ID 返回 `null`。非空字段由编辑器已有控件承载,动态属性区不得重复展示或加载字典;服务端会兼容但忽略旧客户端重复提交的映射属性。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
|
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
|
||||||
|
|
||||||
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`;可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`。首期只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
|
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`,每页最多 200 项;传入 `next_cursor` 可继续读取,`null` 表示本地缓存已读完。可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`;无效游标返回 `400 VALIDATION_FAILED`。接口只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
||||||
|
|
||||||
返回当前平台的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,不提供同步按钮;Worker 仅使用显式配置的共享 Ozon 缓存源预热该树。
|
返回当前目标门店的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,编辑器不提供同步按钮;当前 Worker 在门店凭据验证成功后用该门店凭据同步树。平台级共享参考源属于待迁移目标,不是本接口的现有行为。
|
||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
|
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
|
||||||
|
|
||||||
@@ -146,11 +148,48 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
|||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
|
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
|
||||||
|
|
||||||
按关键词(至少两个字符)创建 `store_attribute_dictionary_sync` operation,Worker 使用该门店的 Ozon 字典搜索接口并 upsert 命中的本地值。不会为打开一个字段而全量下载大字典;前端先读本地缓存,缺少结果时由用户明确选择“从 Ozon 搜索”。
|
不传 `q` 时创建 `store_attribute_dictionary_sync` operation,Worker 从该门店的 Ozon 字典值接口按游标读取首批最多 1000 项并 upsert 本地缓存;打开字典控件时可按需触发,当前类目下同一字典只自动触发一次,切换类目后重新评估,不在加载类目时同时同步全部字典。传入 `q` 时至少两个字符,Worker 使用 Ozon 字典搜索接口 upsert 命中的值;前端输入关键词后延迟触发,以便从超过首批范围的大字典中继续查找。所有字典属性均由 schema 的 `dictionary_id` 驱动,不根据中文属性名维护固定选项。Ozon 密钥始终留在 Worker。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/validation`
|
`GET /api/product-platform-profiles/{profile_id}/validation`
|
||||||
|
|
||||||
返回 profile 与每个未删除 SKU 的本地首次上架校验:商品型号、类目/属性、标题、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
|
返回 profile 与每个未删除 SKU 的本地首次上架校验:标题、型号、品牌、类目/属性、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
|
||||||
|
|
||||||
|
响应 `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ready": false,
|
||||||
|
"profile": {
|
||||||
|
"ready": false,
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"code": "OZON_ATTRIBUTE_REQUIRED",
|
||||||
|
"field": "attributes.10001.0",
|
||||||
|
"message": "请填写最小儿童年龄",
|
||||||
|
"platform_attribute_id": "10001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"variant_id": "uuid",
|
||||||
|
"display_name": "红色",
|
||||||
|
"status": "blocked",
|
||||||
|
"ready": false,
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"code": "OZON_ATTRIBUTE_REQUIRED",
|
||||||
|
"field": "variants.uuid.attributes.10096.0",
|
||||||
|
"message": "请填写颜色",
|
||||||
|
"platform_attribute_id": "10096"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`profile.issues` 包含商品级基础信息、类目 schema 和 `is_aspect=false` 的必填属性;每个 SKU 的 `issues` 包含货号、价格、有效包装规格、图片和 `is_aspect=true` 的必填属性。只有 `confirmation_status=confirmed` 且包含非空值的属性满足发布要求。`ready=true` 要求 profile 无问题、至少存在一个未上架 SKU,且全部未上架 SKU 均通过校验。
|
||||||
|
|
||||||
## 4. SKU 定价确认
|
## 4. SKU 定价确认
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与
|
|||||||
|
|
||||||
Headers:`Idempotency-Key` 必填。同步请求 Ozon `POST /v1/description-category/tree` 并写入目标门店缓存;只有完整响应成功落库后才返回 `200` 与类目数量。必须是凭据已验证的 Ozon 门店,否则返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。Ozon 请求或落库失败返回脱敏错误,旧缓存保持不变。此手动操作不经 Worker 或队列,API Key 仅在服务端请求期间短暂存在。
|
Headers:`Idempotency-Key` 必填。同步请求 Ozon `POST /v1/description-category/tree` 并写入目标门店缓存;只有完整响应成功落库后才返回 `200` 与类目数量。必须是凭据已验证的 Ozon 门店,否则返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。Ozon 请求或落库失败返回脱敏错误,旧缓存保持不变。此手动操作不经 Worker 或队列,API Key 仅在服务端请求期间短暂存在。
|
||||||
|
|
||||||
|
> 此端点是现有门店级能力,不是未来系统管理员同步入口。迁移至[平台级参考目录](../architecture/platform-product-reference-catalog.md)时,须另行修订/废弃该同步语义;管理员操作应按平台排入统一后台任务,不复用本同步 HTTP 路径直接请求 Ozon。
|
||||||
|
|
||||||
`POST /api/stores/{store_id}/warehouse-syncs`
|
`POST /api/stores/{store_id}/warehouse-syncs`
|
||||||
|
|
||||||
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
|
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 平台级商品参考目录的归属与维护决策
|
||||||
|
|
||||||
|
> 决策日期:2026-09-18。状态:平台参考目录、系统管理员维护 API/页面及定时同步链路已经实现;商品编辑器的门店级引用尚未迁移。当前商品编辑契约仍以 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 为准。本决策取代 2026-09-10「每个门店完整保存一份类目、属性和字典」的目标设计,不抹去其实现历史。
|
||||||
|
|
||||||
|
## 1. 依据与边界
|
||||||
|
|
||||||
|
Ozon 的[类目树接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree)请求体仅以 `language` 选择返回语言;[属性接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes)以 `description_category_id + type_id + language` 查询属性;[字典值接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributeValues)还要求 `attribute_id`,并用 `last_value_id` 分页。属性响应的 `category_dependent` 指示字典值是否随类目变化。三个接口都需要卖家 `Client-Id + Api-Key`。
|
||||||
|
|
||||||
|
Atlas 本阶段明确采用**平台级参考数据**:同一 `platform_id` 下的所有门店共用一套类目/商品类型树、类型属性定义和字典候选值,不再按门店复制,也**不建立或维护门店差异层**。Ozon 文档未保证所有卖家账号返回完全一致;“同平台门店共用”是本阶段产品假设,而不是 Ozon 官方保证。若实际发现账号差异,再评审下文保留的扩展方案,不提前实现。
|
||||||
|
|
||||||
|
## 2. 数据归属
|
||||||
|
|
||||||
|
| 数据 | 目标归属与键 | 约束 |
|
||||||
|
|---|---|---|
|
||||||
|
| 类目/商品类型树 | `platform_id` 下的一份平台目录,名称按语言保存;Ozon 可发布叶子使用 `description_category_id + type_id` 标识 | 同平台门店读取相同的目录节点、父子关系与 `disabled`;不以名称或单独 `type_id` 判等,也不复制到各门店 |
|
||||||
|
| 类型属性定义 | `platform_id + 平台类目/类型键 + 属性键 + 语言/版本` | Ozon 的 `is_required`、`is_aspect`、`dictionary_id`、类型和数量限制属于其平台适配数据;其他平台由各自适配器定义外部标识和规则 |
|
||||||
|
| 字典候选值 | `platform_id + 平台类目/类型键 + 属性键 + 字典键 + 语言 + 值键`;记录完整/部分同步状态 | Ozon 的 `category_dependent=false` 可供后续去重,但不能仅以 `dictionary_id + value_id` 跨类目或跨平台合并 |
|
||||||
|
| 门店连接与商品/SKU 已填属性 | 原工作空间、门店、商品、profile、SKU | 凭据、上架结果、用户填报值、确认状态和发布快照仍是业务数据,按工作空间隔离;它们不是平台参考目录 |
|
||||||
|
|
||||||
|
平台参考表以 `platform_id` 隔离 Ozon、未来的 Wildberries 等平台,只保存对应平台可复用的参考元数据;不存客户凭据、商品、价格或店铺专有数据,也不使用 `store_id` 作为目录分区键。所有门店连接和商品引用仍须满足 [`development-collaboration-contract.md`](../project/development-collaboration-contract.md) 的工作空间外键与 RLS 约束。读 API 依页头平台上下文过滤,不能跨平台混用 ID,也不能据此枚举其他店铺信息。
|
||||||
|
|
||||||
|
目标交互上,切换到**同一平台**的另一个门店不应清空已选类目和属性;切换平台则必须重新选择该平台的类目并按新平台 schema 校验。新门店凭据验证只处理该门店连接,不为其复制或重新同步整套平台目录;若平台目录尚未准备好,由平台级任务补齐。当前门店级外键尚未迁移前仍维持现有切换行为。
|
||||||
|
|
||||||
|
## 3. 同步方案:自动为主、人工为辅
|
||||||
|
|
||||||
|
统一的后台同步服务按 `platform_id` 负责定时任务、首次缺失时的补齐和管理员手动请求。三种触发方式都创建有权限校验、幂等去重的 `platform_catalog_sync_runs + platform_catalog_outbox_events`;这是不带 `workspace_id` 的全局任务事实,不能借用租户 `operations/outbox_events`。Worker 通过平台适配器读取服务端安全存储的参考源凭据并调用对应平台。浏览器、日志、run 和 outbox 载荷不包含 API Key。管理员手动同步不得在 HTTP 请求内直接访问平台或绕过同一套限流、审计与事务发布流程。
|
||||||
|
|
||||||
|
1. **同步来源**:每个平台分别配置 Atlas 专用的参考源凭据及适配器;Ozon 首期使用专用参考门店凭据作为技术采集身份,而非普通客户工作空间门店。设置最小授权、密钥轮换、失效告警与备用源,不能未经授权借用客户门店密钥。若暂无专用凭据,保持现有门店级链路,待来源配置完成后迁移。
|
||||||
|
2. **基线调度**:当前实现为类目树和类型属性成功后 24 小时内不重复、字典成功后 7 天内不重复;调度器每 60 秒扫描,每个平台最多 6 个活动任务。BullMQ 最多尝试 5 次并从 5 秒开始指数退避。当前固定按树、属性、字典排序,在数千类型下会造成字典饥饿;后续必须改为属性/字典配额轮转,并增加近期访问优先级。具体间隔是 Atlas 运营参数,不是 Ozon 文档承诺的 TTL。
|
||||||
|
3. **管理员页面**:按平台展示参考源状态、树、属性、字典候选值、最后成功时间、失败详情、排队任务和「同步类目树 / 指定类目属性 / 指定字典」操作。手动动作必须受系统管理员权限控制、记录操作者,并返回异步任务;页面不展示密钥明文。当前实现位于 `/system/platform-catalog`,全局系统管理员授权独立于工作空间角色。
|
||||||
|
4. **读路径**:正常用户只读 Atlas 缓存。首次缺失时可排队补齐并显示准备中;已有数据过期时优先提供上次成功快照并后台更新。不能在每次打开编辑器或下拉框时同步请求 Ozon。大字典搜索按规范化关键词及上述字典作用域共享缓存,并去重并发补齐。
|
||||||
|
5. **原子性与监控**:P0 按平台及树、类型属性、字典作用域在单事务内 upsert,提交前读取方继续看到旧数据;失败保留上次成功缓存。仅在确认完整分页完成后才处理消失的字典值,部分同步记录 `is_complete=false`。当前尚未实现不可变版本快照、内容哈希、`next_refresh_at`、连续失败告警和异常删减保护,这些属于上线前可靠性加固,不能在文档中误记为已完成。
|
||||||
|
|
||||||
|
Ozon 字典分页每次最多 2000 个值;[搜索接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_SearchAttributeValues)要求至少两个字符、每次最多 100 个值。搜索命中只是局部候选,不能证明字典已完整同步。当前平台任务单次最多读取 5 页(10000 项);超过上限会正确保留为部分缓存,但续传游标尚未实现。
|
||||||
|
|
||||||
|
## 4. 门店差异层的未来预案(本阶段不实施)
|
||||||
|
|
||||||
|
本阶段按同平台所有门店使用相同目录、属性定义和候选值实施:**不创建门店差异表,不按门店同步参考目录,不在读取或发布校验时叠加门店覆盖规则**。门店凭据状态和某次发布的失败原因仍按门店记录,但不据此自动改写平台共享目录。
|
||||||
|
|
||||||
|
如果联调或线上证据表明确有账号专属的可售状态、属性或字典差异,再单独提出迁移:可按 `workspace_id + store_id` 建门店覆盖层,或按市场/卖家分组收窄共享范围,并制定差异检测与回退策略。该预案只保留扩展方向,不是当前开发任务或上线前置条件。
|
||||||
|
|
||||||
|
## 5. 从现有实现迁移
|
||||||
|
|
||||||
|
当前 `022_store_catalog_cache.sql` 的 `store_product_categories`、属性和字典表,以及 profile 的 `store_product_category_id` 外键都是已上线的门店级事实,**本次文档决策不改变它们**。旧 `001_foundation.sql` / `021_product_category_attribute_schema.sql` 虽已有平台级表,但原字典唯一键不足以表达按类目/类型变化的字典;不能直接当成最终系统目录复用。
|
||||||
|
|
||||||
|
迁移 `024_platform_catalog_management.sql` 已补齐以 `platform_id` 为作用域的参考来源、候选值和同步任务表,**没有建立门店差异表**;API、Worker 和管理页面已经按该作用域工作。下一步仍需以 Ozon 外部 ID 对照旧门店记录、回填平台目录。草稿/接口迁移应先提供兼容读写与可回滚映射,逐步改为平台目录引用,最后经校验和维护者批准再清理冗余门店缓存。现有商品编辑 HTTP 路径、UUID 含义和门店同步 operation 尚未改变;任何契约变更须先更新 `docs/api/`、共享类型和测试。Wildberries 等新平台应新增平台适配器及自己的外部 ID 映射,不复用 Ozon 的 `description_category_id + type_id` 语义。
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
`products` / `product_variants` 只服务采集箱商品,保存来源、采购和跨平台可复用资料。`platform_listings` 则是目标平台、目标门店的实际商品镜像;它不能跨平台或跨门店复用,即使它关联了 Atlas SKU。`product_platform_profiles.market_code` 表示当前平台业务上下文,首期取平台默认 `RU`,不从门店派生。
|
`products` / `product_variants` 只服务采集箱商品,保存来源、采购和跨平台可复用资料。`platform_listings` 则是目标平台、目标门店的实际商品镜像;它不能跨平台或跨门店复用,即使它关联了 Atlas SKU。`product_platform_profiles.market_code` 表示当前平台业务上下文,首期取平台默认 `RU`,不从门店派生。
|
||||||
|
|
||||||
|
类目/商品类型、属性定义和字典候选值是待迁移的**平台级**参考数据:同一平台所有门店共用,不跨 Ozon、Wildberries 等平台混用,也不在本阶段维护门店差异层。门店连接与商品/SKU 已填值仍按工作空间隔离。现有 `022_store_catalog_cache.sql` 的门店级缓存和 profile 外键继续有效,目标模型及迁移边界见 [`platform-product-reference-catalog.md`](./platform-product-reference-catalog.md)。
|
||||||
|
|
||||||
## 2. 关键关系与状态
|
## 2. 关键关系与状态
|
||||||
|
|
||||||
| 表 | 粒度 | 核心约束 |
|
| 表 | 粒度 | 核心约束 |
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` |
|
| language | string | 可选 | 语言代码:`DEFAULT`(默认俄文)、`RU`(俄文)、`EN`(英文)、`TR`(土耳其文)、`ZH_HANS`(简体中文) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,13 +105,13 @@
|
|||||||
⑤ 选中后保存 description_category_id + type_id
|
⑤ 选中后保存 description_category_id + type_id
|
||||||
```
|
```
|
||||||
|
|
||||||
### 场景 2:服务端门店缓存
|
### 场景 2:Atlas 平台级参考缓存(目标架构,未实施)
|
||||||
|
|
||||||
```
|
```
|
||||||
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取完整类目树(Atlas 使用 `ZH_HANS`)
|
① 按平台的定时任务用 Atlas 专用参考源凭据拉取完整类目树(Ozon 首期 `ZH_HANS`)
|
||||||
② 递归保存每一层及其父子关系,按门店存入本地类目缓存;不与其他门店混用
|
② 原子更新该平台共享目录;同平台门店读取同一份树,不建门店覆盖层
|
||||||
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
|
③ 同步失败保留上次成功版本,由后台任务重试并告警
|
||||||
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
|
④ 用户选类目时只查 Atlas 缓存,不频繁调 Ozon API
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -213,30 +213,7 @@ function toTreeData(nodes: CategoryNode[]): any[] {
|
|||||||
|
|
||||||
## 缓存策略
|
## 缓存策略
|
||||||
|
|
||||||
### 按门店缓存(Atlas 方案)
|
目标方案见 [`platform-product-reference-catalog.md`](../../architecture/platform-product-reference-catalog.md):每个平台分别维护共享目录;定时自动同步与系统管理员手动触发共用异步任务、原子版本切换和失败保留旧版;前端只读 Atlas 缓存。当前运行中的门店级同步仍见下方「Atlas 当前集成」,不能把目标方案当作已经上线。
|
||||||
|
|
||||||
```python
|
|
||||||
# 类目数据的读取凭证属于门店。即使结构通常相近,也必须按 store_id 隔离缓存。
|
|
||||||
# 验证成功或缓存失效时,由 Worker 用该门店凭证拉取完整树并递归写库。
|
|
||||||
# 前端只读取该门店的缓存,绝不携带或读取 Client-Id / Api-Key。
|
|
||||||
async def get_store_category_tree(store_id: str):
|
|
||||||
cached = await load_store_category_cache(store_id)
|
|
||||||
if cached and cached.is_valid:
|
|
||||||
return cached.tree
|
|
||||||
|
|
||||||
await enqueue_store_catalog_sync(store_id)
|
|
||||||
raise CatalogCachePreparingError(store_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 按需更新
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 用户在门店管理中手动刷新时,API 同步请求 Ozon 并在成功后原子替换目标门店缓存。
|
|
||||||
# 请求或落库失败会保留旧缓存并向用户返回错误,不经 Worker 队列。
|
|
||||||
async def refresh_store_category_tree(store_id: str):
|
|
||||||
tree = await fetch_category_tree_for_store(store_id)
|
|
||||||
return await replace_store_category_cache_atomically(store_id, tree)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -244,15 +221,15 @@ async def refresh_store_category_tree(store_id: str):
|
|||||||
|
|
||||||
### Q1: 类目树很大吗?
|
### Q1: 类目树很大吗?
|
||||||
|
|
||||||
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。完整树在一次响应中以递归 `children` 返回,首次拉取需几秒,后续从门店缓存读取。
|
**A**: 完整树在一次响应中以递归 `children` 返回;具体节点数和响应大小以实际同步记录为准,后续从 Atlas 缓存读取。
|
||||||
|
|
||||||
### Q2: 多久更新一次?
|
### Q2: 多久更新一次?
|
||||||
|
|
||||||
**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**。
|
**A**: Ozon 接口未规定 Atlas 的缓存 TTL。目标架构建议由后台每日检查树,并提供管理员手动排队同步入口;间隔可按运行量调整,见[架构决策](../../architecture/platform-product-reference-catalog.md)。
|
||||||
|
|
||||||
### Q3: 不同语言的类目树结构一样吗?
|
### Q3: 不同语言的类目树结构一样吗?
|
||||||
|
|
||||||
**A**: 通常 `description_category_id` / `type_id` 相同,仅 `category_name` / `type_name` 翻译不同。Atlas 统一由 Worker 为每个门店缓存 `ZH_HANS`,前端直接读取该门店缓存,不能为展示语言绕过缓存直接携带门店密钥请求 Ozon。
|
**A**: 接口支持语言参数,不应未经验证就假定各语言的 ID 和翻译映射完全相同。当前 Worker 为每个门店缓存 `ZH_HANS`;目标是由 Ozon 平台参考源统一缓存该语言,同平台所有门店共用。前端不能携带门店密钥直连 Ozon。
|
||||||
|
|
||||||
### Q4: `type_id=0` 是什么意思?
|
### Q4: `type_id=0` 是什么意思?
|
||||||
|
|
||||||
@@ -266,6 +243,7 @@ async def refresh_store_category_tree(store_id: str):
|
|||||||
- **API**:按当前目标门店返回缓存的扁平节点(包含 `parent_id`);不会将 `Client-Id` 或 `Api-Key` 返回给浏览器。
|
- **API**:按当前目标门店返回缓存的扁平节点(包含 `parent_id`);不会将 `Client-Id` 或 `Api-Key` 返回给浏览器。
|
||||||
- **前端**:根据 `parent_id` 重建任意深度的 Cascader;目录可展开,只有可发布的叶子能写入商品草稿。
|
- **前端**:根据 `parent_id` 重建任意深度的 Cascader;目录可展开,只有可发布的叶子能写入商品草稿。
|
||||||
- **调试**:若需要查看 Ozon 原始响应,应由受权限保护的服务端调试能力获取并脱敏展示;浏览器不得直接用门店密钥请求 Ozon。
|
- **调试**:若需要查看 Ozon 原始响应,应由受权限保护的服务端调试能力获取并脱敏展示;浏览器不得直接用门店密钥请求 Ozon。
|
||||||
|
- **迁移目标**:平台级共享目录和统一异步管理同步尚未实现;本阶段不维护门店差异层,见[架构决策](../../architecture/platform-product-reference-catalog.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
| description_category_id | integer | ✅ 是 | 类目 ID |
|
| description_category_id | integer | ✅ 是 | 类目 ID |
|
||||||
| type_id | integer | ✅ 是 | 商品类型 ID |
|
| type_id | integer | ✅ 是 | 商品类型 ID |
|
||||||
| language | string | 可选 | 语言代码 |
|
| language | string | 可选 | 语言代码 |
|
||||||
| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 |
|
| limit | integer | ✅ 是 | 每页数量,范围 **1–2000**;Atlas 当前首批请求使用 1000 |
|
||||||
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
|
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -209,7 +209,7 @@ async def fetch_all_values(attribute_id, category_id, type_id):
|
|||||||
"description_category_id": category_id,
|
"description_category_id": category_id,
|
||||||
"type_id": type_id,
|
"type_id": type_id,
|
||||||
"language": "RU",
|
"language": "RU",
|
||||||
"limit": 5000,
|
"limit": 2000,
|
||||||
"last_value_id": last_id
|
"last_value_id": last_id
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -431,48 +431,9 @@ if missing:
|
|||||||
|
|
||||||
## 7. 缓存策略
|
## 7. 缓存策略
|
||||||
|
|
||||||
### 属性列表缓存
|
Ozon 只定义查询参数和分页规则,不规定 Atlas 的 TTL。2026-09-18 的[架构决策](../../architecture/platform-product-reference-catalog.md)将属性定义和字典候选值设为平台级参考数据:同一平台所有门店共用,本阶段不维护门店差异层;此方案尚未实现。当前代码仍以门店、类目和类型为作用域按需同步,用户从 Atlas 本地缓存读取。
|
||||||
|
|
||||||
```python
|
目标同步方式:对活跃 `description_category_id + type_id` 的属性定义定期后台复核;字典候选值至少以类目、类型、属性、字典 ID 和语言限定作用域。`category_dependent=false` 可以作为进一步去重的依据,但在跨账号/跨类目验证前不能只以 `dictionary_id` 合并。小字典可完整分页并标记 `complete`;大字典先保存常用页与搜索命中,并明确标记 `partial`。搜索结果不是全集,不能据此清理未命中的旧值。定时和管理员手动同步必须共用异步任务链路,成功后原子切换版本,失败保留上次成功值。
|
||||||
# 按 (category_id, type_id) 缓存
|
|
||||||
# TTL 7 天(属性变化极少)
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
@lru_cache(maxsize=500)
|
|
||||||
async def get_attributes_cached(category_id: int, type_id: int):
|
|
||||||
# 先查数据库
|
|
||||||
cached = await db.query(CategoryAttribute).filter_by(
|
|
||||||
description_category_id=category_id,
|
|
||||||
type_id=type_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
if cached:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
# 未缓存,调 API
|
|
||||||
attrs = await fetch_category_attributes(category_id, type_id)
|
|
||||||
|
|
||||||
# 写入数据库
|
|
||||||
await save_attributes_to_db(attrs)
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字典值缓存(按需)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 字典值可能很大(数万条),不全量缓存
|
|
||||||
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search)
|
|
||||||
|
|
||||||
async def get_attribute_values(attr_id, category_id, type_id, keyword=None):
|
|
||||||
if keyword:
|
|
||||||
# 有关键词 → 搜索接口(limit 100)
|
|
||||||
return await search_values(attr_id, category_id, type_id, keyword)
|
|
||||||
else:
|
|
||||||
# 无关键词 → 拉全量(分页,存数据库)
|
|
||||||
return await fetch_all_values(attr_id, category_id, type_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Ozon 动态属性与 SKU 填报改造方案
|
# Ozon 动态属性与 SKU 填报改造方案
|
||||||
|
|
||||||
> 状态:待实施设计,记录于 2026-09-18。本文描述目标交互、数据归属和校验边界;在对应 API 契约、共享类型与实现完成更新前,不代表当前接口已经具备这些行为。
|
> 状态:P0 已于 2026-09-18 实现,P1/P2 待实施。当前已完成必填展示、通用/变体属性分区、宽松草稿保存及服务端逐 SKU 校验;新 SKU 首次保存时关联属性、结构化字典值与发布闭环仍以后续阶段为准。
|
||||||
|
|
||||||
## 1. 目标与原则
|
## 1. 目标与原则
|
||||||
|
|
||||||
Ozon 商品的必填属性由目标门店、`description_category_id` 与 `type_id` 对应的实时类目 schema 决定。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。
|
Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的类目 schema 决定;目标架构中,同一 Ozon 平台的所有门店共用该 schema。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。目标参考数据归属见[架构决策](../architecture/platform-product-reference-catalog.md);当前实现仍读门店级缓存。
|
||||||
|
|
||||||
页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
|
页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
|
||||||
|
|
||||||
@@ -65,6 +65,8 @@ SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父
|
|||||||
3. 同步成功后重新构建动态属性表单。
|
3. 同步成功后重新构建动态属性表单。
|
||||||
4. 页面显示类目路径、商品类型和最后同步时间,并提供失败后的重试入口。
|
4. 页面显示类目路径、商品类型和最后同步时间,并提供失败后的重试入口。
|
||||||
|
|
||||||
|
以上描述当前门店级 schema 的获取流程。迁移到平台级参考目录后,直接读取当前平台共享 schema,不叠加门店差异;缺失或过期时由统一后台任务补齐,普通用户不直接触发 Ozon 请求,管理员重试与定时任务共用同一队列。接口字段及草稿外键在 `docs/api/` 更新前不改变。
|
||||||
|
|
||||||
属性 schema 响应除现有字段外,计划补充:
|
属性 schema 响应除现有字段外,计划补充:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -175,6 +177,10 @@ GET /api/product-platform-profiles/{profile_id}/validation
|
|||||||
- 保存草稿允许不完整,发布校验阻断缺失项。
|
- 保存草稿允许不完整,发布校验阻断缺失项。
|
||||||
- 服务端补齐通用和逐 SKU 必填属性检查。
|
- 服务端补齐通用和逐 SKU 必填属性检查。
|
||||||
|
|
||||||
|
实现状态:已完成。编辑器会在选定类目后立即加载 schema,商品级必填属性始终显示在「产品属性」,SKU 级必填属性始终显示在对应 SKU 卡片;保存草稿不因缺少必填值失败,`validation` 接口按商品与 SKU 返回结构化缺失项。
|
||||||
|
|
||||||
|
类目 schema 中由编辑器已有控件承载的字段通过 `editor_field` 显式绑定,不在动态属性区重复展示,也不重复加载其字典。绑定只允许按 Ozon 属性 ID:品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254`、简介/产品描述 `4191`;禁止使用名称或翻译文本猜测。后续发布适配器必须据此生成 Ozon 所需字段,不能要求用户重复填写。
|
||||||
|
|
||||||
### P1:SKU 数据结构
|
### P1:SKU 数据结构
|
||||||
|
|
||||||
- 属性草稿调整为 `common_attributes + variants[].attributes`。
|
- 属性草稿调整为 `common_attributes + variants[].attributes`。
|
||||||
@@ -185,7 +191,7 @@ GET /api/product-platform-profiles/{profile_id}/validation
|
|||||||
|
|
||||||
### P2:发布闭环
|
### P2:发布闭环
|
||||||
|
|
||||||
- 实现 validation 接口及发布接口复用。
|
- 发布接口复用现有 validation 校验结果。
|
||||||
- 按 SKU 生成 `/v3/product/import` 的 `items[]`。
|
- 按 SKU 生成 `/v3/product/import` 的 `items[]`。
|
||||||
- 正确生成普通属性与复杂属性。
|
- 正确生成普通属性与复杂属性。
|
||||||
- 固化发布快照并将 Ozon 错误回填到 SKU/属性。
|
- 固化发布快照并将 Ozon 错误回填到 SKU/属性。
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
||||||
|
|
||||||
前端根据当前目标门店、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。门店 Ozon 凭据验证成功后,Worker 使用该门店凭据同步中文类目树;缓存为空时编辑器显示准备中状态,用户不能在编辑页发起树同步。选中类目但 schema 缺失时,仍可显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端门店类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。
|
当前实现中,前端根据目标门店的本地类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。门店 Ozon 凭据验证成功后,Worker 使用该门店凭据同步中文类目树;缓存为空时编辑器显示准备中状态,用户不能在编辑页发起树同步。选中类目但 schema 缺失时,仍可显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端门店类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。[待迁移目标](../architecture/platform-product-reference-catalog.md)是同平台门店共用参考目录,本阶段不维护门店差异层。
|
||||||
|
|
||||||
类目、标题、型号、品牌以及默认包装重量和默认包装尺寸固定放在「主要信息」区。SKU 默认继承父商品包装规格,仅在包装不同的情况下填写覆盖值。平台动态必填字段统一使用表单标准表现,在 Label 前显示红色 `*`;不追加「Ozon 必填」标签,也不在区块标题显示完成数量。更完整的目标交互、属性值表达、SKU 关联和发布校验改造见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](./ozon-dynamic-attributes-and-sku-form-plan.md)。
|
类目、标题、型号、品牌以及默认包装重量和默认包装尺寸固定放在「主要信息」区。SKU 默认继承父商品包装规格,仅在包装不同的情况下填写覆盖值。平台动态必填字段统一使用表单标准表现,在 Label 前显示红色 `*`;不追加「Ozon 必填」标签,也不在区块标题显示完成数量。更完整的目标交互、属性值表达、SKU 关联和发布校验改造见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](./ozon-dynamic-attributes-and-sku-form-plan.md)。
|
||||||
|
|
||||||
|
|||||||
+98
-8
@@ -215,7 +215,7 @@
|
|||||||
|
|
||||||
### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性
|
### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性
|
||||||
|
|
||||||
- 旧迁移 `021_product_category_attribute_schema.sql` 建立了平台级 schema 缓存;最新设计由 `022_store_catalog_cache.sql` 另建门店级类目、schema 与字典缓存,发布草稿只引用门店级数据。
|
- 旧迁移 `021_product_category_attribute_schema.sql` 建立了平台级 schema 缓存;当时采用 `022_store_catalog_cache.sql` 另建门店级类目、schema 与字典缓存,发布草稿只引用门店级数据。此处记录已实现历史,2026-09-18 的新目标见[平台级参考目录决策](../architecture/platform-product-reference-catalog.md)。
|
||||||
- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。
|
- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。
|
||||||
- 编辑器草稿可原子保存 `store_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。
|
- 编辑器草稿可原子保存 `store_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。
|
||||||
- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。
|
- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。
|
||||||
@@ -224,17 +224,18 @@
|
|||||||
|
|
||||||
- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于门店级发布元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。
|
- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于门店级发布元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。
|
||||||
- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。
|
- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。
|
||||||
- 字典不会全量拉取:字段先读取已有缓存;用户输入至少两个字符且本地无匹配时,现有实现可显式发起 Ozon `/attribute/values/search`,Worker 仅 upsert 返回的命中项。同步失败应保留旧缓存和可读错误状态。
|
- 字典不会全量拉取:字段先读取已有缓存;首次打开某个字典属性时,按需从 Ozon `/attribute/values` 按游标同步首批最多 1000 项,当前类目下同一字典仅自动触发一次。输入至少两个字符后延迟调用 Ozon `/attribute/values/search`,补充首批以外的候选;本地缓存每页返回 200 项并支持下拉滚动续读。同步失败应保留旧缓存和可读错误状态。
|
||||||
|
|
||||||
### 1.27 历史状态(2026-09-10):平台级预热方案已废弃
|
### 1.27 历史状态(2026-09-10):平台级预热方案已废弃
|
||||||
|
|
||||||
- 原交接要求的“Worker 启动时以指定门店预热一份平台共享类目树”已被产品决策替代:类目、属性和字典缓存按门店隔离,不能再恢复平台级共享缓存或环境变量指定的共享源。
|
- 原交接要求的“Worker 启动时以指定门店预热一份平台共享类目树”当时被门店级方案替代。该时期要求类目、属性和字典按门店隔离,不恢复平台级共享缓存或以环境变量任意指定客户门店作为共享源。
|
||||||
- 保留的原则是:浏览器、日志、operation 和 outbox 事件均不携带 API Key;编辑器只读缓存,不提供类目树手动同步入口。
|
- 保留的原则是:浏览器、日志、operation 和 outbox 事件均不携带 API Key;编辑器只读缓存,不提供类目树手动同步入口。
|
||||||
|
- 本节只记录当时决策;2026-09-18 的[平台级参考目录决策](../architecture/platform-product-reference-catalog.md)已取代该目标。现有门店级实现未随文档决策自动迁移。
|
||||||
|
|
||||||
### 1.28 开发进度(2026-09-10):门店类目同步与来源回填
|
### 1.28 开发进度(2026-09-10):门店类目同步与来源回填
|
||||||
|
|
||||||
- Ozon 门店凭据验证成功后,在同一事务中创建门店类目同步 operation 与 outbox 事件;Worker 使用该门店 `Client-Id + Api-Key` 请求中文类目树,并写入 `store_product_categories`。失败只标记类目同步 operation,不会把已验证的凭据改为无效。
|
- Ozon 门店凭据验证成功后,在同一事务中创建门店类目同步 operation 与 outbox 事件;Worker 使用该门店 `Client-Id + Api-Key` 请求中文类目树,并写入 `store_product_categories`。失败只标记类目同步 operation,不会把已验证的凭据改为无效。
|
||||||
- 已删除编辑器的手动类目树同步接口和客户端调用。属性 schema 与字典值仍按当前类目和关键词按需同步,使用原有 Outbox/Worker 链路。
|
- 已删除编辑器的手动类目树同步接口和客户端调用。属性 schema 与字典值仍按当前类目按需同步;字典支持打开控件时同步首批值和按关键词搜索,均使用原有 Outbox/Worker 链路。
|
||||||
- Cascader 显示完整缓存树路径,但表单与草稿 API 始终保存末级类目 UUID,修复了路径数组不能直接提交到 `editor-draft` 的问题。
|
- Cascader 显示完整缓存树路径,但表单与草稿 API 始终保存末级类目 UUID,修复了路径数组不能直接提交到 `editor-draft` 的问题。
|
||||||
- 对 Ozon 来源商品,进入或读取编辑器时会将 `common.categories` 的规范化层级路径与活动缓存节点精确匹配;仅当 profile 未保存类目时写入匹配项并标记 `source_capture`。已有手工选择或此前保存的类目永远不被复采覆盖。API 集成测试覆盖回填和保护规则。
|
- 对 Ozon 来源商品,进入或读取编辑器时会将 `common.categories` 的规范化层级路径与活动缓存节点精确匹配;仅当 profile 未保存类目时写入匹配项并标记 `source_capture`。已有手工选择或此前保存的类目永远不被复采覆盖。API 集成测试覆盖回填和保护规则。
|
||||||
- 已新增 API 集成测试覆盖来源回填和保护规则;仍需在有效 Ozon 门店、真实 Ozon 样本与浏览器页面上执行最终回归。本地 Docker 基础设施未启动时,该集成测试无法实际执行。
|
- 已新增 API 集成测试覆盖来源回填和保护规则;仍需在有效 Ozon 门店、真实 Ozon 样本与浏览器页面上执行最终回归。本地 Docker 基础设施未启动时,该集成测试无法实际执行。
|
||||||
@@ -275,6 +276,93 @@ M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目
|
|||||||
|
|
||||||
下一步:在真实采集来源中回归首次按需物化、Worker 下载失败重试与已选图片保存;然后实施定价确认。来源域限流、429/503 延迟重试和跨候选 URL 下载去重属于 Worker 下载器的后续可靠性加固,尚未在本 M3.6 变更中完成。
|
下一步:在真实采集来源中回归首次按需物化、Worker 下载失败重试与已选图片保存;然后实施定价确认。来源域限流、429/503 延迟重试和跨候选 URL 下载去重属于 Worker 下载器的后续可靠性加固,尚未在本 M3.6 变更中完成。
|
||||||
|
|
||||||
|
### 1.32 开发进度(2026-09-18):Ozon 动态属性与 SKU 表单 P0
|
||||||
|
|
||||||
|
- 编辑器选择类目后立即加载属性 schema。`is_required=true` 的商品级属性始终展示在「产品属性」,`is_aspect=true` 的必填属性始终展示在对应 SKU 卡片;必填状态仅使用 Label 前的红色 `*`,选填属性仍可折叠。
|
||||||
|
- 动态属性控件已拆为独立组件,统一处理字典、布尔、数值、文本和多值字段;商品级与 SKU 级属性只负责各自的分区编排。主要信息继续保留类目、标题、型号、品牌和默认包装规格,SKU 展开区可覆盖包装及商品规格。
|
||||||
|
- 草稿保存不会因 Ozon 必填属性或包装规格为空而失败;已提交的非空属性仍由 API 校验类型、数量、字典归属、类目归属及 `is_aspect` 对应的商品/SKU scope。
|
||||||
|
- 新增 `GET /api/product-platform-profiles/{profile_id}/validation`,在独立校验模块中返回商品级问题和逐 SKU 问题。已导入 SKU 标为 `not_publishable_again`;必填通用属性和必填变体属性都以当前门店类目 schema 为准。
|
||||||
|
- 类目 schema 新增 `editor_field` 语义绑定,严格按 Ozon 属性 ID 识别品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254` 和简介/产品描述 `4191`。这些字段由编辑器已有控件承载,动态属性区不再重复展示或预取字典;名称和翻译文本不参与判断。
|
||||||
|
- Contracts、API 与 Web TypeScript 检查、Web production build 和 `git diff --check` 已通过。API 集成测试已补充通用/逐 SKU 必填缺失和 scope 伪造场景;本轮环境无法连接 PostgreSQL/Redis,因此未实际跑通该集成测试,且未按约定启动 API 或 Worker。
|
||||||
|
|
||||||
|
后续 P1/P2 仍包括:新 SKU 通过 `client_key` 在首次保存时关联动态属性、SKU 级条形码、结构化字典值,以及首次发布任务复用 validation 并回填 Ozon 错误。详见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](../product/ozon-dynamic-attributes-and-sku-form-plan.md)。
|
||||||
|
|
||||||
|
### 1.33 开发进度与交接(2026-09-18):平台级商品参考目录、系统后台与同步维护
|
||||||
|
|
||||||
|
#### 1.33.1 已冻结的数据归属
|
||||||
|
|
||||||
|
- 类目/商品类型树、类型属性定义和字典候选值归属 `platform_id`,同一平台的全部门店共用,不再按门店复制。Ozon 与未来 Wildberries 等平台分别使用自己的适配器和外部 ID 语义;本阶段**不创建、不读取门店差异层**。
|
||||||
|
- 门店连接、卖家凭据、商品/SKU 已填属性、发布状态和错误仍是工作空间业务数据。平台参考目录不保存客户商品、价格、库存或发布事实。
|
||||||
|
- 这项平台级能力已经独立落地,但商品编辑器仍使用 `022_store_catalog_cache.sql` 的门店级类目 UUID、属性和字典 API。不得直接删除旧表或把新旧 UUID 混用;编辑器迁移必须通过 Ozon 外部 ID 做双读/回填后再切换。
|
||||||
|
|
||||||
|
#### 1.33.2 类目树、属性和字典的标识规则
|
||||||
|
|
||||||
|
| 参考数据 | Atlas 表与唯一作用域 | Ozon 字段及处理规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| 类目/类型树 | `platform_product_categories`;`platform_id + external_category_id + external_type_id` | `external_category_id = description_category_id`,`external_type_id = type_id`。类型节点可能不重复返回类目 ID,映射器会继承最近父类目的 `description_category_id`。纯目录节点使用 `type_id = 0`,只用于树结构,不允许作为建品类型;可发布类型必须同时使用 `description_category_id + type_id`,不能按名称或单独 ID 匹配。`parent_id` 和 `path` 由树同步统一重建,Ozon `disabled` 或本次全量树中消失的节点标为非活动,不物理删除。 |
|
||||||
|
| 类型属性定义 | `platform_product_category_attributes`;平台 + 类型节点 + `platform_attribute_id + complex_id` | 保存 Ozon 属性 `id`、`attribute_complex_id/complex_id`、名称、说明、分组、类型、`is_required`、`is_aspect`、`is_collection`、`max_value_count`、`dictionary_id` 和 `category_dependent`。属性同步是某一 `description_category_id + type_id` 的完整定义;成功后本次未返回的旧属性标为非活动。 |
|
||||||
|
| 字典候选值 | `platform_catalog_dictionary_values`;`attribute_id + external_value_id` | 调用字典接口时必须同时传 `attribute_id + description_category_id + type_id`。即使 Ozon 返回相同 `dictionary_id`,也不能只按字典 ID 跨类型合并。当前每页 2000 项,单任务最多读取 5 页;不足 5 页且 `has_next=false` 才记录 `is_complete=true`,只有完整同步才停用本次未返回的旧值。 |
|
||||||
|
|
||||||
|
商品编辑器隐藏品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254`、简介/产品描述 `4191` 时,仍严格使用 Ozon 属性 ID;名称只用于展示。管理后台是参考数据检查工具,会展示完整属性定义,不应用编辑器的隐藏规则。
|
||||||
|
|
||||||
|
#### 1.33.3 数据库和安全边界
|
||||||
|
|
||||||
|
迁移 `024_platform_catalog_management.sql` 已新增或扩展以下对象:
|
||||||
|
|
||||||
|
- `system_admin_grants`:独立的全局系统管理员授权。工作空间 `owner/admin` 不等于系统管理员;`atlas_app` 被撤销该表的写权限,不能通过普通 HTTP 接口自助提权。
|
||||||
|
- `platform_catalog_sources`:每个平台一份专用参考源。Ozon `Api-Key` 使用现有 AES-256-GCM 边界加密,随机 nonce 与不可逆 HMAC fingerprint 分开保存;响应只返回脱敏 Client-Id,不回显密钥、密文或 fingerprint。
|
||||||
|
- `platform_catalog_dictionary_values`:替代旧平台字典表不足以表达类目/属性作用域的问题;旧表暂不删除。
|
||||||
|
- `platform_catalog_sync_runs`:平台级树、属性和字典任务事实,记录触发方式、状态、来源版本、尝试次数、结果数量和脱敏错误。
|
||||||
|
- `platform_catalog_sync_requests`:把系统管理员的 `Idempotency-Key` 持久映射到任务;即使手动请求复用了一个已存在的定时任务,重试也不会在原任务完成后创建第二个任务。
|
||||||
|
- `platform_catalog_outbox_events`:不带 `workspace_id` 的平台级 Transactional Outbox。平台任务不能借用租户 `operations/outbox_events`,避免伪造工作空间归属。
|
||||||
|
|
||||||
|
配置来源时,API 在同一事务更新密文和 `revision`,取消旧来源尚未完成的任务,并排队一次树同步。Worker 在领取和提交结果时都会比较 `source_revision`;来源已经更换的旧任务只能取消,不能用新凭据执行或覆盖新数据。API Key 只在 API 加密入口和 Worker 解密调用 Ozon 的短暂内存中出现,不进入浏览器回显、任务载荷或日志。
|
||||||
|
|
||||||
|
#### 1.33.4 系统管理员后台
|
||||||
|
|
||||||
|
- 独立后台路由为 `/system/platform-catalog`,布局不依赖工作台的门店选择器。普通登录用户可调用 `GET /api/system/access` 获取布尔值;所有管理读写 API 还会在服务端实时校验 `system_admin_grants`,前端隐藏菜单不是权限边界。
|
||||||
|
- 页面名称为「平台类目与属性」。可选择平台、查看参考源状态和类目树最近成功时间;选择可发布类型后查看属性 ID、必填、SKU 变体、数据类型及已缓存候选值数量;字典在 Drawer 中分页搜索 Atlas 缓存并显示完整/部分状态。
|
||||||
|
- 「配置同步来源」提交 Client-Id 与 Api-Key。首次配置或实际变更会自动排队树同步;重复提交同一凭据不增加来源版本或重复任务。
|
||||||
|
- 「同步类目树」「同步当前类型属性」「同步此字典」都只向 API 排队,不在 HTTP 请求中直接访问 Ozon。最近任务表每 5 秒轮询一次;任务由 `queued → running → succeeded/failed/cancelled` 转换,完成后页面重新读取相应缓存。
|
||||||
|
- 管理 API 契约、首位管理员初始化 SQL 和错误边界见 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md)。目前数据库已经存在一名有效系统管理员授权;此前反馈过用户菜单没有显示入口,数据库授权已确认正确,但刷新/当前 Web 与 API 进程版本下的菜单展示仍需浏览器回归。
|
||||||
|
|
||||||
|
#### 1.33.5 手动同步与自动同步的共同链路
|
||||||
|
|
||||||
|
```text
|
||||||
|
系统管理员页面 / 定时调度器
|
||||||
|
↓ 同一事务创建 run + platform outbox
|
||||||
|
PlatformCatalogDispatcher(每 2 秒,批量最多 20 条)
|
||||||
|
↓ 投递 atlas-dispatch / platform_catalog_sync.requested
|
||||||
|
BullMQ Worker(最多 5 次,5 秒起指数退避)
|
||||||
|
↓ 使用平台参考源凭据调用 Ozon
|
||||||
|
事务内 upsert 参考数据并完成 run
|
||||||
|
↓
|
||||||
|
管理页面轮询任务并重新读取 Atlas 缓存
|
||||||
|
```
|
||||||
|
|
||||||
|
- 手动接口要求 UUID `Idempotency-Key`。同一平台和 `scope_key` 只允许一个 `queued/running` 任务;已有活动任务时返回并映射到该任务。
|
||||||
|
- Worker 启动时立即执行一次调度,之后每 60 秒扫描一次。每个平台最多保留 6 个活动任务,优先级依次为树、属性、字典;树和属性成功后 24 小时内不重复,字典成功后 7 天内不重复,任一作用域创建任务后 1 小时内不会被定时器重复排队。
|
||||||
|
- Ozon 单次 HTTP 超时为 30 秒。401/403 转换为凭据错误;429、5xx、网络错误和响应结构异常交给 BullMQ 重试;其他确定性 4xx 直接记录请求被拒绝。最终失败只更新任务错误,不清空上次成功缓存。
|
||||||
|
- 树、某类型的属性和某属性的字典分别在单个数据库事务中发布。读取方在事务提交前仍看到旧缓存;P0 尚未实现不可变版本快照、内容哈希和 `next_refresh_at`,当前依赖事务 upsert、`synced_at` 与任务历史保证一致性和可追踪性。
|
||||||
|
|
||||||
|
#### 1.33.6 当前实际进度快照
|
||||||
|
|
||||||
|
- `024_platform_catalog_management.sql` 已于 2026-09-18 实际应用,系统管理员授权已写入并回读验证。
|
||||||
|
- Worker 曾因先于 `024` 迁移启动而无法初始化平台 Outbox Dispatcher;迁移后 Worker 已恢复。最新数据库事实显示 Outbox 已被分发、任务 `attempt_count=1`,证明平台 Dispatcher、BullMQ 消费和 Ozon 调用链路当前正在工作。
|
||||||
|
- 两次 Ozon 类目树任务均已成功,每次映射结果为 7933 条;当前活动节点为 7365 条,差额主要是纯目录/禁用节点。定时属性预热已经开始:检查时 18 个属性任务成功,当前活动属性定义 436 条,单类型返回约 27~50 个属性。
|
||||||
|
- 字典候选值当前仍为 0,尚未进入有效的初始预热阶段。现有调度固定优先全部属性,再处理字典;在数千个类型下可能导致字典长期排在属性之后,这是已知的 P0 调度公平性问题,不能把“属性同步已开始”误认为字典已经可供全部用户使用。
|
||||||
|
- 已通过 API/Worker/Web TypeScript 检查、Web production build、3 项系统管理员/参数单元测试、6 项 Ozon 树/属性/字典映射与分页测试及 `git diff --check`。由于功能落地后才执行迁移,尚未新增并运行覆盖真实 PostgreSQL 的平台目录端到端测试;现有成功任务提供了真实 Ozon 链路验证,但不能替代自动化数据库回归。
|
||||||
|
|
||||||
|
#### 1.33.7 明确未完成项
|
||||||
|
|
||||||
|
1. 调整定时调度公平性:树之后不能等待全部类型属性完成才开始字典;应在属性和字典之间设置配额/轮转,并优先近期使用或管理员选中的类型。
|
||||||
|
2. 为超过 10000 项的大字典实现可续传游标或分片同步周期。当前任务会正确标记 `is_complete=false`,但下一次仍从头读取,不能持续推进到第 10001 项以后。
|
||||||
|
3. 将商品编辑器由门店级缓存迁移到平台级目录:按 `description_category_id + type_id` 和属性 ID 回填,先双读/兼容保存,再切换外键和 API,最后才讨论清理 `022` 表。
|
||||||
|
4. 增加平台目录 PostgreSQL 集成测试,覆盖系统管理员隔离、凭据版本替换、手动幂等复用、定时去重、失败保留旧缓存、完整字典停用旧值及跨平台 ID 拒绝。
|
||||||
|
5. 在浏览器完成管理员菜单、直达路由、凭据弹窗、任务轮询、类目/属性/字典展示的回归,并确认 API/Worker 进程都使用迁移后的当前代码。
|
||||||
|
|
||||||
|
完整架构边界见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md),HTTP 契约见 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md)。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
@@ -293,6 +381,7 @@ M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目
|
|||||||
| 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 |
|
| 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 |
|
||||||
| 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 |
|
| 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 |
|
||||||
| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 |
|
| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 |
|
||||||
|
| 平台商品参考目录 | `platform_id` 下共享类目/类型树、属性定义和字典候选值;同平台门店共用,本阶段不维护门店差异层。定时同步、系统管理员页面和手动排队已实现;商品编辑器的门店级引用待迁移,见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md)。 |
|
||||||
|
|
||||||
## 3. Ozon 已核对结论
|
## 3. Ozon 已核对结论
|
||||||
|
|
||||||
@@ -335,13 +424,13 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
下一步建议按以下顺序推进:
|
下一步建议按以下顺序推进:
|
||||||
|
|
||||||
> 注意:下面的历史清单仍保留用于追溯;当前执行优先级以 [`1.27 当前进行中`](#127-当前进行中2026-09-10类目树交互改造尚未验收) 和本段补充为准。先完成类目启动同步、树形选择与 Ozon 来源类目自动回填,再进入真实采集回归和后续发布能力。
|
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台和同步基础链路已经实现;现有门店级商品编辑契约在完成兼容迁移前继续生效。
|
||||||
|
|
||||||
当前执行顺序:
|
当前执行顺序:
|
||||||
|
|
||||||
1. 配置专用 Ozon 类目缓存源,在真实 Ozon 样本和 8100 页面完成类目预热、树形选择及来源回填回归。
|
1. 完成平台目录首次预热并修正属性/字典调度公平性和大字典续传;补 PostgreSQL 集成测试与系统管理员浏览器回归。
|
||||||
2. 完成候选媒体选择与按需物化,并把已选素材纳入 SKU 编辑草稿。
|
2. 按 Ozon 外部 ID 建立新旧目录映射和双读,迁移商品编辑器到平台级类目、属性和候选值;在验证前保留门店级缓存与外键。
|
||||||
3. 完成定价、AI 任务与候选结果、首次发布和在线商品同步的剩余纵向闭环。
|
3. 在真实采集来源中继续回归候选媒体按需物化,然后完成定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
|
||||||
|
|
||||||
### 历史计划(仅供追溯)
|
### 历史计划(仅供追溯)
|
||||||
|
|
||||||
@@ -360,6 +449,7 @@ User → Workspace → Membership / Role
|
|||||||
- [API 统一约定](../api/conventions.md)
|
- [API 统一约定](../api/conventions.md)
|
||||||
- [基础模块数据模型与 DDL](../architecture/foundation-data-model.md)
|
- [基础模块数据模型与 DDL](../architecture/foundation-data-model.md)
|
||||||
- [商品领域数据模型与 DDL](../architecture/product-domain-data-model.md)
|
- [商品领域数据模型与 DDL](../architecture/product-domain-data-model.md)
|
||||||
|
- [平台级商品参考目录决策](../architecture/platform-product-reference-catalog.md)
|
||||||
- [幂等与 Transactional Outbox](../architecture/transactional-outbox-and-idempotency.md)
|
- [幂等与 Transactional Outbox](../architecture/transactional-outbox-and-idempotency.md)
|
||||||
- [数据库角色与 RLS](../architecture/database-security.md)
|
- [数据库角色与 RLS](../architecture/database-security.md)
|
||||||
- [商品采集图片与存储策略](../architecture/media-storage-strategy.md)
|
- [商品采集图片与存储策略](../architecture/media-storage-strategy.md)
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export type ProductAttributeSchema = {
|
|||||||
max_value_count: number;
|
max_value_count: number;
|
||||||
dictionary_id: string | null;
|
dictionary_id: string | null;
|
||||||
is_aspect: boolean;
|
is_aspect: boolean;
|
||||||
|
editor_field:
|
||||||
|
| 'category'
|
||||||
|
| 'brand'
|
||||||
|
| 'model'
|
||||||
|
| 'tags'
|
||||||
|
| 'rich_content'
|
||||||
|
| 'description'
|
||||||
|
| null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProductAttributeDictionaryValue = {
|
export type ProductAttributeDictionaryValue = {
|
||||||
@@ -72,6 +80,28 @@ export type ProductAttributeDictionaryValue = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductEditorValidationIssue = {
|
||||||
|
code: string;
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
platform_attribute_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductEditorValidation = {
|
||||||
|
ready: boolean;
|
||||||
|
profile: {
|
||||||
|
ready: boolean;
|
||||||
|
issues: ProductEditorValidationIssue[];
|
||||||
|
};
|
||||||
|
variants: Array<{
|
||||||
|
variant_id: string;
|
||||||
|
display_name: string | null;
|
||||||
|
status: 'ready' | 'blocked' | 'not_publishable_again';
|
||||||
|
ready: boolean;
|
||||||
|
issues: ProductEditorValidationIssue[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
||||||
* publishing DTOs intentionally stay out of this first editor slice.
|
* publishing DTOs intentionally stay out of this first editor slice.
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- 全局管理员授权独立于工作空间成员身份;日常应用角色只能读取授权。
|
||||||
|
CREATE TABLE system_admin_grants (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
granted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
REVOKE INSERT, UPDATE, DELETE ON system_admin_grants FROM atlas_app;
|
||||||
|
|
||||||
|
-- 每个平台只配置一组受控参考源凭据,不挂载到客户门店。
|
||||||
|
CREATE TABLE platform_catalog_sources (
|
||||||
|
platform_id UUID PRIMARY KEY REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
client_id TEXT NOT NULL CHECK (length(trim(client_id)) BETWEEN 1 AND 128),
|
||||||
|
secret_ciphertext BYTEA NOT NULL,
|
||||||
|
secret_nonce BYTEA NOT NULL,
|
||||||
|
secret_fingerprint TEXT NOT NULL,
|
||||||
|
encryption_key_version TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 复用既有平台类目和属性定义表;补齐管理页面及同步所需的属性元数据。
|
||||||
|
ALTER TABLE platform_product_category_attributes
|
||||||
|
ADD COLUMN description TEXT,
|
||||||
|
ADD COLUMN group_id TEXT,
|
||||||
|
ADD COLUMN group_name TEXT,
|
||||||
|
ADD COLUMN category_dependent BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD CONSTRAINT uq_platform_catalog_attribute_scope
|
||||||
|
UNIQUE (id, platform_id, platform_product_category_id);
|
||||||
|
|
||||||
|
-- 旧字典表仅以 dictionary_id 唯一,无法表达类目相关候选值;新表明确归属属性。
|
||||||
|
CREATE TABLE platform_catalog_dictionary_values (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
platform_product_category_id UUID NOT NULL,
|
||||||
|
attribute_id UUID NOT NULL,
|
||||||
|
dictionary_id TEXT NOT NULL,
|
||||||
|
external_value_id TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
info TEXT,
|
||||||
|
picture TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (attribute_id, platform_id, platform_product_category_id)
|
||||||
|
REFERENCES platform_product_category_attributes(id, platform_id, platform_product_category_id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
UNIQUE (attribute_id, external_value_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_catalog_dictionary_values_lookup
|
||||||
|
ON platform_catalog_dictionary_values (attribute_id, is_active, value, external_value_id);
|
||||||
|
|
||||||
|
-- 平台任务不能借用带 workspace_id 的租户 operation/outbox。
|
||||||
|
CREATE TABLE platform_catalog_sync_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL CHECK (scope IN ('tree', 'attributes', 'dictionary')),
|
||||||
|
scope_key TEXT NOT NULL,
|
||||||
|
category_id UUID REFERENCES platform_product_categories(id) ON DELETE RESTRICT,
|
||||||
|
attribute_id UUID REFERENCES platform_product_category_attributes(id) ON DELETE RESTRICT,
|
||||||
|
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('manual', 'scheduled', 'source_configured')),
|
||||||
|
status operation_status NOT NULL DEFAULT 'queued',
|
||||||
|
source_revision INTEGER NOT NULL CHECK (source_revision > 0),
|
||||||
|
requested_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
idempotency_key UUID,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||||
|
result JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
CHECK (
|
||||||
|
(scope = 'tree' AND category_id IS NULL AND attribute_id IS NULL)
|
||||||
|
OR (scope = 'attributes' AND category_id IS NOT NULL AND attribute_id IS NULL)
|
||||||
|
OR (scope = 'dictionary' AND category_id IS NOT NULL AND attribute_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CHECK (idempotency_key IS NULL OR requested_by_user_id IS NOT NULL),
|
||||||
|
UNIQUE (requested_by_user_id, idempotency_key)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX uq_platform_catalog_active_sync
|
||||||
|
ON platform_catalog_sync_runs (platform_id, scope_key)
|
||||||
|
WHERE status IN ('queued', 'running');
|
||||||
|
CREATE INDEX ix_platform_catalog_sync_recent
|
||||||
|
ON platform_catalog_sync_runs (platform_id, scope, scope_key, created_at DESC);
|
||||||
|
|
||||||
|
-- 一个手动请求可复用已有的定时任务;幂等键因此独立映射到任务,而不是只放在任务行。
|
||||||
|
CREATE TABLE platform_catalog_sync_requests (
|
||||||
|
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
idempotency_key UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
scope_key TEXT NOT NULL,
|
||||||
|
run_id UUID NOT NULL REFERENCES platform_catalog_sync_runs(id) ON DELETE RESTRICT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (requested_by_user_id, idempotency_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_catalog_sync_requests_run
|
||||||
|
ON platform_catalog_sync_requests (run_id);
|
||||||
|
|
||||||
|
CREATE TABLE platform_catalog_outbox_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
run_id UUID NOT NULL UNIQUE REFERENCES platform_catalog_sync_runs(id) ON DELETE CASCADE,
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
status outbox_event_status NOT NULL DEFAULT 'pending',
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
locked_by TEXT,
|
||||||
|
locked_until TIMESTAMPTZ,
|
||||||
|
dispatched_at TIMESTAMPTZ,
|
||||||
|
last_error_code TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (
|
||||||
|
(status = 'dispatching' AND locked_by IS NOT NULL AND locked_until IS NOT NULL)
|
||||||
|
OR (status <> 'dispatching' AND locked_by IS NULL AND locked_until IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_catalog_outbox_dispatch
|
||||||
|
ON platform_catalog_outbox_events (status, next_attempt_at, created_at)
|
||||||
|
WHERE status IN ('pending', 'dispatching');
|
||||||
Reference in New Issue
Block a user