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 { ExtensionModule } from './extension/extension.module.js';
|
||||
import { ProductEditorModule } from './product-editor/product-editor.module.js';
|
||||
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
|
||||
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@@ -51,6 +52,7 @@ class HealthController {
|
||||
CollectionModule,
|
||||
ExtensionModule,
|
||||
ProductEditorModule,
|
||||
PlatformCatalogAdminModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { encryptStoreSecret } from '../stores/store-secret.crypto.js';
|
||||
import type { SourceInput, SyncInput } from './platform-catalog-admin.models.js';
|
||||
|
||||
type RunRow = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
scope: string;
|
||||
scope_key: string;
|
||||
status: string;
|
||||
trigger_kind: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PlatformCatalogAdminCommands {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
public async configureSource(platformId: string, userId: string, input: SourceInput) {
|
||||
const encrypted = encryptStoreSecret(input.api_key, this.config.STORE_SECRET_ENCRYPTION_KEY);
|
||||
return this.database.transaction(async (tx) => {
|
||||
const platform = await tx.query<{ code: string }>(
|
||||
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
|
||||
[platformId],
|
||||
);
|
||||
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
|
||||
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
|
||||
}
|
||||
const existing = await tx.query<{
|
||||
client_id: string; secret_fingerprint: string; revision: number;
|
||||
}>(
|
||||
`SELECT client_id, secret_fingerprint, revision
|
||||
FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE`,
|
||||
[platformId],
|
||||
);
|
||||
const unchanged = existing.rows[0]?.client_id === input.client_id &&
|
||||
existing.rows[0]?.secret_fingerprint === encrypted.fingerprint;
|
||||
const revision = unchanged ? existing.rows[0].revision : (existing.rows[0]?.revision ?? 0) + 1;
|
||||
if (!unchanged) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_sources
|
||||
(platform_id, client_id, secret_ciphertext, secret_nonce,
|
||||
secret_fingerprint, encryption_key_version, revision, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (platform_id) DO UPDATE
|
||||
SET client_id = EXCLUDED.client_id,
|
||||
secret_ciphertext = EXCLUDED.secret_ciphertext,
|
||||
secret_nonce = EXCLUDED.secret_nonce,
|
||||
secret_fingerprint = EXCLUDED.secret_fingerprint,
|
||||
encryption_key_version = EXCLUDED.encryption_key_version,
|
||||
revision = EXCLUDED.revision,
|
||||
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||
updated_at = now()`,
|
||||
[platformId, input.client_id, encrypted.ciphertext, encrypted.nonce,
|
||||
encrypted.fingerprint, this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||
revision, userId],
|
||||
);
|
||||
// 旧来源的任务即使已进队列,也不能拿新凭据执行。
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_catalog_sync_runs
|
||||
SET status = 'cancelled', completed_at = now(),
|
||||
error_code = 'CATALOG_SOURCE_REPLACED', error_message = '同步来源已更新'
|
||||
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||
[platformId],
|
||||
);
|
||||
await enqueueRun(tx, {
|
||||
platformId, scope: 'tree', scopeKey: 'tree', sourceRevision: revision,
|
||||
trigger: 'source_configured', userId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
configured: true,
|
||||
client_id_hint: `••••${input.client_id.slice(-4)}`,
|
||||
revision,
|
||||
changed: !unchanged,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async sync(platformId: string, userId: string, idempotencyKey: string, input: SyncInput) {
|
||||
return this.database.transaction(async (tx) => {
|
||||
const platform = await tx.query<{ code: string }>(
|
||||
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
|
||||
[platformId],
|
||||
);
|
||||
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
|
||||
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
|
||||
}
|
||||
const source = await tx.query<{ revision: number }>(
|
||||
'SELECT revision FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE',
|
||||
[platformId],
|
||||
);
|
||||
if (!source.rows[0]) {
|
||||
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
|
||||
}
|
||||
const target = await this.resolveTarget(tx, platformId, input);
|
||||
const prior = await tx.query<RunRow>(
|
||||
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
|
||||
run.trigger_kind, run.created_at
|
||||
FROM atlas.platform_catalog_sync_requests request
|
||||
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
|
||||
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
|
||||
[userId, idempotencyKey],
|
||||
);
|
||||
if (prior.rows[0]) {
|
||||
if (prior.rows[0].platform_id !== platformId || prior.rows[0].scope_key !== target.scopeKey) {
|
||||
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他同步请求');
|
||||
}
|
||||
return presentRun(prior.rows[0]);
|
||||
}
|
||||
const run = await enqueueRun(tx, {
|
||||
platformId,
|
||||
scope: input.scope,
|
||||
scopeKey: target.scopeKey,
|
||||
categoryId: target.categoryId,
|
||||
attributeId: target.attributeId,
|
||||
sourceRevision: source.rows[0].revision,
|
||||
trigger: 'manual',
|
||||
userId,
|
||||
idempotencyKey,
|
||||
});
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_sync_requests
|
||||
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[userId, idempotencyKey, platformId, target.scopeKey, run.id],
|
||||
);
|
||||
return presentRun(run);
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveTarget(tx: DatabaseTransaction, platformId: string, input: SyncInput) {
|
||||
if (input.scope === 'tree') return { scopeKey: 'tree' };
|
||||
if (input.scope === 'attributes') {
|
||||
const category = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.platform_product_categories
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active
|
||||
AND external_type_id IS NOT NULL AND external_type_id <> '0'`,
|
||||
[input.category_id, platformId],
|
||||
);
|
||||
if (!category.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '商品类型不存在');
|
||||
return { scopeKey: `attributes:${input.category_id}`, categoryId: input.category_id };
|
||||
}
|
||||
const attribute = await tx.query<{ platform_product_category_id: string }>(
|
||||
`SELECT platform_product_category_id
|
||||
FROM atlas.platform_product_category_attributes
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active AND dictionary_id IS NOT NULL`,
|
||||
[input.attribute_id, platformId],
|
||||
);
|
||||
if (!attribute.rows[0]) {
|
||||
throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '字典属性不存在');
|
||||
}
|
||||
return {
|
||||
scopeKey: `dictionary:${input.attribute_id}`,
|
||||
categoryId: attribute.rows[0].platform_product_category_id,
|
||||
attributeId: input.attribute_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type EnqueueInput = {
|
||||
platformId: string;
|
||||
scope: 'tree' | 'attributes' | 'dictionary';
|
||||
scopeKey: string;
|
||||
categoryId?: string;
|
||||
attributeId?: string;
|
||||
sourceRevision: number;
|
||||
trigger: 'manual' | 'scheduled' | 'source_configured';
|
||||
userId?: string;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
/** 同一事务写入持久任务和平台 Outbox;活跃作用域复用已有任务。 */
|
||||
export async function enqueueRun(tx: DatabaseTransaction, input: EnqueueInput): Promise<RunRow> {
|
||||
const active = await tx.query<RunRow>(
|
||||
`SELECT id, platform_id, scope, scope_key, status, trigger_kind, created_at
|
||||
FROM atlas.platform_catalog_sync_runs
|
||||
WHERE platform_id = $1 AND scope_key = $2 AND status IN ('queued', 'running')
|
||||
LIMIT 1`,
|
||||
[input.platformId, input.scopeKey],
|
||||
);
|
||||
if (active.rows[0]) return active.rows[0];
|
||||
const inserted = await tx.query<RunRow>(
|
||||
`INSERT INTO atlas.platform_catalog_sync_runs
|
||||
(platform_id, scope, scope_key, category_id, attribute_id, trigger_kind,
|
||||
source_revision, requested_by_user_id, idempotency_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, platform_id, scope, scope_key, status, trigger_kind, created_at`,
|
||||
[input.platformId, input.scope, input.scopeKey, input.categoryId ?? null,
|
||||
input.attributeId ?? null, input.trigger, input.sourceRevision,
|
||||
input.userId ?? null, input.idempotencyKey ?? null],
|
||||
);
|
||||
const run = inserted.rows[0];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id) VALUES ($1, $2)`,
|
||||
[run.id, input.platformId],
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
function presentRun(run: RunRow) {
|
||||
return {
|
||||
id: run.id,
|
||||
platform_id: run.platform_id,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
trigger: run.trigger_kind,
|
||||
created_at: run.created_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Body, Controller, Get, Headers, Inject, Param, Post, Put, Query,
|
||||
Req, UseGuards, HttpCode,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
|
||||
import {
|
||||
parseInput, parseListLimit, parseUuid, sourceInputSchema, syncInputSchema,
|
||||
} from './platform-catalog-admin.models.js';
|
||||
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
||||
import { hasSystemAdminGrant, SystemAdminGuard } from './system-admin.guard.js';
|
||||
|
||||
@ApiTags('System')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('system')
|
||||
export class SystemAccessController {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
@Get('access')
|
||||
public async access(@Req() request: AuthenticatedRequest) {
|
||||
return { data: { is_system_admin: await hasSystemAdminGrant(this.database, request.auth.userId) } };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('System platform catalog')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(SystemAdminGuard)
|
||||
@Controller('system/platform-catalog')
|
||||
export class PlatformCatalogAdminController {
|
||||
public constructor(
|
||||
@Inject(PlatformCatalogAdminReads) private readonly reads: PlatformCatalogAdminReads,
|
||||
@Inject(PlatformCatalogAdminCommands) private readonly commands: PlatformCatalogAdminCommands,
|
||||
) {}
|
||||
|
||||
@Get('platforms')
|
||||
public async platforms() {
|
||||
return { data: await this.reads.platforms() };
|
||||
}
|
||||
|
||||
@Put('platforms/:platformId/source')
|
||||
public async configureSource(
|
||||
@Param('platformId') platformId: string,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
return {
|
||||
data: await this.commands.configureSource(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
request.auth.userId,
|
||||
parseInput(sourceInputSchema, body),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/categories')
|
||||
public async categories(@Param('platformId') platformId: string) {
|
||||
return { data: await this.reads.categories(parseUuid(platformId, 'platform_id')) };
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/categories/:categoryId/attributes')
|
||||
public async attributes(
|
||||
@Param('platformId') platformId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
) {
|
||||
return {
|
||||
data: await this.reads.attributes(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
parseUuid(categoryId, 'category_id'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/attributes/:attributeId/values')
|
||||
public async values(
|
||||
@Param('platformId') platformId: string,
|
||||
@Param('attributeId') attributeId: string,
|
||||
@Query('q') keyword: string | undefined,
|
||||
@Query('page') pageValue: string | undefined,
|
||||
) {
|
||||
const page = pageValue === undefined ? 1 : Number(pageValue);
|
||||
if (!Number.isInteger(page) || page < 1 || page > 100000) {
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', 'page 必须为正整数');
|
||||
}
|
||||
if (keyword && keyword.length > 120) {
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '搜索词不能超过 120 字符');
|
||||
}
|
||||
return this.reads.values(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
parseUuid(attributeId, 'attribute_id'),
|
||||
keyword,
|
||||
page,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platforms/:platformId/syncs')
|
||||
@HttpCode(202)
|
||||
public async sync(
|
||||
@Param('platformId') platformId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
if (!idempotencyKey) {
|
||||
throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请提供 Idempotency-Key');
|
||||
}
|
||||
return {
|
||||
data: await this.commands.sync(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
request.auth.userId,
|
||||
parseUuid(idempotencyKey, 'Idempotency-Key'),
|
||||
parseInput(syncInputSchema, body),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/syncs')
|
||||
public async syncs(
|
||||
@Param('platformId') platformId: string,
|
||||
@Query('limit') limit: string | undefined,
|
||||
) {
|
||||
return {
|
||||
data: await this.reads.runs(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
parseListLimit(limit),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
|
||||
export const sourceInputSchema = z.object({
|
||||
client_id: z.string().trim().min(1).max(128),
|
||||
api_key: z.string().trim().min(1).max(2048),
|
||||
}).strict();
|
||||
|
||||
export const syncInputSchema = z.discriminatedUnion('scope', [
|
||||
z.object({ scope: z.literal('tree') }).strict(),
|
||||
z.object({ scope: z.literal('attributes'), category_id: z.uuid() }).strict(),
|
||||
z.object({ scope: z.literal('dictionary'), attribute_id: z.uuid() }).strict(),
|
||||
]);
|
||||
|
||||
export type SyncInput = z.infer<typeof syncInputSchema>;
|
||||
export type SourceInput = z.infer<typeof sourceInputSchema>;
|
||||
|
||||
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
|
||||
const parsed = schema.safeParse(input);
|
||||
if (parsed.success) return parsed.data;
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不合法');
|
||||
}
|
||||
|
||||
export function parseUuid(value: string, field: string): string {
|
||||
if (z.uuid().safeParse(value).success) return value;
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', `${field} 不是有效 UUID`);
|
||||
}
|
||||
|
||||
export function parseListLimit(value: unknown, defaultValue = 20): number {
|
||||
if (value === undefined) return defaultValue;
|
||||
const count = Number(value);
|
||||
if (Number.isInteger(count) && count >= 1 && count <= 50) return count;
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', 'limit 必须在 1 到 50 之间');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
PlatformCatalogAdminController, SystemAccessController,
|
||||
} from './platform-catalog-admin.controller.js';
|
||||
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
|
||||
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
||||
import { SystemAdminGuard } from './system-admin.guard.js';
|
||||
|
||||
@Module({
|
||||
controllers: [SystemAccessController, PlatformCatalogAdminController],
|
||||
providers: [SystemAdminGuard, PlatformCatalogAdminCommands, PlatformCatalogAdminReads],
|
||||
})
|
||||
export class PlatformCatalogAdminModule {}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformCatalogAdminReads {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
public async platforms() {
|
||||
const rows = await this.database.query<{
|
||||
id: string; code: string; name: string; status: string;
|
||||
client_id: string | null; source_updated_at: Date | null;
|
||||
category_count: string; last_tree_sync_at: Date | null; last_error: string | null;
|
||||
}>(
|
||||
`SELECT p.id, p.code, p.name, p.status,
|
||||
source.client_id, source.updated_at AS source_updated_at,
|
||||
(SELECT count(*) FROM atlas.platform_product_categories category
|
||||
WHERE category.platform_id = p.id AND category.is_active)::text AS category_count,
|
||||
(SELECT max(completed_at) FROM atlas.platform_catalog_sync_runs run
|
||||
WHERE run.platform_id = p.id AND run.scope = 'tree' AND run.status = 'succeeded') AS last_tree_sync_at,
|
||||
(SELECT CASE WHEN run.status = 'failed' THEN run.error_message ELSE NULL END
|
||||
FROM atlas.platform_catalog_sync_runs run
|
||||
WHERE run.platform_id = p.id AND run.scope = 'tree'
|
||||
ORDER BY run.created_at DESC LIMIT 1) AS last_error
|
||||
FROM atlas.platforms p
|
||||
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
|
||||
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||
);
|
||||
return rows.rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
source: {
|
||||
configured: row.client_id !== null,
|
||||
client_id_hint: row.client_id ? `••••${row.client_id.slice(-4)}` : null,
|
||||
updated_at: row.source_updated_at,
|
||||
},
|
||||
summary: {
|
||||
category_count: Number(row.category_count),
|
||||
last_tree_sync_at: row.last_tree_sync_at,
|
||||
last_error: row.last_error,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
public async categories(platformId: string) {
|
||||
await this.requirePlatform(platformId);
|
||||
const result = await this.database.query(
|
||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path,
|
||||
is_active, synced_at
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1 ORDER BY path, id LIMIT 20000`,
|
||||
[platformId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
public async attributes(platformId: string, categoryId: string) {
|
||||
await this.requireCategory(platformId, categoryId);
|
||||
const result = await this.database.query(
|
||||
`SELECT attribute.id, attribute.platform_attribute_id, attribute.complex_id,
|
||||
attribute.name, attribute.description, attribute.group_id, attribute.group_name,
|
||||
attribute.value_type, attribute.is_required, attribute.is_aspect,
|
||||
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
|
||||
attribute.category_dependent, attribute.is_active, attribute.synced_at,
|
||||
(SELECT count(*)::int FROM atlas.platform_catalog_dictionary_values value
|
||||
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
|
||||
FROM atlas.platform_product_category_attributes attribute
|
||||
WHERE attribute.platform_id = $1 AND attribute.platform_product_category_id = $2
|
||||
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
|
||||
attribute.name, attribute.platform_attribute_id`,
|
||||
[platformId, categoryId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
public async values(
|
||||
platformId: string,
|
||||
attributeId: string,
|
||||
keyword: string | undefined,
|
||||
page: number,
|
||||
) {
|
||||
const attribute = await this.database.query<{ dictionary_id: string | null }>(
|
||||
`SELECT dictionary_id FROM atlas.platform_product_category_attributes
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||
[attributeId, platformId],
|
||||
);
|
||||
if (!attribute.rows[0]) throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '属性不存在');
|
||||
if (!attribute.rows[0].dictionary_id) {
|
||||
throw new ApiError(422, 'ATTRIBUTE_DICTIONARY_UNAVAILABLE', '该属性没有字典候选值');
|
||||
}
|
||||
const q = keyword?.trim() ?? '';
|
||||
const [items, total, lastRun] = await Promise.all([
|
||||
this.database.query(
|
||||
`SELECT id, external_value_id, value, info, picture
|
||||
FROM atlas.platform_catalog_dictionary_values
|
||||
WHERE attribute_id = $1 AND is_active
|
||||
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)
|
||||
ORDER BY value, external_value_id LIMIT 100 OFFSET $3`,
|
||||
[attributeId, q, (page - 1) * 100],
|
||||
),
|
||||
this.database.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM atlas.platform_catalog_dictionary_values
|
||||
WHERE attribute_id = $1 AND is_active
|
||||
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)`,
|
||||
[attributeId, q],
|
||||
),
|
||||
this.database.query<{ is_complete: boolean }>(
|
||||
`SELECT COALESCE((result->>'is_complete')::boolean, false) AS is_complete
|
||||
FROM atlas.platform_catalog_sync_runs
|
||||
WHERE platform_id = $1 AND scope_key = $2 AND status = 'succeeded'
|
||||
ORDER BY completed_at DESC LIMIT 1`,
|
||||
[platformId, `dictionary:${attributeId}`],
|
||||
),
|
||||
]);
|
||||
return {
|
||||
data: items.rows,
|
||||
meta: {
|
||||
page,
|
||||
page_size: 100,
|
||||
total: Number(total.rows[0]?.count ?? 0),
|
||||
is_complete: lastRun.rows[0]?.is_complete ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async runs(platformId: string, limit: number) {
|
||||
await this.requirePlatform(platformId);
|
||||
const result = await this.database.query(
|
||||
`SELECT id, platform_id, scope, scope_key, category_id, attribute_id,
|
||||
trigger_kind AS trigger, status, result, error_code, error_message,
|
||||
attempt_count, created_at, started_at, completed_at
|
||||
FROM atlas.platform_catalog_sync_runs
|
||||
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||
[platformId, limit],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
public async requirePlatform(platformId: string) {
|
||||
const result = await this.database.query<{ code: string }>(
|
||||
'SELECT code FROM atlas.platforms WHERE id = $1',
|
||||
[platformId],
|
||||
);
|
||||
if (!result.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
public async requireCategory(platformId: string, categoryId: string) {
|
||||
const result = await this.database.query(
|
||||
`SELECT 1 FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2`,
|
||||
[categoryId, platformId],
|
||||
);
|
||||
if (!result.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '类目不属于当前平台');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class SystemAdminGuard implements CanActivate {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
if (await hasSystemAdminGrant(this.database, request.auth.userId)) return true;
|
||||
throw new ApiError(403, 'SYSTEM_ADMIN_REQUIRED', '需要系统管理员权限');
|
||||
}
|
||||
}
|
||||
|
||||
/** 授权实时查库;撤销后旧 JWT 不能继续使用后台接口。 */
|
||||
export async function hasSystemAdminGrant(database: Database, userId: string): Promise<boolean> {
|
||||
const result = await database.query(
|
||||
`SELECT 1 FROM atlas.system_admin_grants grant_row
|
||||
JOIN atlas.users user_row ON user_row.id = grant_row.user_id
|
||||
WHERE grant_row.user_id = $1 AND grant_row.revoked_at IS NULL
|
||||
AND user_row.status = 'active'`,
|
||||
[userId],
|
||||
);
|
||||
return !!result.rowCount;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ProductAttributeSchema } from '@atlas/contracts';
|
||||
|
||||
type AttributeDefinition = Omit<ProductAttributeSchema, 'editor_field'>;
|
||||
type EditorField = Exclude<ProductAttributeSchema['editor_field'], null>;
|
||||
|
||||
/** Ozon 固定属性 ID 到编辑器内建字段的唯一映射,不使用任何名称或翻译文本判断。 */
|
||||
const editorFieldByAttributeId: Readonly<Record<string, EditorField>> = {
|
||||
'85': 'brand',
|
||||
'9048': 'model',
|
||||
'8229': 'category',
|
||||
'23171': 'tags',
|
||||
'11254': 'rich_content',
|
||||
'4191': 'description',
|
||||
};
|
||||
|
||||
/** 仅依据 Ozon 属性 ID 将类目属性绑定到编辑器内建字段。 */
|
||||
export function editorFieldForAttributeId(
|
||||
platformAttributeId: string,
|
||||
): ProductAttributeSchema['editor_field'] {
|
||||
return editorFieldByAttributeId[platformAttributeId] ?? null;
|
||||
}
|
||||
|
||||
/** 为数据库中的原始类目属性补充前端和校验共用的内建字段绑定。 */
|
||||
export function bindEditorFields(
|
||||
definitions: AttributeDefinition[],
|
||||
): ProductAttributeSchema[] {
|
||||
return definitions.map((definition) => ({
|
||||
...definition,
|
||||
editor_field: editorFieldForAttributeId(
|
||||
definition.platform_attribute_id,
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -21,6 +21,26 @@ type CatalogSyncResult = {
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
type DictionaryCursor = { value: string; value_id: string; query: string };
|
||||
|
||||
/** 游标仅记录排序位置;类目、字典及搜索条件仍由当前请求的权限校验约束。 */
|
||||
function parseDictionaryCursor(cursor: string | undefined): DictionaryCursor | null {
|
||||
if (!cursor) return null;
|
||||
if (cursor.length > 2_000) throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
if (
|
||||
parsed && typeof parsed === 'object' &&
|
||||
typeof (parsed as DictionaryCursor).value === 'string' &&
|
||||
typeof (parsed as DictionaryCursor).value_id === 'string' &&
|
||||
typeof (parsed as DictionaryCursor).query === 'string'
|
||||
) return parsed as DictionaryCursor;
|
||||
} catch {
|
||||
// 无效游标统一返回参数错误。
|
||||
}
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
|
||||
}
|
||||
|
||||
type CatalogAccess = {
|
||||
requireProfileForPlatform: (
|
||||
transaction: DatabaseTransaction,
|
||||
@@ -155,9 +175,11 @@ export class ProductEditorCatalogOperations {
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
search: string | undefined,
|
||||
cursor: string | undefined,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
const after = parseDictionaryCursor(cursor);
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
@@ -202,6 +224,8 @@ export class ProductEditorCatalogOperations {
|
||||
'字典不属于当前类目的属性',
|
||||
);
|
||||
const needle = search?.trim() ?? '';
|
||||
if (after && after.query !== needle)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标与搜索条件不匹配');
|
||||
const values = await transaction.query<{
|
||||
value_id: string;
|
||||
value: string;
|
||||
@@ -211,11 +235,20 @@ export class ProductEditorCatalogOperations {
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3 AND is_active
|
||||
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
||||
AND ($5::text IS NULL OR (value, external_value_id) > ($5, $6))
|
||||
ORDER BY value, external_value_id
|
||||
LIMIT 100`,
|
||||
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
|
||||
LIMIT 201`,
|
||||
[selectedStoreId, selectedCategoryId, dictionaryId, needle,
|
||||
after?.value ?? null, after?.value_id ?? null],
|
||||
);
|
||||
return { items: values.rows, next_cursor: null };
|
||||
const items = values.rows.slice(0, 200);
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
items,
|
||||
next_cursor: values.rows.length > 200 && last
|
||||
? Buffer.from(JSON.stringify({ ...last, query: needle })).toString('base64url')
|
||||
: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -241,7 +274,7 @@ export class ProductEditorCatalogOperations {
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建带关键词限制的属性字典同步任务。 */
|
||||
/** 创建首批字典值或关键词搜索同步任务。 */
|
||||
async requestAttributeDictionarySync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -252,7 +285,7 @@ export class ProductEditorCatalogOperations {
|
||||
dictionaryId: string,
|
||||
query: string,
|
||||
) {
|
||||
if (query.trim().length < 2)
|
||||
if (query.trim().length === 1)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'DICTIONARY_SEARCH_REQUIRED',
|
||||
|
||||
@@ -108,6 +108,22 @@ export class ProductEditorController {
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回首次上架前的服务端发布就绪校验。 */
|
||||
@Get('product-platform-profiles/:profileId/validation')
|
||||
async validation(
|
||||
@Param('profileId') profileId: string,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.editor.validation(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 幂等地创建所选类目属性定义的刷新任务。 */
|
||||
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||
async attributeSchemaSync(
|
||||
@@ -139,6 +155,7 @@ export class ProductEditorController {
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@@ -153,6 +170,7 @@ export class ProductEditorController {
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
dictionaryId,
|
||||
query,
|
||||
cursor,
|
||||
optionalUuid('category_id', categoryId),
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ProductRow = {
|
||||
/** 商品在一个发布平台下的编辑配置行。 */
|
||||
export type ProfileRow = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
revision: number;
|
||||
platform_id: string;
|
||||
market_code: string | null;
|
||||
@@ -57,7 +58,7 @@ export type OperationRow = {
|
||||
};
|
||||
|
||||
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
|
||||
export const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
|
||||
export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code,
|
||||
p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
|
||||
p.platform_type_id, p.category_source, category.name AS category_name,
|
||||
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
profileSelect,
|
||||
} from './product-editor.models.js';
|
||||
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
||||
import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
||||
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||
import {
|
||||
latestProductAiSourceMaterial,
|
||||
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
@Injectable()
|
||||
export class ProductEditorService {
|
||||
private readonly catalog: ProductEditorCatalogOperations;
|
||||
private readonly validationOperations: ProductEditorValidationOperations;
|
||||
|
||||
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
||||
public constructor(
|
||||
@@ -45,12 +48,24 @@ export class ProductEditorService {
|
||||
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
||||
requireProfileForPlatform: (...args) =>
|
||||
this.requireProfileForPlatform(...args),
|
||||
requireEditableStore: (...args) => this.requireEditableStore(...args),
|
||||
requireActiveCategory: (...args) => this.requireActiveCategory(...args),
|
||||
requireEditableStore: (...args) =>
|
||||
this.requireEditableStore(...args),
|
||||
requireActiveCategory: (...args) =>
|
||||
this.requireActiveCategory(...args),
|
||||
attributeSchemaForCategory: (...args) =>
|
||||
this.attributeSchemaForCategory(...args),
|
||||
requestCatalogSync: (input) => this.requestCatalogSync(input),
|
||||
});
|
||||
this.validationOperations = new ProductEditorValidationOperations(
|
||||
this.database,
|
||||
{
|
||||
requireProfileForPlatform: (...args) =>
|
||||
this.requireProfileForPlatform(...args),
|
||||
requireProduct: (...args) => this.requireProduct(...args),
|
||||
attributeSchemaForCategory: (...args) =>
|
||||
this.attributeSchemaForCategory(...args),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建或复用平台发布配置,并返回编辑器导航上下文。 */
|
||||
@@ -487,6 +502,19 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 按当前持久化草稿和 Ozon 类目 schema 计算首次上架就绪状态。 */
|
||||
async validation(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
) {
|
||||
return this.validationOperations.validation(
|
||||
workspaceId,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
}
|
||||
|
||||
/** 仅从店铺本地缓存查询当前类目的字典值。 */
|
||||
async attributeDictionary(
|
||||
workspaceId: string,
|
||||
@@ -494,6 +522,7 @@ export class ProductEditorService {
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
search: string | undefined,
|
||||
cursor: string | undefined,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
@@ -503,6 +532,7 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
dictionaryId,
|
||||
search,
|
||||
cursor,
|
||||
categoryId,
|
||||
storeId,
|
||||
);
|
||||
@@ -1002,7 +1032,10 @@ export class ProductEditorService {
|
||||
'SKU 发布记录已变化,请重新加载编辑器',
|
||||
);
|
||||
const listingByVariant = new Map(
|
||||
listings.rows.map((listing) => [listing.product_variant_id, listing]),
|
||||
listings.rows.map((listing) => [
|
||||
listing.product_variant_id,
|
||||
listing,
|
||||
]),
|
||||
);
|
||||
const assetIds = [
|
||||
...new Set(input.variants.flatMap((variant) => variant.assetIds)),
|
||||
@@ -1020,7 +1053,9 @@ export class ProductEditorService {
|
||||
[assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const assetById = new Map(assets.rows.map((asset) => [asset.id, asset]));
|
||||
const assetById = new Map(
|
||||
assets.rows.map((asset) => [asset.id, asset]),
|
||||
);
|
||||
const existing = await transaction.query<{
|
||||
product_variant_id: string;
|
||||
asset_id: string;
|
||||
@@ -1033,7 +1068,8 @@ export class ProductEditorService {
|
||||
);
|
||||
const existingByVariant = new Map<string, Set<string>>();
|
||||
for (const item of existing.rows) {
|
||||
const ids = existingByVariant.get(item.product_variant_id) ?? new Set();
|
||||
const ids =
|
||||
existingByVariant.get(item.product_variant_id) ?? new Set();
|
||||
ids.add(item.asset_id);
|
||||
existingByVariant.set(item.product_variant_id, ids);
|
||||
}
|
||||
@@ -1058,7 +1094,8 @@ export class ProductEditorService {
|
||||
: { rows: [] };
|
||||
const candidateVariants = new Map<string, Set<string | null>>();
|
||||
for (const candidate of candidates.rows) {
|
||||
const variants = candidateVariants.get(candidate.asset_id) ?? new Set();
|
||||
const variants =
|
||||
candidateVariants.get(candidate.asset_id) ?? new Set();
|
||||
variants.add(candidate.source_variant_id);
|
||||
candidateVariants.set(candidate.asset_id, variants);
|
||||
}
|
||||
@@ -1094,7 +1131,10 @@ export class ProductEditorService {
|
||||
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
||||
continue;
|
||||
const allowedFor = candidateVariants.get(assetId);
|
||||
if (!allowedFor?.has(null) && !allowedFor?.has(variant.variantId))
|
||||
if (
|
||||
!allowedFor?.has(null) &&
|
||||
!allowedFor?.has(variant.variantId)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||
@@ -1252,6 +1292,16 @@ export class ProductEditorService {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// 主要信息字段是唯一事实来源;兼容旧页面提交的重复属性,但不再持久化。
|
||||
if (definition.editor_field) continue;
|
||||
if (definition.is_aspect !== (attribute.scope === 'variant'))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'ATTRIBUTE_SCOPE_INVALID',
|
||||
definition.is_aspect
|
||||
? `${definition.name} 必须按 SKU 填写`
|
||||
: `${definition.name} 必须作为商品通用属性填写`,
|
||||
);
|
||||
if (attribute.scope === 'variant') {
|
||||
if (!attribute.variant_id)
|
||||
throw new ApiError(
|
||||
@@ -1441,7 +1491,7 @@ export class ProductEditorService {
|
||||
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
||||
[storeId, categoryId],
|
||||
);
|
||||
return result.rows;
|
||||
return bindEditorFields(result.rows);
|
||||
}
|
||||
|
||||
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
|
||||
@@ -1601,7 +1651,8 @@ export class ProductEditorService {
|
||||
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
||||
[product.collection_item_id],
|
||||
);
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return profile;
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON')
|
||||
return profile;
|
||||
|
||||
const category = await transaction.query<{
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
ProductAttributeSchema,
|
||||
ProductEditorValidation,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import type { ProductRow, ProfileRow } from './product-editor.models.js';
|
||||
|
||||
type ValidationAccess = {
|
||||
requireProfileForPlatform: (
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
) => Promise<ProfileRow>;
|
||||
requireProduct: (
|
||||
transaction: DatabaseTransaction,
|
||||
productId: string,
|
||||
) => Promise<ProductRow>;
|
||||
attributeSchemaForCategory: (
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
categoryId: string,
|
||||
) => Promise<ProductAttributeSchema[]>;
|
||||
};
|
||||
|
||||
type VariantValidationRow = {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
listing_status: string;
|
||||
offer_id: string | null;
|
||||
selling_price: string | null;
|
||||
package_length_mm: string | null;
|
||||
package_width_mm: string | null;
|
||||
package_height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
package_spec_status: string;
|
||||
media_count: string;
|
||||
};
|
||||
|
||||
type AttributeValidationRow = {
|
||||
scope: 'common' | 'variant';
|
||||
product_variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: unknown;
|
||||
confirmation_status: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算商品草稿的首次上架就绪状态。
|
||||
* 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。
|
||||
*/
|
||||
export class ProductEditorValidationOperations {
|
||||
/** 注入工作空间事务入口和编辑器统一访问边界。 */
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly access: ValidationAccess,
|
||||
) {}
|
||||
|
||||
/** 按当前持久化草稿和 Ozon 类目 schema 返回结构化校验结果。 */
|
||||
async validation(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
): Promise<ProductEditorValidation> {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const product = await this.access.requireProduct(
|
||||
transaction,
|
||||
profile.product_id,
|
||||
);
|
||||
const variants = await this.readVariants(
|
||||
transaction,
|
||||
profile.id,
|
||||
profile.product_id,
|
||||
);
|
||||
const attributes = await this.readAttributes(
|
||||
transaction,
|
||||
profile.id,
|
||||
);
|
||||
const schema = profile.store_product_category_id
|
||||
? await this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
profile.target_store_id,
|
||||
profile.store_product_category_id,
|
||||
)
|
||||
: [];
|
||||
|
||||
const profileIssues = this.profileIssues(
|
||||
product,
|
||||
profile,
|
||||
schema,
|
||||
attributes,
|
||||
);
|
||||
const variantResults = variants.map((variant) =>
|
||||
this.variantResult(variant, schema, attributes),
|
||||
);
|
||||
const publishableVariants = variantResults.filter(
|
||||
(variant) => variant.status !== 'not_publishable_again',
|
||||
);
|
||||
|
||||
return {
|
||||
ready:
|
||||
profileIssues.length === 0 &&
|
||||
publishableVariants.length > 0 &&
|
||||
publishableVariants.every((variant) => variant.ready),
|
||||
profile: {
|
||||
ready: profileIssues.length === 0,
|
||||
issues: profileIssues,
|
||||
},
|
||||
variants: variantResults,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 汇总商品级基础信息与通用类目属性问题。 */
|
||||
private profileIssues(
|
||||
product: ProductRow,
|
||||
profile: ProfileRow,
|
||||
schema: ProductAttributeSchema[],
|
||||
attributes: AttributeValidationRow[],
|
||||
): ProductEditorValidation['profile']['issues'] {
|
||||
const issues: ProductEditorValidation['profile']['issues'] = [];
|
||||
if (!product.source_title?.trim())
|
||||
issues.push({
|
||||
code: 'TITLE_REQUIRED',
|
||||
field: 'common.source_title',
|
||||
message: '请填写标题',
|
||||
});
|
||||
if (!product.model?.trim())
|
||||
issues.push({
|
||||
code: 'MODEL_REQUIRED',
|
||||
field: 'common.model',
|
||||
message: '请填写型号',
|
||||
});
|
||||
if (!product.brand?.trim())
|
||||
issues.push({
|
||||
code: 'BRAND_REQUIRED',
|
||||
field: 'common.brand',
|
||||
message: '请填写品牌',
|
||||
});
|
||||
if (!profile.store_product_category_id)
|
||||
issues.push({
|
||||
code: 'CATEGORY_REQUIRED',
|
||||
field: 'profile.store_product_category_id',
|
||||
message: '请选择产品类目',
|
||||
});
|
||||
if (profile.store_product_category_id && !schema.length)
|
||||
issues.push({
|
||||
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
|
||||
field: 'attributes',
|
||||
message: '当前类目的 Ozon 属性尚未同步',
|
||||
});
|
||||
|
||||
for (const definition of schema.filter(
|
||||
(item) => item.is_required && !item.is_aspect && !item.editor_field,
|
||||
)) {
|
||||
if (!hasConfirmedValue(attributes, 'common', null, definition))
|
||||
issues.push({
|
||||
code: 'OZON_ATTRIBUTE_REQUIRED',
|
||||
field: `attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
|
||||
message: `请填写${definition.name}`,
|
||||
platform_attribute_id: definition.platform_attribute_id,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** 计算单个 SKU 的发布资格与缺失项。 */
|
||||
private variantResult(
|
||||
variant: VariantValidationRow,
|
||||
schema: ProductAttributeSchema[],
|
||||
attributes: AttributeValidationRow[],
|
||||
): ProductEditorValidation['variants'][number] {
|
||||
if (variant.listing_status === 'imported')
|
||||
return {
|
||||
variant_id: variant.id,
|
||||
display_name: variant.display_name,
|
||||
status: 'not_publishable_again',
|
||||
ready: false,
|
||||
issues: [],
|
||||
};
|
||||
|
||||
const issues: ProductEditorValidation['variants'][number]['issues'] =
|
||||
[];
|
||||
if (!variant.offer_id?.trim())
|
||||
issues.push({
|
||||
code: 'OFFER_ID_REQUIRED',
|
||||
field: `variants.${variant.id}.offer_id`,
|
||||
message: '请填写卖家货号',
|
||||
});
|
||||
if (!variant.selling_price)
|
||||
issues.push({
|
||||
code: 'PRICE_REQUIRED',
|
||||
field: `variants.${variant.id}.selling_price`,
|
||||
message: '请先确认售价',
|
||||
});
|
||||
if (
|
||||
variant.package_spec_status !== 'confirmed' ||
|
||||
!variant.package_length_mm ||
|
||||
!variant.package_width_mm ||
|
||||
!variant.package_height_mm ||
|
||||
!variant.shipping_weight_g
|
||||
)
|
||||
issues.push({
|
||||
code: 'PACKAGE_SPEC_REQUIRED',
|
||||
field: `variants.${variant.id}.package_spec`,
|
||||
message: '请确认有效的包装重量和包装尺寸',
|
||||
});
|
||||
if (Number(variant.media_count) < 1)
|
||||
issues.push({
|
||||
code: 'IMAGE_REQUIRED',
|
||||
field: `variants.${variant.id}.media_asset_ids`,
|
||||
message: '请至少选择一张 SKU 图片',
|
||||
});
|
||||
|
||||
for (const definition of schema.filter(
|
||||
(item) => item.is_required && item.is_aspect && !item.editor_field,
|
||||
)) {
|
||||
if (
|
||||
!hasConfirmedValue(
|
||||
attributes,
|
||||
'variant',
|
||||
variant.id,
|
||||
definition,
|
||||
)
|
||||
)
|
||||
issues.push({
|
||||
code: 'OZON_ATTRIBUTE_REQUIRED',
|
||||
field: `variants.${variant.id}.attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
|
||||
message: `请填写${definition.name}`,
|
||||
platform_attribute_id: definition.platform_attribute_id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
variant_id: variant.id,
|
||||
display_name: variant.display_name,
|
||||
status: issues.length ? 'blocked' : 'ready',
|
||||
ready: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
/** 一次读取 SKU 的售价、有效包装规格和素材数量。 */
|
||||
private async readVariants(
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
productId: string,
|
||||
) {
|
||||
const variants = await transaction.query<VariantValidationRow>(
|
||||
`SELECT v.id, v.display_name, listing.status AS listing_status,
|
||||
listing.offer_id, listing.selling_price::text,
|
||||
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
|
||||
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
|
||||
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
|
||||
coalesce(v.shipping_weight_g, product.default_shipping_weight_g)::text AS shipping_weight_g,
|
||||
coalesce(v.package_spec_status, product.package_spec_status) AS package_spec_status,
|
||||
count(media.id)::text AS media_count
|
||||
FROM atlas.product_variants v
|
||||
JOIN atlas.products product ON product.id = v.product_id
|
||||
JOIN atlas.product_variant_platform_listings listing
|
||||
ON listing.product_variant_id = v.id AND listing.profile_id = $1
|
||||
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
|
||||
WHERE v.product_id = $2 AND v.deleted_at IS NULL
|
||||
GROUP BY v.id, v.display_name, listing.status, listing.offer_id,
|
||||
listing.selling_price, product.default_package_length_mm,
|
||||
product.default_package_width_mm, product.default_package_height_mm,
|
||||
product.default_shipping_weight_g, product.package_spec_status
|
||||
ORDER BY v.sort_order, v.id`,
|
||||
[profileId, productId],
|
||||
);
|
||||
return variants.rows;
|
||||
}
|
||||
|
||||
/** 读取当前发布配置下已经持久化的动态属性值。 */
|
||||
private async readAttributes(
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
) {
|
||||
const attributes = await transaction.query<AttributeValidationRow>(
|
||||
`SELECT scope, product_variant_id, platform_attribute_id, complex_id,
|
||||
values, confirmation_status
|
||||
FROM atlas.product_attributes WHERE profile_id = $1`,
|
||||
[profileId],
|
||||
);
|
||||
return attributes.rows;
|
||||
}
|
||||
}
|
||||
|
||||
/** 只有已确认且至少包含一个非空字符串的值才满足发布要求。 */
|
||||
function hasConfirmedValue(
|
||||
attributes: AttributeValidationRow[],
|
||||
scope: 'common' | 'variant',
|
||||
variantId: string | null,
|
||||
definition: ProductAttributeSchema,
|
||||
) {
|
||||
return attributes.some(
|
||||
(attribute) =>
|
||||
attribute.scope === scope &&
|
||||
attribute.product_variant_id === variantId &&
|
||||
attribute.platform_attribute_id ===
|
||||
definition.platform_attribute_id &&
|
||||
attribute.complex_id === definition.complex_id &&
|
||||
attribute.confirmation_status === 'confirmed' &&
|
||||
stringValues(attribute.values).some((value) => value.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function stringValues(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { describe, it } from 'vitest';
|
||||
import {
|
||||
parseInput,
|
||||
sourceInputSchema,
|
||||
syncInputSchema,
|
||||
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
|
||||
import { hasSystemAdminGrant } from '../src/platform-catalog-admin/system-admin.guard.js';
|
||||
|
||||
describe('平台目录系统管理员边界', () => {
|
||||
it('只有未撤销且用户有效的数据库授权才被视为系统管理员', async () => {
|
||||
const queries: Array<{ sql: string; parameters: unknown[] }> = [];
|
||||
const database = {
|
||||
query: async (sql: string, parameters: unknown[]) => {
|
||||
queries.push({ sql, parameters });
|
||||
return { rowCount: 1, rows: [{ '?column?': 1 }] };
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
assert.equal(await hasSystemAdminGrant(database, 'user-1'), true);
|
||||
assert.deepEqual(queries[0].parameters, ['user-1']);
|
||||
assert.match(queries[0].sql, /revoked_at IS NULL/);
|
||||
assert.match(queries[0].sql, /user_row\.status = 'active'/);
|
||||
});
|
||||
|
||||
it('同步请求不能混入其他作用域字段', () => {
|
||||
assert.throws(() => parseInput(syncInputSchema, {
|
||||
scope: 'tree',
|
||||
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
|
||||
}));
|
||||
assert.throws(() => parseInput(syncInputSchema, {
|
||||
scope: 'dictionary',
|
||||
attribute_id: 'not-a-uuid',
|
||||
}));
|
||||
});
|
||||
|
||||
it('同步来源仅接受 Client-Id 与 Api-Key', () => {
|
||||
assert.deepEqual(parseInput(sourceInputSchema, {
|
||||
client_id: ' 12345 ',
|
||||
api_key: ' secret ',
|
||||
}), { client_id: '12345', api_key: 'secret' });
|
||||
assert.throws(() => parseInput(sourceInputSchema, {
|
||||
client_id: '12345',
|
||||
api_key: 'secret',
|
||||
workspace_id: 'forbidden',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,14 @@ async function seedStoreCategory(
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[storeId, platformId, externalCategoryId, externalTypeId, name, path],
|
||||
[
|
||||
storeId,
|
||||
platformId,
|
||||
externalCategoryId,
|
||||
externalTypeId,
|
||||
name,
|
||||
path,
|
||||
],
|
||||
);
|
||||
return { id: category.rows[0].id, storeId };
|
||||
});
|
||||
@@ -115,9 +122,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -161,9 +169,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.media_assets WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
});
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
@@ -852,38 +861,64 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('M3.4 按当前类目返回动态属性,并将类目切换后的旧属性保留为未映射草稿', async () => {
|
||||
it('M3.4 返回去重语义的动态属性,并校验必填、scope 与类目切换', async () => {
|
||||
const dictionarySuffix = randomUUID();
|
||||
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
||||
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
||||
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
||||
const categoryA = await seedStoreCategory(`m34-a-${randomUUID()}`, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱');
|
||||
const categoryB = await seedStoreCategory(`m34-b-${randomUUID()}`, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包');
|
||||
const categoryA = await seedStoreCategory(
|
||||
`m34-a-${randomUUID()}`,
|
||||
'type-a',
|
||||
'M3.4 收纳箱',
|
||||
'家居 › 收纳 › M3.4 收纳箱',
|
||||
);
|
||||
const categoryB = await seedStoreCategory(
|
||||
`m34-b-${randomUUID()}`,
|
||||
'type-b',
|
||||
'M3.4 背包',
|
||||
'箱包 › 背包 › M3.4 背包',
|
||||
);
|
||||
seededCategoryIds.push(categoryA.id, categoryB.id);
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '85', '品牌', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '9048', '型号名称', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '8229', '类型', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '23171', '主题标签', 'text', false, true, 10, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '4191', '简介', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
));
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
|
||||
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId, colorDictionaryId],
|
||||
));
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
),
|
||||
);
|
||||
const categories = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
@@ -900,6 +935,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
},
|
||||
);
|
||||
const attributeVariantId =
|
||||
before.body.data.variants.find(
|
||||
(variant: any) => variant.listing.status !== 'imported',
|
||||
)?.id ?? before.body.data.variants[0].id;
|
||||
const variants = before.body.data.variants.map((variant: any) => ({
|
||||
id: variant.id,
|
||||
client_key: null,
|
||||
@@ -945,7 +984,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
{
|
||||
scope: 'variant',
|
||||
variant_id: variants[0].id,
|
||||
variant_id: attributeVariantId,
|
||||
platform_attribute_id: 'color',
|
||||
complex_id: '0',
|
||||
values: ['black'],
|
||||
@@ -953,6 +992,16 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
source: 'manual',
|
||||
confirmation_status: 'confirmed',
|
||||
},
|
||||
{
|
||||
scope: 'common',
|
||||
variant_id: null,
|
||||
platform_attribute_id: '85',
|
||||
complex_id: '0',
|
||||
values: ['重复品牌值'],
|
||||
language_code: null,
|
||||
source: 'manual',
|
||||
confirmation_status: 'confirmed',
|
||||
},
|
||||
],
|
||||
variants,
|
||||
};
|
||||
@@ -981,6 +1030,48 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
).is_aspect,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '85',
|
||||
).editor_field,
|
||||
'brand',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '9048',
|
||||
).editor_field,
|
||||
'model',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '8229',
|
||||
).editor_field,
|
||||
'category',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '23171',
|
||||
).editor_field,
|
||||
'tags',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '11254',
|
||||
).editor_field,
|
||||
'rich_content',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '4191',
|
||||
).editor_field,
|
||||
'description',
|
||||
);
|
||||
const dictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
@@ -988,19 +1079,174 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.deepEqual(dictionary.body.data.items, [
|
||||
{ value_id: 'cotton', value: '棉' },
|
||||
]);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id,
|
||||
dictionary_id, external_value_id, value)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||
'page-' || value::text, '测试选项' || lpad(value::text, 3, '0')
|
||||
FROM generate_series(1, 205) AS series(value)`,
|
||||
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId],
|
||||
),
|
||||
);
|
||||
const dictionaryPageOne = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(dictionaryPageOne.body.data.items.length, 200);
|
||||
assert.ok(dictionaryPageOne.body.data.next_cursor);
|
||||
const dictionaryPageTwo = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}&cursor=${encodeURIComponent(dictionaryPageOne.body.data.next_cursor)}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(dictionaryPageTwo.body.data.items.length, 6);
|
||||
assert.equal(dictionaryPageTwo.body.data.next_cursor, null);
|
||||
const invalidDictionaryCursor = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}&cursor=invalid`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(invalidDictionaryCursor.response.status, 400);
|
||||
const afterSave = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
{
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
afterSave.body.data.profile.category.id,
|
||||
categoryA.id,
|
||||
);
|
||||
assert.equal(afterSave.body.data.profile.category.id, categoryA.id);
|
||||
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
|
||||
assert.equal(afterSave.body.data.attributes.length, 2);
|
||||
|
||||
const validAttributes = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(validAttributes.response.status, 200);
|
||||
assert.ok(
|
||||
!validAttributes.body.data.profile.issues.some(
|
||||
(issue: { code: string }) =>
|
||||
issue.code === 'OZON_ATTRIBUTE_REQUIRED',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!validAttributes.body.data.variants
|
||||
.find(
|
||||
(variant: { variant_id: string }) =>
|
||||
variant.variant_id === attributeVariantId,
|
||||
)
|
||||
.issues.some(
|
||||
(issue: { code: string }) =>
|
||||
issue.code === 'OZON_ATTRIBUTE_REQUIRED',
|
||||
),
|
||||
);
|
||||
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`DELETE FROM atlas.product_attributes
|
||||
WHERE profile_id = $1 AND product_variant_id = $2
|
||||
AND platform_attribute_id = 'color'`,
|
||||
[first.profileId, attributeVariantId],
|
||||
),
|
||||
);
|
||||
const missingVariantAttribute = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
missingVariantAttribute.body.data.variants
|
||||
.find(
|
||||
(variant: { variant_id: string }) =>
|
||||
variant.variant_id === attributeVariantId,
|
||||
)
|
||||
.issues.some(
|
||||
(issue: { code: string; platform_attribute_id?: string }) =>
|
||||
issue.code === 'OZON_ATTRIBUTE_REQUIRED' &&
|
||||
issue.platform_attribute_id === 'color',
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.product_attributes
|
||||
(workspace_id, product_id, profile_id, product_variant_id, scope,
|
||||
platform_attribute_id, complex_id, values, source, confirmation_status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'variant',
|
||||
'color', '0', '["black"]'::jsonb, 'manual', 'confirmed')`,
|
||||
[first.productId, first.profileId, attributeVariantId],
|
||||
),
|
||||
);
|
||||
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`DELETE FROM atlas.product_attributes
|
||||
WHERE profile_id = $1 AND scope = 'common'
|
||||
AND platform_attribute_id = 'material'`,
|
||||
[first.profileId],
|
||||
),
|
||||
);
|
||||
const missingCommonAttribute = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
missingCommonAttribute.body.data.profile.issues.some(
|
||||
(issue: { code: string; platform_attribute_id?: string }) =>
|
||||
issue.code === 'OZON_ATTRIBUTE_REQUIRED' &&
|
||||
issue.platform_attribute_id === 'material',
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.product_attributes
|
||||
(workspace_id, product_id, profile_id, product_variant_id, scope,
|
||||
platform_attribute_id, complex_id, values, source, confirmation_status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, NULL, 'common',
|
||||
'material', '0', '["cotton"]'::jsonb, 'manual', 'confirmed')`,
|
||||
[first.productId, first.profileId],
|
||||
),
|
||||
);
|
||||
|
||||
const invalidScope = {
|
||||
...draft,
|
||||
ai_source_material: afterSave.body.data.source_material,
|
||||
ai_source_overridden_fields:
|
||||
afterSave.body.data.source_material_state.overridden_fields,
|
||||
attributes: afterSave.body.data.attributes.map(
|
||||
(attribute: any) => ({
|
||||
scope:
|
||||
attribute.platform_attribute_id === 'color'
|
||||
? 'common'
|
||||
: attribute.scope,
|
||||
variant_id:
|
||||
attribute.platform_attribute_id === 'color'
|
||||
? null
|
||||
: attribute.variant_id,
|
||||
platform_attribute_id: attribute.platform_attribute_id,
|
||||
complex_id: attribute.complex_id,
|
||||
values: attribute.values,
|
||||
language_code: attribute.language_code,
|
||||
source: attribute.source,
|
||||
confirmation_status: attribute.confirmation_status,
|
||||
}),
|
||||
),
|
||||
};
|
||||
const invalidScopeSave = await request(
|
||||
`/api/products/${first.productId}/editor-draft`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${afterSave.body.data.product.revision}:${afterSave.body.data.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(invalidScope),
|
||||
},
|
||||
);
|
||||
assert.equal(invalidScopeSave.response.status, 422);
|
||||
assert.equal(
|
||||
invalidScopeSave.body.error.code,
|
||||
'ATTRIBUTE_SCOPE_INVALID',
|
||||
);
|
||||
|
||||
const categoryChanged = {
|
||||
...draft,
|
||||
ai_source_material: afterSave.body.data.source_material,
|
||||
@@ -1059,8 +1305,18 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
|
||||
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
|
||||
const suffix = randomUUID();
|
||||
const captured = await seedStoreCategory(`m35-source-${suffix}`, 'source-type', 'M3.5 采集叶', 'M3.5 来源 › M3.5 采集叶');
|
||||
const manual = await seedStoreCategory(`m35-manual-${suffix}`, 'manual-type', 'M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶');
|
||||
const captured = await seedStoreCategory(
|
||||
`m35-source-${suffix}`,
|
||||
'source-type',
|
||||
'M3.5 采集叶',
|
||||
'M3.5 来源 › M3.5 采集叶',
|
||||
);
|
||||
const manual = await seedStoreCategory(
|
||||
`m35-manual-${suffix}`,
|
||||
'manual-type',
|
||||
'M3.5 手动叶',
|
||||
'M3.5 手动 › M3.5 手动叶',
|
||||
);
|
||||
seededCategoryIds.push(captured.id, manual.id);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
@@ -1089,14 +1345,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[first.profileId],
|
||||
);
|
||||
});
|
||||
const resolved = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(resolved.response.status, 200);
|
||||
assert.equal(
|
||||
resolved.body.data.profile.category.id,
|
||||
captured.id,
|
||||
const resolved = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
{
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
},
|
||||
);
|
||||
assert.equal(resolved.response.status, 200);
|
||||
assert.equal(resolved.body.data.profile.category.id, captured.id);
|
||||
assert.equal(
|
||||
resolved.body.data.profile.category.source,
|
||||
'source_capture',
|
||||
@@ -1111,29 +1367,36 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[manual.id, first.profileId],
|
||||
),
|
||||
);
|
||||
const preserved = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(preserved.response.status, 200);
|
||||
assert.equal(
|
||||
preserved.body.data.profile.category.id,
|
||||
manual.id,
|
||||
const preserved = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
{
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
},
|
||||
);
|
||||
assert.equal(preserved.response.status, 200);
|
||||
assert.equal(preserved.body.data.profile.category.id, manual.id);
|
||||
assert.equal(preserved.body.data.profile.category.source, 'manual');
|
||||
});
|
||||
|
||||
it('M3.5 为类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
|
||||
const dictionaryId = `m35-dictionary-${randomUUID()}`;
|
||||
const category = await seedStoreCategory(`m35-category-${randomUUID()}`, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目');
|
||||
const category = await seedStoreCategory(
|
||||
`m35-category-${randomUUID()}`,
|
||||
'm35-type',
|
||||
'M3.5 测试类目',
|
||||
'测试 › M3.5 测试类目',
|
||||
);
|
||||
seededCategoryIds.push(category.id);
|
||||
seededDictionaryIds.push(dictionaryId);
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
|
||||
[category.storeId, platformId, category.id, dictionaryId],
|
||||
));
|
||||
[category.storeId, platformId, category.id, dictionaryId],
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const profile = await tx.query<{ target_store_id: string }>(
|
||||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
||||
@@ -1164,7 +1427,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(schema.response.status, 202);
|
||||
assert.equal(schema.body.data.operation.kind, 'store_category_attribute_sync');
|
||||
assert.equal(
|
||||
schema.body.data.operation.kind,
|
||||
'store_category_attribute_sync',
|
||||
);
|
||||
const dictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
|
||||
{
|
||||
@@ -1176,74 +1442,107 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(dictionary.response.status, 202);
|
||||
assert.equal(dictionary.body.data.operation.kind, 'store_attribute_dictionary_sync');
|
||||
assert.equal(
|
||||
dictionary.body.data.operation.kind,
|
||||
'store_attribute_dictionary_sync',
|
||||
);
|
||||
const initialDictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(initialDictionary.response.status, 202);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const events = await tx.query<{ event_type: string; event_payload: unknown }>(
|
||||
const events = await tx.query<{
|
||||
event_type: string;
|
||||
event_payload: unknown;
|
||||
}>(
|
||||
`SELECT event_type, event_payload FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
|
||||
ORDER BY created_at`,
|
||||
[first.profileId],
|
||||
);
|
||||
assert.equal(events.rows.length, 2);
|
||||
assert.equal(events.rows.length, 3);
|
||||
assert.deepEqual(
|
||||
events.rows.map((event) => event.event_type),
|
||||
events.rows.map((event) => event.event_type).sort(),
|
||||
[
|
||||
'store_category_attribute_sync.requested',
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
'store_category_attribute_sync.requested',
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
events.rows.every(
|
||||
(event) =>
|
||||
!JSON.stringify(event.event_payload).includes('m35-test-ciphertext'),
|
||||
!JSON.stringify(event.event_payload).includes(
|
||||
'm35-test-ciphertext',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const before = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
{
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
},
|
||||
);
|
||||
const variant = before.body.data.variants[0];
|
||||
const asset = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const created = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
const asset = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) => {
|
||||
const created = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
||||
'image/jpeg', 1024, $2, 1200, 900)
|
||||
RETURNING id`,
|
||||
[`m36/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '')],
|
||||
);
|
||||
const snapshot = await tx.query<{ source_snapshot_revision: string }>(
|
||||
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
|
||||
[first.collectionItemId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.collection_asset_transfers
|
||||
[
|
||||
`m36/${randomUUID()}.jpg`,
|
||||
randomUUID().replaceAll('-', ''),
|
||||
],
|
||||
);
|
||||
const snapshot = await tx.query<{
|
||||
source_snapshot_revision: string;
|
||||
}>(
|
||||
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
|
||||
[first.collectionItemId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.collection_asset_transfers
|
||||
(workspace_id, collection_item_id, product_id, source_variant_id, role,
|
||||
source_url, status, asset_id, source_snapshot_revision, completed_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
||||
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
|
||||
[
|
||||
first.collectionItemId,
|
||||
first.productId,
|
||||
variant.id,
|
||||
created.rows[0].id,
|
||||
snapshot.rows[0].source_snapshot_revision,
|
||||
],
|
||||
);
|
||||
return created.rows[0].id;
|
||||
});
|
||||
[
|
||||
first.collectionItemId,
|
||||
first.productId,
|
||||
variant.id,
|
||||
created.rows[0].id,
|
||||
snapshot.rows[0].source_snapshot_revision,
|
||||
],
|
||||
);
|
||||
return created.rows[0].id;
|
||||
},
|
||||
);
|
||||
const payload = {
|
||||
ai_source_material: before.body.data.source_material,
|
||||
ai_source_overridden_fields:
|
||||
before.body.data.source_material_state.overridden_fields,
|
||||
common: {
|
||||
...before.body.data.product.common,
|
||||
default_physical_spec: before.body.data.product.default_physical_spec,
|
||||
default_package_spec: before.body.data.product.default_package_spec,
|
||||
default_physical_spec:
|
||||
before.body.data.product.default_physical_spec,
|
||||
default_package_spec:
|
||||
before.body.data.product.default_package_spec,
|
||||
},
|
||||
profile: {
|
||||
target_store_id: before.body.data.profile.target_store.id,
|
||||
@@ -1278,19 +1577,25 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
media_asset_ids: item.id === variant.id ? [asset] : [],
|
||||
})),
|
||||
};
|
||||
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const saved = await request(
|
||||
`/api/products/${first.productId}/editor-draft`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
assert.equal(saved.response.status, 200);
|
||||
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.deepEqual(after.body.data.variants[0].media.map((item: any) => item.asset_id), [asset]);
|
||||
assert.deepEqual(
|
||||
after.body.data.variants[0].media.map((item: any) => item.asset_id),
|
||||
[asset],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user