feat(category): 类目树、类目属性、属性字典维护系统构建完成
This commit is contained in:
@@ -4,16 +4,23 @@ 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';
|
||||
import {
|
||||
enqueuePlatformCatalogRun,
|
||||
type PlatformCatalogRunRow,
|
||||
} from '../platform-catalog/platform-catalog-run.js';
|
||||
import type {
|
||||
AttributeBatchInput, RefreshPolicyInput, SourceInput, SyncInput,
|
||||
} from './platform-catalog-admin.models.js';
|
||||
|
||||
type RunRow = {
|
||||
type BatchRow = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
scope: string;
|
||||
scope_key: string;
|
||||
mode: AttributeBatchInput['mode'];
|
||||
status: string;
|
||||
trigger_kind: string;
|
||||
total_count: number;
|
||||
created_at: Date;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -71,9 +78,15 @@ export class PlatformCatalogAdminCommands {
|
||||
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||
[platformId],
|
||||
);
|
||||
await enqueueRun(tx, {
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_catalog_attribute_batches
|
||||
SET status = 'cancelled', completed_at = now()
|
||||
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||
[platformId],
|
||||
);
|
||||
await enqueuePlatformCatalogRun(tx, {
|
||||
platformId, scope: 'tree', scopeKey: 'tree', sourceRevision: revision,
|
||||
trigger: 'source_configured', userId,
|
||||
trigger: 'source_configured', priority: 'high', userId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -103,9 +116,9 @@ export class PlatformCatalogAdminCommands {
|
||||
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
|
||||
}
|
||||
const target = await this.resolveTarget(tx, platformId, input);
|
||||
const prior = await tx.query<RunRow>(
|
||||
const prior = await tx.query<PlatformCatalogRunRow>(
|
||||
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
|
||||
run.trigger_kind, run.created_at
|
||||
run.trigger_kind, run.priority, 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`,
|
||||
@@ -117,7 +130,7 @@ export class PlatformCatalogAdminCommands {
|
||||
}
|
||||
return presentRun(prior.rows[0]);
|
||||
}
|
||||
const run = await enqueueRun(tx, {
|
||||
const run = await enqueuePlatformCatalogRun(tx, {
|
||||
platformId,
|
||||
scope: input.scope,
|
||||
scopeKey: target.scopeKey,
|
||||
@@ -125,6 +138,9 @@ export class PlatformCatalogAdminCommands {
|
||||
attributeId: target.attributeId,
|
||||
sourceRevision: source.rows[0].revision,
|
||||
trigger: 'manual',
|
||||
priority: input.scope === 'tree'
|
||||
? 'high'
|
||||
: await resourcePriority(tx, platformId, input.scope, target.scopeKey),
|
||||
userId,
|
||||
idempotencyKey,
|
||||
});
|
||||
@@ -138,6 +154,151 @@ export class PlatformCatalogAdminCommands {
|
||||
});
|
||||
}
|
||||
|
||||
public async updateRefreshPolicy(
|
||||
platformId: string,
|
||||
userId: string,
|
||||
input: RefreshPolicyInput,
|
||||
) {
|
||||
return this.database.transaction(async (tx) => {
|
||||
await this.requireSupportedPlatform(tx, platformId);
|
||||
const previous = await ensurePolicy(tx, platformId);
|
||||
if (samePolicy(previous, input)) return previous;
|
||||
const result = await tx.query<{
|
||||
attribute_fresh_days: number; attribute_expire_days: number;
|
||||
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||
updated_at: Date;
|
||||
}>(
|
||||
`INSERT INTO atlas.platform_catalog_refresh_policies
|
||||
(platform_id, attribute_fresh_days, attribute_expire_days,
|
||||
dictionary_fresh_days, dictionary_expire_days, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (platform_id) DO UPDATE
|
||||
SET attribute_fresh_days = EXCLUDED.attribute_fresh_days,
|
||||
attribute_expire_days = EXCLUDED.attribute_expire_days,
|
||||
dictionary_fresh_days = EXCLUDED.dictionary_fresh_days,
|
||||
dictionary_expire_days = EXCLUDED.dictionary_expire_days,
|
||||
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||
updated_at = now()
|
||||
RETURNING attribute_fresh_days, attribute_expire_days,
|
||||
dictionary_fresh_days, dictionary_expire_days, updated_at`,
|
||||
[platformId, input.attribute_fresh_days, input.attribute_expire_days,
|
||||
input.dictionary_fresh_days, input.dictionary_expire_days, userId],
|
||||
);
|
||||
const next = result.rows[0];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_refresh_policy_revisions
|
||||
(platform_id, changed_by_user_id, previous_policy, next_policy)
|
||||
VALUES ($1, $2, $3::jsonb, $4::jsonb)`,
|
||||
[platformId, userId, JSON.stringify(policyValues(previous)),
|
||||
JSON.stringify(policyValues(next))],
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
public async createAttributeBatch(
|
||||
platformId: string,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
input: AttributeBatchInput,
|
||||
) {
|
||||
return this.database.transaction(async (tx) => {
|
||||
await this.requireSupportedPlatform(tx, platformId);
|
||||
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 prior = await tx.query<BatchRow>(
|
||||
`SELECT id, platform_id, mode, status, total_count,
|
||||
created_at, started_at, completed_at
|
||||
FROM atlas.platform_catalog_attribute_batches
|
||||
WHERE requested_by_user_id = $1 AND idempotency_key = $2`,
|
||||
[userId, idempotencyKey],
|
||||
);
|
||||
if (prior.rows[0]) {
|
||||
if (prior.rows[0].platform_id !== platformId || prior.rows[0].mode !== input.mode) {
|
||||
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他属性同步批次');
|
||||
}
|
||||
return presentBatch(tx, prior.rows[0]);
|
||||
}
|
||||
const active = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.platform_catalog_attribute_batches
|
||||
WHERE platform_id = $1 AND status IN ('queued', 'running') LIMIT 1`,
|
||||
[platformId],
|
||||
);
|
||||
if (active.rows[0]) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'CATALOG_ATTRIBUTE_BATCH_ACTIVE',
|
||||
`已有类型属性批次正在执行:${active.rows[0].id}`,
|
||||
);
|
||||
}
|
||||
const policy = await ensurePolicy(tx, platformId);
|
||||
const candidates = await tx.query<{ id: string }>(
|
||||
`SELECT category.id
|
||||
FROM atlas.platform_product_categories category
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = category.platform_id
|
||||
AND state.scope_key = 'attributes:' || category.id
|
||||
WHERE category.platform_id = $1 AND category.is_active
|
||||
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
|
||||
AND (
|
||||
$2 = 'all'
|
||||
OR state.last_success_at IS NULL
|
||||
OR ($2 = 'missing_expired'
|
||||
AND state.last_success_at < now() - make_interval(days => $3))
|
||||
OR ($2 = 'missing_stale_expired'
|
||||
AND state.last_success_at <= now() - make_interval(days => $4))
|
||||
)
|
||||
ORDER BY category.id`,
|
||||
[platformId, input.mode, policy.attribute_expire_days,
|
||||
policy.attribute_fresh_days],
|
||||
);
|
||||
if (!candidates.rows.length) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATALOG_ATTRIBUTE_BATCH_EMPTY',
|
||||
'当前没有符合条件的商品类型',
|
||||
);
|
||||
}
|
||||
const inserted = await tx.query<BatchRow>(
|
||||
`INSERT INTO atlas.platform_catalog_attribute_batches
|
||||
(platform_id, mode, source_revision, attribute_fresh_days,
|
||||
attribute_expire_days, requested_by_user_id, idempotency_key, total_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, platform_id, mode, status, total_count,
|
||||
created_at, started_at, completed_at`,
|
||||
[platformId, input.mode, source.rows[0].revision,
|
||||
policy.attribute_fresh_days, policy.attribute_expire_days,
|
||||
userId, idempotencyKey, candidates.rows.length],
|
||||
);
|
||||
const batch = inserted.rows[0];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_attribute_batch_items
|
||||
(batch_id, platform_id, category_id)
|
||||
SELECT $1, $2, category_id
|
||||
FROM unnest($3::uuid[]) AS category_id`,
|
||||
[batch.id, platformId, candidates.rows.map((candidate) => candidate.id)],
|
||||
);
|
||||
return presentBatch(tx, batch);
|
||||
});
|
||||
}
|
||||
|
||||
private async requireSupportedPlatform(tx: DatabaseTransaction, platformId: string) {
|
||||
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', '该平台尚未接入目录同步');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTarget(tx: DatabaseTransaction, platformId: string, input: SyncInput) {
|
||||
if (input.scope === 'tree') return { scopeKey: 'tree' };
|
||||
if (input.scope === 'attributes') {
|
||||
@@ -167,53 +328,97 @@ export class PlatformCatalogAdminCommands {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
function presentRun(run: PlatformCatalogRunRow) {
|
||||
return {
|
||||
id: run.id,
|
||||
platform_id: run.platform_id,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
trigger: run.trigger_kind,
|
||||
priority: run.priority,
|
||||
created_at: run.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePolicy(tx: DatabaseTransaction, platformId: string) {
|
||||
const result = await tx.query<{
|
||||
attribute_fresh_days: number; attribute_expire_days: number;
|
||||
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||
updated_at: Date;
|
||||
}>(
|
||||
`INSERT INTO atlas.platform_catalog_refresh_policies (platform_id)
|
||||
VALUES ($1) ON CONFLICT (platform_id) DO UPDATE SET platform_id = EXCLUDED.platform_id
|
||||
RETURNING attribute_fresh_days, attribute_expire_days,
|
||||
dictionary_fresh_days, dictionary_expire_days, updated_at`,
|
||||
[platformId],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
type PolicyValues = Pick<RefreshPolicyInput,
|
||||
'attribute_fresh_days' | 'attribute_expire_days' |
|
||||
'dictionary_fresh_days' | 'dictionary_expire_days'>;
|
||||
|
||||
function samePolicy(current: PolicyValues, next: PolicyValues) {
|
||||
return current.attribute_fresh_days === next.attribute_fresh_days &&
|
||||
current.attribute_expire_days === next.attribute_expire_days &&
|
||||
current.dictionary_fresh_days === next.dictionary_fresh_days &&
|
||||
current.dictionary_expire_days === next.dictionary_expire_days;
|
||||
}
|
||||
|
||||
function policyValues(policy: PolicyValues): PolicyValues {
|
||||
return {
|
||||
attribute_fresh_days: policy.attribute_fresh_days,
|
||||
attribute_expire_days: policy.attribute_expire_days,
|
||||
dictionary_fresh_days: policy.dictionary_fresh_days,
|
||||
dictionary_expire_days: policy.dictionary_expire_days,
|
||||
};
|
||||
}
|
||||
|
||||
async function resourcePriority(
|
||||
tx: DatabaseTransaction,
|
||||
platformId: string,
|
||||
scope: 'attributes' | 'dictionary',
|
||||
scopeKey: string,
|
||||
): Promise<'high' | 'low'> {
|
||||
const policy = await ensurePolicy(tx, platformId);
|
||||
const state = await tx.query<{ high_priority: boolean }>(
|
||||
`SELECT state.last_success_at IS NULL
|
||||
OR state.last_success_at < now() - make_interval(days => $3)
|
||||
AS high_priority
|
||||
FROM (SELECT 1) marker
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = $1 AND state.scope_key = $2`,
|
||||
[platformId, scopeKey, scope === 'attributes'
|
||||
? policy.attribute_expire_days
|
||||
: policy.dictionary_expire_days],
|
||||
);
|
||||
return state.rows[0]?.high_priority === true ? 'high' : 'low';
|
||||
}
|
||||
|
||||
async function presentBatch(tx: DatabaseTransaction, batch: BatchRow) {
|
||||
const counts = await tx.query<{
|
||||
pending_count: string; running_count: string; succeeded_count: string;
|
||||
failed_count: string; cancelled_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::text AS pending_count,
|
||||
count(*) FILTER (WHERE run.status = 'running')::text AS running_count,
|
||||
count(*) FILTER (WHERE run.status = 'succeeded')::text AS succeeded_count,
|
||||
count(*) FILTER (WHERE run.status = 'failed')::text AS failed_count,
|
||||
count(*) FILTER (WHERE run.status = 'cancelled')::text AS cancelled_count
|
||||
FROM atlas.platform_catalog_attribute_batch_items item
|
||||
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id
|
||||
WHERE item.batch_id = $1`,
|
||||
[batch.id],
|
||||
);
|
||||
const progress = counts.rows[0];
|
||||
return {
|
||||
...batch,
|
||||
pending_count: Number(progress?.pending_count ?? batch.total_count),
|
||||
running_count: Number(progress?.running_count ?? 0),
|
||||
succeeded_count: Number(progress?.succeeded_count ?? 0),
|
||||
failed_count: Number(progress?.failed_count ?? 0),
|
||||
cancelled_count: Number(progress?.cancelled_count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ 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,
|
||||
attributeBatchInputSchema, parseInput, parseListLimit, parseUuid,
|
||||
refreshPolicyInputSchema, sourceInputSchema, syncInputSchema,
|
||||
} from './platform-catalog-admin.models.js';
|
||||
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
||||
import { hasSystemAdminGrant, SystemAdminGuard } from './system-admin.guard.js';
|
||||
@@ -61,6 +62,28 @@ export class PlatformCatalogAdminController {
|
||||
return { data: await this.reads.categories(parseUuid(platformId, 'platform_id')) };
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/refresh-policy')
|
||||
public async refreshPolicy(@Param('platformId') platformId: string) {
|
||||
return {
|
||||
data: await this.reads.refreshPolicy(parseUuid(platformId, 'platform_id')),
|
||||
};
|
||||
}
|
||||
|
||||
@Put('platforms/:platformId/refresh-policy')
|
||||
public async updateRefreshPolicy(
|
||||
@Param('platformId') platformId: string,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
return {
|
||||
data: await this.commands.updateRefreshPolicy(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
request.auth.userId,
|
||||
parseInput(refreshPolicyInputSchema, body),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/categories/:categoryId/attributes')
|
||||
public async attributes(
|
||||
@Param('platformId') platformId: string,
|
||||
@@ -129,4 +152,60 @@ export class PlatformCatalogAdminController {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/attribute-sync-summary')
|
||||
public async attributeSyncSummary(@Param('platformId') platformId: string) {
|
||||
return {
|
||||
data: await this.reads.attributeSyncSummary(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('platforms/:platformId/attribute-sync-batches')
|
||||
@HttpCode(202)
|
||||
public async createAttributeBatch(
|
||||
@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.createAttributeBatch(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
request.auth.userId,
|
||||
parseUuid(idempotencyKey, 'Idempotency-Key'),
|
||||
parseInput(attributeBatchInputSchema, body),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/attribute-sync-batches')
|
||||
public async attributeBatches(
|
||||
@Param('platformId') platformId: string,
|
||||
@Query('limit') limit: string | undefined,
|
||||
) {
|
||||
return {
|
||||
data: await this.reads.attributeBatches(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
parseListLimit(limit),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('platforms/:platformId/attribute-sync-batches/:batchId')
|
||||
public async attributeBatch(
|
||||
@Param('platformId') platformId: string,
|
||||
@Param('batchId') batchId: string,
|
||||
) {
|
||||
return {
|
||||
data: await this.reads.attributeBatch(
|
||||
parseUuid(platformId, 'platform_id'),
|
||||
parseUuid(batchId, 'batch_id'),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,36 @@ export const syncInputSchema = z.discriminatedUnion('scope', [
|
||||
z.object({ scope: z.literal('dictionary'), attribute_id: z.uuid() }).strict(),
|
||||
]);
|
||||
|
||||
export const refreshPolicyInputSchema = z.object({
|
||||
attribute_fresh_days: z.number().int().min(1).max(365),
|
||||
attribute_expire_days: z.number().int().min(1).max(365),
|
||||
dictionary_fresh_days: z.number().int().min(1).max(365),
|
||||
dictionary_expire_days: z.number().int().min(1).max(365),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.attribute_expire_days <= value.attribute_fresh_days) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['attribute_expire_days'],
|
||||
message: '类型属性过期天数必须大于新鲜天数',
|
||||
});
|
||||
}
|
||||
if (value.dictionary_expire_days <= value.dictionary_fresh_days) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['dictionary_expire_days'],
|
||||
message: '属性字典过期天数必须大于新鲜天数',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const attributeBatchInputSchema = z.object({
|
||||
mode: z.enum(['missing_expired', 'missing_stale_expired', 'all']),
|
||||
}).strict();
|
||||
|
||||
export type SyncInput = z.infer<typeof syncInputSchema>;
|
||||
export type SourceInput = z.infer<typeof sourceInputSchema>;
|
||||
export type RefreshPolicyInput = z.infer<typeof refreshPolicyInputSchema>;
|
||||
export type AttributeBatchInput = z.infer<typeof attributeBatchInputSchema>;
|
||||
|
||||
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
|
||||
const parsed = schema.safeParse(input);
|
||||
|
||||
@@ -11,20 +11,20 @@ export class PlatformCatalogAdminReads {
|
||||
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;
|
||||
category_count: string; last_tree_sync_at: Date | null;
|
||||
next_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
|
||||
tree_state.last_success_at AS last_tree_sync_at,
|
||||
tree_state.last_success_at + interval '7 days' AS next_tree_sync_at,
|
||||
tree_state.last_error_message AS last_error
|
||||
FROM atlas.platforms p
|
||||
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
|
||||
LEFT JOIN atlas.platform_catalog_resource_states tree_state
|
||||
ON tree_state.platform_id = p.id AND tree_state.scope_key = 'tree'
|
||||
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||
);
|
||||
return rows.rows.map((row) => ({
|
||||
@@ -40,6 +40,7 @@ export class PlatformCatalogAdminReads {
|
||||
summary: {
|
||||
category_count: Number(row.category_count),
|
||||
last_tree_sync_at: row.last_tree_sync_at,
|
||||
next_tree_sync_at: row.next_tree_sync_at,
|
||||
last_error: row.last_error,
|
||||
},
|
||||
}));
|
||||
@@ -48,15 +49,111 @@ export class PlatformCatalogAdminReads {
|
||||
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`,
|
||||
`SELECT category.id, category.parent_id, category.external_category_id,
|
||||
category.external_type_id, category.name, category.path,
|
||||
category.is_active, category.synced_at,
|
||||
state.last_success_at AS attribute_last_success_at,
|
||||
state.last_error_message AS attribute_last_error,
|
||||
state.last_error_at AS attribute_last_error_at,
|
||||
CASE
|
||||
WHEN category.external_type_id IS NULL OR category.external_type_id = '0'
|
||||
THEN NULL
|
||||
WHEN state.last_success_at IS NULL THEN 'missing'
|
||||
WHEN state.last_success_at > now() -
|
||||
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
|
||||
THEN 'fresh'
|
||||
WHEN state.last_success_at >= now() -
|
||||
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
|
||||
THEN 'stale'
|
||||
ELSE 'expired'
|
||||
END AS attribute_cache_status
|
||||
FROM atlas.platform_product_categories category
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = category.platform_id
|
||||
AND state.scope_key = 'attributes:' || category.id
|
||||
LEFT JOIN atlas.platform_catalog_refresh_policies policy
|
||||
ON policy.platform_id = category.platform_id
|
||||
WHERE category.platform_id = $1 ORDER BY path, id LIMIT 20000`,
|
||||
[platformId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
public async refreshPolicy(platformId: string) {
|
||||
await this.requirePlatform(platformId);
|
||||
const result = await this.database.query<{
|
||||
attribute_fresh_days: number; attribute_expire_days: number;
|
||||
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||
updated_at: Date | null;
|
||||
}>(
|
||||
`SELECT COALESCE(policy.attribute_fresh_days, 14)::int AS attribute_fresh_days,
|
||||
COALESCE(policy.attribute_expire_days, 21)::int AS attribute_expire_days,
|
||||
COALESCE(policy.dictionary_fresh_days, 14)::int AS dictionary_fresh_days,
|
||||
COALESCE(policy.dictionary_expire_days, 21)::int AS dictionary_expire_days,
|
||||
policy.updated_at
|
||||
FROM atlas.platforms platform
|
||||
LEFT JOIN atlas.platform_catalog_refresh_policies policy
|
||||
ON policy.platform_id = platform.id
|
||||
WHERE platform.id = $1`,
|
||||
[platformId],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
public async attributeSyncSummary(platformId: string) {
|
||||
await this.requirePlatform(platformId);
|
||||
const result = await this.database.query<{
|
||||
total: string; fresh: string; stale: string; expired: string; missing: string;
|
||||
active_batch_id: string | null; evaluated_at: Date;
|
||||
}>(
|
||||
`WITH policy AS (
|
||||
SELECT COALESCE(refresh.attribute_fresh_days, 14)::int AS fresh_days,
|
||||
COALESCE(refresh.attribute_expire_days, 21)::int AS expire_days
|
||||
FROM atlas.platforms platform
|
||||
LEFT JOIN atlas.platform_catalog_refresh_policies refresh
|
||||
ON refresh.platform_id = platform.id
|
||||
WHERE platform.id = $1
|
||||
), resources AS (
|
||||
SELECT state.last_success_at, policy.fresh_days, policy.expire_days
|
||||
FROM atlas.platform_product_categories category
|
||||
CROSS JOIN policy
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = category.platform_id
|
||||
AND state.scope_key = 'attributes:' || category.id
|
||||
WHERE category.platform_id = $1 AND category.is_active
|
||||
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
|
||||
)
|
||||
SELECT count(*)::text AS total,
|
||||
count(*) FILTER (
|
||||
WHERE last_success_at > now() - make_interval(days => fresh_days)
|
||||
)::text AS fresh,
|
||||
count(*) FILTER (
|
||||
WHERE last_success_at <= now() - make_interval(days => fresh_days)
|
||||
AND last_success_at >= now() - make_interval(days => expire_days)
|
||||
)::text AS stale,
|
||||
count(*) FILTER (
|
||||
WHERE last_success_at < now() - make_interval(days => expire_days)
|
||||
)::text AS expired,
|
||||
count(*) FILTER (WHERE last_success_at IS NULL)::text AS missing,
|
||||
(SELECT id FROM atlas.platform_catalog_attribute_batches batch
|
||||
WHERE batch.platform_id = $1 AND batch.status IN ('queued', 'running')
|
||||
LIMIT 1) AS active_batch_id,
|
||||
now() AS evaluated_at
|
||||
FROM resources`,
|
||||
[platformId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
fresh: Number(row?.fresh ?? 0),
|
||||
stale: Number(row?.stale ?? 0),
|
||||
expired: Number(row?.expired ?? 0),
|
||||
missing: Number(row?.missing ?? 0),
|
||||
active_batch_id: row?.active_batch_id ?? null,
|
||||
evaluated_at: row?.evaluated_at ?? new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
public async attributes(platformId: string, categoryId: string) {
|
||||
await this.requireCategory(platformId, categoryId);
|
||||
const result = await this.database.query(
|
||||
@@ -65,9 +162,45 @@ export class PlatformCatalogAdminReads {
|
||||
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,
|
||||
schema_state.last_success_at,
|
||||
CASE
|
||||
WHEN schema_state.last_success_at IS NULL THEN 'missing'
|
||||
WHEN schema_state.last_success_at > now() -
|
||||
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
|
||||
THEN 'fresh'
|
||||
WHEN schema_state.last_success_at >= now() -
|
||||
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
|
||||
THEN 'stale'
|
||||
ELSE 'expired'
|
||||
END AS cache_status,
|
||||
schema_state.last_error_message,
|
||||
schema_state.last_error_at,
|
||||
dictionary_state.last_success_at AS dictionary_last_success_at,
|
||||
CASE
|
||||
WHEN attribute.dictionary_id IS NULL THEN NULL
|
||||
WHEN dictionary_state.last_success_at IS NULL THEN 'missing'
|
||||
WHEN dictionary_state.last_success_at > now() -
|
||||
make_interval(days => COALESCE(policy.dictionary_fresh_days, 14))
|
||||
THEN 'fresh'
|
||||
WHEN dictionary_state.last_success_at >= now() -
|
||||
make_interval(days => COALESCE(policy.dictionary_expire_days, 21))
|
||||
THEN 'stale'
|
||||
ELSE 'expired'
|
||||
END AS dictionary_cache_status,
|
||||
dictionary_state.is_complete AS dictionary_is_complete,
|
||||
dictionary_state.last_error_message AS dictionary_last_error_message,
|
||||
dictionary_state.last_error_at AS dictionary_last_error_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
|
||||
LEFT JOIN atlas.platform_catalog_resource_states schema_state
|
||||
ON schema_state.platform_id = attribute.platform_id
|
||||
AND schema_state.scope_key = 'attributes:' || attribute.platform_product_category_id
|
||||
LEFT JOIN atlas.platform_catalog_resource_states dictionary_state
|
||||
ON dictionary_state.platform_id = attribute.platform_id
|
||||
AND dictionary_state.scope_key = 'dictionary:' || attribute.id
|
||||
LEFT JOIN atlas.platform_catalog_refresh_policies policy
|
||||
ON policy.platform_id = attribute.platform_id
|
||||
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`,
|
||||
@@ -108,10 +241,8 @@ export class PlatformCatalogAdminReads {
|
||||
[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`,
|
||||
`SELECT is_complete FROM atlas.platform_catalog_resource_states
|
||||
WHERE platform_id = $1 AND scope_key = $2`,
|
||||
[platformId, `dictionary:${attributeId}`],
|
||||
),
|
||||
]);
|
||||
@@ -130,7 +261,7 @@ export class PlatformCatalogAdminReads {
|
||||
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,
|
||||
trigger_kind AS trigger, priority, 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`,
|
||||
@@ -139,6 +270,32 @@ export class PlatformCatalogAdminReads {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
public async attributeBatches(platformId: string, limit: number) {
|
||||
await this.requirePlatform(platformId);
|
||||
const result = await this.database.query(
|
||||
`${batchProgressSelect}
|
||||
WHERE batch.platform_id = $1
|
||||
GROUP BY batch.id
|
||||
ORDER BY batch.created_at DESC LIMIT $2`,
|
||||
[platformId, limit],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
public async attributeBatch(platformId: string, batchId: string) {
|
||||
await this.requirePlatform(platformId);
|
||||
const result = await this.database.query(
|
||||
`${batchProgressSelect}
|
||||
WHERE batch.platform_id = $1 AND batch.id = $2
|
||||
GROUP BY batch.id`,
|
||||
[platformId, batchId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw new ApiError(404, 'CATALOG_ATTRIBUTE_BATCH_NOT_FOUND', '类型属性同步批次不存在');
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
public async requirePlatform(platformId: string) {
|
||||
const result = await this.database.query<{ code: string }>(
|
||||
'SELECT code FROM atlas.platforms WHERE id = $1',
|
||||
@@ -156,3 +313,16 @@ export class PlatformCatalogAdminReads {
|
||||
if (!result.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '类目不属于当前平台');
|
||||
}
|
||||
}
|
||||
|
||||
const batchProgressSelect = `
|
||||
SELECT batch.id, batch.platform_id, batch.mode, batch.status, batch.total_count,
|
||||
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::int
|
||||
AS pending_count,
|
||||
count(*) FILTER (WHERE run.status = 'running')::int AS running_count,
|
||||
count(*) FILTER (WHERE run.status = 'succeeded')::int AS succeeded_count,
|
||||
count(*) FILTER (WHERE run.status = 'failed')::int AS failed_count,
|
||||
count(*) FILTER (WHERE run.status = 'cancelled')::int AS cancelled_count,
|
||||
batch.created_at, batch.started_at, batch.completed_at
|
||||
FROM atlas.platform_catalog_attribute_batches batch
|
||||
JOIN atlas.platform_catalog_attribute_batch_items item ON item.batch_id = batch.id
|
||||
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id`;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DatabaseTransaction } from '@atlas/database';
|
||||
|
||||
export type PlatformCatalogRunRow = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
scope: string;
|
||||
scope_key: string;
|
||||
status: string;
|
||||
trigger_kind: string;
|
||||
priority: 'high' | 'low';
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
type EnqueuePlatformCatalogRunInput = {
|
||||
platformId: string;
|
||||
scope: 'tree' | 'attributes' | 'dictionary' | 'dictionary_search';
|
||||
scopeKey: string;
|
||||
categoryId?: string;
|
||||
attributeId?: string;
|
||||
searchKey?: string;
|
||||
sourceRevision: number;
|
||||
trigger: 'manual' | 'scheduled' | 'weekly' | 'source_configured' |
|
||||
'admin_batch' | 'user_access' | 'publish';
|
||||
priority: 'high' | 'low';
|
||||
userId?: string;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
/** 同一事务写入平台任务和 Outbox;同一作用域的活动任务直接复用。 */
|
||||
export async function enqueuePlatformCatalogRun(
|
||||
tx: DatabaseTransaction,
|
||||
input: EnqueuePlatformCatalogRunInput,
|
||||
): Promise<PlatformCatalogRunRow> {
|
||||
const active = await tx.query<PlatformCatalogRunRow>(
|
||||
`SELECT id, platform_id, scope, scope_key, status, trigger_kind, priority, 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]) {
|
||||
if (input.priority === 'high' && active.rows[0].priority === 'low') {
|
||||
const promoted = await tx.query<PlatformCatalogRunRow>(
|
||||
`UPDATE atlas.platform_catalog_sync_runs
|
||||
SET priority = 'high', promoted_at = now()
|
||||
WHERE id = $1 AND status = 'queued'
|
||||
RETURNING id, platform_id, scope, scope_key, status,
|
||||
trigger_kind, priority, created_at`,
|
||||
[active.rows[0].id],
|
||||
);
|
||||
return promoted.rows[0] ?? active.rows[0];
|
||||
}
|
||||
return active.rows[0];
|
||||
}
|
||||
const inserted = await tx.query<PlatformCatalogRunRow>(
|
||||
`INSERT INTO atlas.platform_catalog_sync_runs
|
||||
(platform_id, scope, scope_key, category_id, attribute_id, search_key,
|
||||
trigger_kind, priority, source_revision, requested_by_user_id, idempotency_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, platform_id, scope, scope_key, status, trigger_kind, priority, created_at`,
|
||||
[input.platformId, input.scope, input.scopeKey, input.categoryId ?? null,
|
||||
input.attributeId ?? null, input.searchKey ?? null, input.trigger, input.priority,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
ProductCatalogCacheMetadata,
|
||||
ProductCatalogCacheStatus,
|
||||
} from '@atlas/contracts';
|
||||
|
||||
export type ProductCatalogResourceState = {
|
||||
last_success_at: Date | string | null;
|
||||
is_complete: boolean;
|
||||
fresh_days: number;
|
||||
expire_days: number;
|
||||
refresh_run_id: string | null;
|
||||
refresh_status: string | null;
|
||||
};
|
||||
|
||||
/** 统一实现 14/21 天边界,供所有商品目录读取与发布预检复用。 */
|
||||
export function productCatalogCacheStatus(
|
||||
lastSuccessAt: Date | string | null,
|
||||
freshDays: number,
|
||||
expireDays: number,
|
||||
now = Date.now(),
|
||||
): ProductCatalogCacheStatus {
|
||||
if (!lastSuccessAt) return 'missing';
|
||||
const age = now - new Date(lastSuccessAt).getTime();
|
||||
const day = 86_400_000;
|
||||
if (age < freshDays * day) return 'fresh';
|
||||
if (age <= expireDays * day) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
export function productCatalogResourceStatus(
|
||||
state: ProductCatalogResourceState,
|
||||
): ProductCatalogCacheStatus {
|
||||
return productCatalogCacheStatus(
|
||||
state.last_success_at,
|
||||
state.fresh_days,
|
||||
state.expire_days,
|
||||
);
|
||||
}
|
||||
|
||||
/** cache 元数据在编辑读取与发布预检之间保持同一形状和边界计算。 */
|
||||
export function productCatalogCacheMetadata(
|
||||
state: ProductCatalogResourceState,
|
||||
status = productCatalogResourceStatus(state),
|
||||
): ProductCatalogCacheMetadata {
|
||||
const last = state.last_success_at ? new Date(state.last_success_at) : null;
|
||||
return {
|
||||
status,
|
||||
last_success_at: last?.toISOString() ?? null,
|
||||
fresh_until: last
|
||||
? new Date(
|
||||
last.getTime() + state.fresh_days * 86_400_000,
|
||||
).toISOString()
|
||||
: null,
|
||||
expires_at: last
|
||||
? new Date(
|
||||
last.getTime() + state.expire_days * 86_400_000,
|
||||
).toISOString()
|
||||
: null,
|
||||
is_complete: state.is_complete,
|
||||
refresh_run_id: state.refresh_run_id,
|
||||
refresh_status: state.refresh_status,
|
||||
retry_after_ms:
|
||||
status === 'expired' || status === 'missing' ? 1_000 : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function productCatalogStateWithRun(
|
||||
state: ProductCatalogResourceState,
|
||||
run: { id: string; status: string },
|
||||
): ProductCatalogResourceState {
|
||||
return {
|
||||
...state,
|
||||
refresh_run_id: run.id,
|
||||
refresh_status: run.status,
|
||||
};
|
||||
}
|
||||
@@ -1,45 +1,21 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ProductAttributeSchema } from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import {
|
||||
enqueuePlatformCatalogRun,
|
||||
type PlatformCatalogRunRow,
|
||||
} from '../platform-catalog/platform-catalog-run.js';
|
||||
import {
|
||||
productCatalogCacheMetadata as cacheMetadata,
|
||||
productCatalogResourceStatus as cacheStatus,
|
||||
type ProductCatalogResourceState as ResourceState,
|
||||
} from './product-editor.catalog-cache.js';
|
||||
import type { ProfileRow } from './product-editor.models.js';
|
||||
|
||||
type CatalogSyncInput = {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
profileId: string;
|
||||
platformId: string;
|
||||
key: string;
|
||||
scope: 'attributes' | 'dictionary';
|
||||
path: string;
|
||||
categoryId?: string;
|
||||
dictionaryId?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
type CatalogSyncResult = {
|
||||
status: number;
|
||||
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 RefreshScope = 'attributes' | 'dictionary' | 'dictionary_search';
|
||||
type RunRow = PlatformCatalogRunRow;
|
||||
|
||||
type CatalogAccess = {
|
||||
requireProfileForPlatform: (
|
||||
@@ -47,58 +23,67 @@ type CatalogAccess = {
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
) => Promise<ProfileRow>;
|
||||
requireEditableStore: (
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
) => Promise<void>;
|
||||
requireActiveCategory: (
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<unknown>;
|
||||
attributeSchemaForCategory: (
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<ProductAttributeSchema[]>;
|
||||
requestCatalogSync: (input: CatalogSyncInput) => Promise<CatalogSyncResult>;
|
||||
};
|
||||
|
||||
/** 游标只保存排序位置,类目和属性归属仍由每次请求重新校验。 */
|
||||
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', '字典游标不合法');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理商品编辑器的类目、属性定义和字典缓存读取。
|
||||
* 数据校验仍复用工作流服务的统一访问边界,避免目录读取绕过平台和店铺隔离。
|
||||
* 商品编辑器只读取平台级目录缓存。stale 立即返回并后台刷新;expired/missing
|
||||
* 只创建平台 Worker 任务,短暂等待后仍未完成则以 202 告知前端继续轮询。
|
||||
*/
|
||||
export class ProductEditorCatalogOperations {
|
||||
/** 注入工作空间事务入口及共享的权限、归属校验能力。 */
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly access: CatalogAccess,
|
||||
) {}
|
||||
|
||||
/** 读取当前或指定目标店铺的完整类目缓存。 */
|
||||
async categoryOptions(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
search: string | undefined,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||
if (storeId)
|
||||
await this.access.requireEditableStore(
|
||||
transaction,
|
||||
storeId,
|
||||
platformId,
|
||||
);
|
||||
const categories = await transaction.query<{
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
@@ -109,151 +94,93 @@ export class ProductEditorCatalogOperations {
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||
FROM atlas.store_product_categories
|
||||
WHERE store_id = $1
|
||||
ORDER BY path, name, id
|
||||
LIMIT 10000`,
|
||||
[selectedStoreId],
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1
|
||||
ORDER BY path, name, id
|
||||
LIMIT 10000`,
|
||||
[platformId],
|
||||
);
|
||||
// 保留参数以保持既有 HTTP 契约;当前缓存树始终完整返回。
|
||||
// 搜索由 Cascader 在完整平台树上执行,保持父子路径完整。
|
||||
void search;
|
||||
return categories.rows;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取当前或指定类目的已缓存动态属性定义。 */
|
||||
async attributeSchema(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
const prepared = await this.readAttributeSchema(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
categoryId ?? profile.store_product_category_id;
|
||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||
if (storeId)
|
||||
await this.access.requireEditableStore(
|
||||
transaction,
|
||||
storeId,
|
||||
platformId,
|
||||
);
|
||||
if (!selectedCategoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
if (categoryId)
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
selectedStoreId,
|
||||
categoryId,
|
||||
);
|
||||
return this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
selectedStoreId,
|
||||
selectedCategoryId,
|
||||
);
|
||||
},
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
categoryId,
|
||||
true,
|
||||
);
|
||||
if (!prepared.waitForRunId)
|
||||
return { status: 200, body: { data: prepared.result } };
|
||||
await this.waitForRun(prepared.waitForRunId);
|
||||
const completed = await this.readAttributeSchema(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
categoryId,
|
||||
false,
|
||||
);
|
||||
const usable =
|
||||
completed.result.cache.status === 'fresh' ||
|
||||
completed.result.cache.status === 'stale';
|
||||
return { status: usable ? 200 : 202, body: { data: completed.result } };
|
||||
}
|
||||
|
||||
/** 在确认字典属于当前类目后,搜索店铺本地缓存的字典值。 */
|
||||
async attributeDictionary(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
attributeId: string,
|
||||
search: string | undefined,
|
||||
cursor: string | undefined,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
const after = parseDictionaryCursor(cursor);
|
||||
return this.database.workspaceTransaction(
|
||||
const prepared = await this.readDictionary(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
categoryId ?? profile.store_product_category_id;
|
||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||
if (storeId)
|
||||
await this.access.requireEditableStore(
|
||||
transaction,
|
||||
storeId,
|
||||
platformId,
|
||||
);
|
||||
if (!selectedCategoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
if (categoryId)
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
selectedStoreId,
|
||||
categoryId,
|
||||
);
|
||||
const allowed = await transaction.query<{ dictionary_id: string }>(
|
||||
`SELECT dictionary_id
|
||||
FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3
|
||||
LIMIT 1`,
|
||||
[selectedStoreId, selectedCategoryId, dictionaryId],
|
||||
);
|
||||
if (!allowed.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'ATTRIBUTE_DICTIONARY_INVALID',
|
||||
'字典不属于当前类目的属性',
|
||||
);
|
||||
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;
|
||||
}>(
|
||||
`SELECT external_value_id AS value_id, value
|
||||
FROM atlas.store_attribute_dictionary_values
|
||||
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 201`,
|
||||
[selectedStoreId, selectedCategoryId, dictionaryId, needle,
|
||||
after?.value ?? null, after?.value_id ?? 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,
|
||||
};
|
||||
},
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
attributeId,
|
||||
search,
|
||||
after,
|
||||
categoryId,
|
||||
true,
|
||||
);
|
||||
if (!prepared.waitForRunId)
|
||||
return { status: 200, body: { data: prepared.result } };
|
||||
await this.waitForRun(prepared.waitForRunId);
|
||||
const completed = await this.readDictionary(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
attributeId,
|
||||
search,
|
||||
after,
|
||||
categoryId,
|
||||
false,
|
||||
);
|
||||
const usable =
|
||||
completed.result.cache.status === 'fresh' ||
|
||||
completed.result.cache.status === 'stale';
|
||||
return { status: usable ? 200 : 202, body: { data: completed.result } };
|
||||
}
|
||||
|
||||
/** 创建类目属性定义同步任务。 */
|
||||
async requestAttributeSchemaSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -262,19 +189,33 @@ export class ProductEditorCatalogOperations {
|
||||
key: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
return this.access.requestCatalogSync({
|
||||
const run = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
key,
|
||||
scope: 'attributes',
|
||||
path: 'attribute-schema-syncs',
|
||||
categoryId,
|
||||
});
|
||||
async (transaction) => {
|
||||
await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
categoryId,
|
||||
);
|
||||
return this.enqueueRequestedRefresh(transaction, key, {
|
||||
platformId,
|
||||
userId,
|
||||
scope: 'attributes',
|
||||
scopeKey: `attributes:${categoryId}`,
|
||||
categoryId,
|
||||
priority: 'high',
|
||||
trigger: 'user_access',
|
||||
});
|
||||
},
|
||||
);
|
||||
return { status: 202, body: { data: { run } } };
|
||||
}
|
||||
|
||||
/** 创建首批字典值或关键词搜索同步任务。 */
|
||||
async requestAttributeDictionarySync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -282,26 +223,435 @@ export class ProductEditorCatalogOperations {
|
||||
platformId: string,
|
||||
key: string,
|
||||
categoryId: string,
|
||||
dictionaryId: string,
|
||||
attributeId: string,
|
||||
query: string,
|
||||
) {
|
||||
if (query.trim().length === 1)
|
||||
const needle = query.trim();
|
||||
if (needle.length === 1)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'DICTIONARY_SEARCH_REQUIRED',
|
||||
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
||||
);
|
||||
return this.access.requestCatalogSync({
|
||||
const run = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
key,
|
||||
scope: 'dictionary',
|
||||
path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
||||
categoryId,
|
||||
dictionaryId,
|
||||
query: query.trim(),
|
||||
async (transaction) => {
|
||||
await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
categoryId,
|
||||
);
|
||||
await this.requireDictionaryAttribute(
|
||||
transaction,
|
||||
platformId,
|
||||
categoryId,
|
||||
attributeId,
|
||||
);
|
||||
const searchScope = needle.length >= 2;
|
||||
const normalized = normalizeSearch(needle);
|
||||
return this.enqueueRequestedRefresh(transaction, key, {
|
||||
platformId,
|
||||
userId,
|
||||
scope: searchScope ? 'dictionary_search' : 'dictionary',
|
||||
scopeKey: searchScope
|
||||
? dictionarySearchScopeKey(attributeId, normalized)
|
||||
: `dictionary:${attributeId}`,
|
||||
categoryId,
|
||||
attributeId,
|
||||
searchKey: searchScope ? normalized : undefined,
|
||||
priority: 'high',
|
||||
trigger: 'user_access',
|
||||
});
|
||||
},
|
||||
);
|
||||
return { status: 202, body: { data: { run } } };
|
||||
}
|
||||
|
||||
private async readAttributeSchema(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
categoryId: string | undefined,
|
||||
enqueue: boolean,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
categoryId ?? profile.platform_product_category_id;
|
||||
if (!selectedCategoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
selectedCategoryId,
|
||||
);
|
||||
const state = await this.resourceState(
|
||||
transaction,
|
||||
platformId,
|
||||
'attributes',
|
||||
`attributes:${selectedCategoryId}`,
|
||||
);
|
||||
const status = cacheStatus(state);
|
||||
let run: RunRow | null = null;
|
||||
if (enqueue && status !== 'fresh') {
|
||||
run = await this.enqueueRefresh(transaction, {
|
||||
platformId,
|
||||
userId,
|
||||
scope: 'attributes',
|
||||
scopeKey: `attributes:${selectedCategoryId}`,
|
||||
categoryId: selectedCategoryId,
|
||||
priority: status === 'stale' ? 'low' : 'high',
|
||||
trigger: 'user_access',
|
||||
});
|
||||
}
|
||||
const effectiveState = run
|
||||
? {
|
||||
...state,
|
||||
refresh_run_id: run.id,
|
||||
refresh_status: run.status,
|
||||
}
|
||||
: state;
|
||||
const usable = status === 'fresh' || status === 'stale';
|
||||
return {
|
||||
result: {
|
||||
items: usable
|
||||
? await this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
selectedCategoryId,
|
||||
)
|
||||
: [],
|
||||
cache: cacheMetadata(effectiveState, status),
|
||||
},
|
||||
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async readDictionary(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
attributeId: string,
|
||||
search: string | undefined,
|
||||
after: DictionaryCursor | null,
|
||||
categoryId: string | undefined,
|
||||
enqueue: boolean,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
categoryId ?? profile.platform_product_category_id;
|
||||
if (!selectedCategoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
selectedCategoryId,
|
||||
);
|
||||
await this.requireDictionaryAttribute(
|
||||
transaction,
|
||||
platformId,
|
||||
selectedCategoryId,
|
||||
attributeId,
|
||||
);
|
||||
const needle = search?.trim() ?? '';
|
||||
if (after && after.query !== needle)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'VALIDATION_FAILED',
|
||||
'字典游标与搜索条件不匹配',
|
||||
);
|
||||
const values = await this.dictionaryValues(
|
||||
transaction,
|
||||
attributeId,
|
||||
needle,
|
||||
after,
|
||||
);
|
||||
const dictionaryState = await this.resourceState(
|
||||
transaction,
|
||||
platformId,
|
||||
'dictionary',
|
||||
`dictionary:${attributeId}`,
|
||||
);
|
||||
const normalized = normalizeSearch(needle);
|
||||
const searchScopeKey = dictionarySearchScopeKey(
|
||||
attributeId,
|
||||
normalized,
|
||||
);
|
||||
const searchState =
|
||||
needle.length >= 2 && !dictionaryState.is_complete
|
||||
? await this.resourceState(
|
||||
transaction,
|
||||
platformId,
|
||||
'dictionary_search',
|
||||
searchScopeKey,
|
||||
)
|
||||
: null;
|
||||
// 部分字典本地未命中时补齐查询词;一旦建立过该词作用域,后续继续
|
||||
// 使用它的新鲜度,避免搜索成功写入值后又退回整个字典的状态。
|
||||
const usesSearchScope =
|
||||
!!searchState &&
|
||||
(values.rows.length === 0 ||
|
||||
searchState.last_success_at !== null);
|
||||
const scope: RefreshScope = usesSearchScope
|
||||
? 'dictionary_search'
|
||||
: 'dictionary';
|
||||
const scopeKey = usesSearchScope
|
||||
? searchScopeKey
|
||||
: `dictionary:${attributeId}`;
|
||||
const state = usesSearchScope ? searchState : dictionaryState;
|
||||
const status = cacheStatus(state);
|
||||
let run: RunRow | null = null;
|
||||
if (enqueue && status !== 'fresh') {
|
||||
run = await this.enqueueRefresh(transaction, {
|
||||
platformId,
|
||||
userId,
|
||||
scope,
|
||||
scopeKey,
|
||||
categoryId: selectedCategoryId,
|
||||
attributeId,
|
||||
searchKey: usesSearchScope ? normalized : undefined,
|
||||
priority: status === 'stale' ? 'low' : 'high',
|
||||
trigger: 'user_access',
|
||||
});
|
||||
}
|
||||
const effectiveState = run
|
||||
? {
|
||||
...state,
|
||||
refresh_run_id: run.id,
|
||||
refresh_status: run.status,
|
||||
}
|
||||
: state;
|
||||
const usable = status === 'fresh' || status === 'stale';
|
||||
const items = usable ? values.rows.slice(0, 200) : [];
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
result: {
|
||||
items,
|
||||
next_cursor:
|
||||
usable && values.rows.length > 200 && last
|
||||
? Buffer.from(
|
||||
JSON.stringify({
|
||||
...last,
|
||||
query: needle,
|
||||
}),
|
||||
).toString('base64url')
|
||||
: null,
|
||||
cache: cacheMetadata(effectiveState, status),
|
||||
},
|
||||
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async dictionaryValues(
|
||||
transaction: DatabaseTransaction,
|
||||
attributeId: string,
|
||||
needle: string,
|
||||
after: DictionaryCursor | null,
|
||||
) {
|
||||
return transaction.query<{ value_id: string; value: string }>(
|
||||
`SELECT external_value_id AS value_id, value
|
||||
FROM atlas.platform_catalog_dictionary_values
|
||||
WHERE attribute_id = $1 AND is_active
|
||||
AND ($2 = '' OR value ILIKE '%' || $2 || '%')
|
||||
AND ($3::text IS NULL OR (value, external_value_id) > ($3, $4))
|
||||
ORDER BY value, external_value_id
|
||||
LIMIT 201`,
|
||||
[
|
||||
attributeId,
|
||||
needle,
|
||||
after?.value ?? null,
|
||||
after?.value_id ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private async requireDictionaryAttribute(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
attributeId: string,
|
||||
) {
|
||||
const attribute = await transaction.query<{ id: string }>(
|
||||
`SELECT 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 ApiError(
|
||||
422,
|
||||
'ATTRIBUTE_DICTIONARY_INVALID',
|
||||
'字典不属于当前类目的属性',
|
||||
);
|
||||
}
|
||||
|
||||
private async resourceState(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
scope: RefreshScope,
|
||||
scopeKey: string,
|
||||
): Promise<ResourceState> {
|
||||
const result = await transaction.query<ResourceState>(
|
||||
`SELECT state.last_success_at, coalesce(state.is_complete, false) AS is_complete,
|
||||
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
|
||||
ELSE policy.dictionary_fresh_days END AS fresh_days,
|
||||
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
|
||||
ELSE policy.dictionary_expire_days END AS expire_days,
|
||||
run.id AS refresh_run_id, run.status AS refresh_status
|
||||
FROM (SELECT 1) marker
|
||||
JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id = $1
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = $1 AND state.scope_key = $3
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, status FROM atlas.platform_catalog_sync_runs
|
||||
WHERE platform_id = $1 AND scope_key = $3
|
||||
AND status IN ('queued', 'running')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) run ON true`,
|
||||
[platformId, scope, scopeKey],
|
||||
);
|
||||
if (!result.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATALOG_POLICY_REQUIRED',
|
||||
'平台目录刷新策略尚未初始化',
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private async enqueueRefresh(
|
||||
transaction: DatabaseTransaction,
|
||||
input: {
|
||||
platformId: string;
|
||||
userId: string;
|
||||
scope: RefreshScope;
|
||||
scopeKey: string;
|
||||
categoryId: string;
|
||||
attributeId?: string;
|
||||
searchKey?: string;
|
||||
priority: 'high' | 'low';
|
||||
trigger: 'user_access';
|
||||
},
|
||||
): Promise<RunRow> {
|
||||
const source = await transaction.query<{ revision: number }>(
|
||||
`SELECT revision FROM atlas.platform_catalog_sources
|
||||
WHERE platform_id = $1 FOR UPDATE`,
|
||||
[input.platformId],
|
||||
);
|
||||
if (!source.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATALOG_SOURCE_REQUIRED',
|
||||
'平台目录同步来源尚未配置',
|
||||
);
|
||||
return enqueuePlatformCatalogRun(transaction, {
|
||||
platformId: input.platformId,
|
||||
scope: input.scope,
|
||||
scopeKey: input.scopeKey,
|
||||
categoryId: input.categoryId,
|
||||
attributeId: input.attributeId,
|
||||
searchKey: input.searchKey,
|
||||
sourceRevision: source.rows[0].revision,
|
||||
trigger: input.trigger,
|
||||
priority: input.priority,
|
||||
userId: input.userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 手动兼容入口把幂等键映射到平台 run,即使复用活动任务也可安全重放。 */
|
||||
private async enqueueRequestedRefresh(
|
||||
transaction: DatabaseTransaction,
|
||||
key: string,
|
||||
input: Parameters<ProductEditorCatalogOperations['enqueueRefresh']>[1],
|
||||
) {
|
||||
const prior = await transaction.query<PlatformCatalogRunRow>(
|
||||
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
|
||||
run.trigger_kind, run.priority, 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`,
|
||||
[input.userId, key],
|
||||
);
|
||||
if (prior.rows[0]) {
|
||||
if (
|
||||
prior.rows[0].platform_id !== input.platformId ||
|
||||
prior.rows[0].scope_key !== input.scopeKey
|
||||
) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'IDEMPOTENCY_KEY_REUSED',
|
||||
'幂等键已用于其他目录刷新请求',
|
||||
);
|
||||
}
|
||||
return prior.rows[0];
|
||||
}
|
||||
const run = await this.enqueueRefresh(transaction, input);
|
||||
await transaction.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)`,
|
||||
[input.userId, key, input.platformId, input.scopeKey, run.id],
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** 最多等待 5 秒;API 不直连 Ozon,只观察 Worker 写入的任务终态。 */
|
||||
private async waitForRun(runId: string) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await this.database.query<{ status: string }>(
|
||||
`SELECT status FROM atlas.platform_catalog_sync_runs WHERE id = $1`,
|
||||
[runId],
|
||||
);
|
||||
if (
|
||||
!result.rows[0] ||
|
||||
['succeeded', 'failed', 'cancelled'].includes(
|
||||
result.rows[0].status,
|
||||
)
|
||||
)
|
||||
return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.trim().toLocaleLowerCase('zh-CN').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function dictionarySearchScopeKey(attributeId: string, search: string) {
|
||||
const hash = createHash('sha256').update(search).digest('hex').slice(0, 24);
|
||||
return `dictionary_search:${attributeId}:${hash}`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productPublishPreflightRequestSchema,
|
||||
saveProductEditorDraftRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
@@ -68,12 +69,11 @@ export class ProductEditorController {
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回目标店铺已缓存的类目树,供编辑器选择。 */
|
||||
/** 返回当前平台共享的类目树,供编辑器选择。 */
|
||||
@Get('product-platform-profiles/:profileId/category-options')
|
||||
async categoryOptions(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -83,7 +83,6 @@ export class ProductEditorController {
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
query,
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -93,19 +92,20 @@ export class ProductEditorController {
|
||||
async attributeSchema(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return {
|
||||
data: await this.editor.attributeSchema(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
optionalUuid('category_id', categoryId),
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
const result = await this.editor.attributeSchema(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
optionalUuid('category_id', categoryId),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 返回首次上架前的服务端发布就绪校验。 */
|
||||
@@ -124,6 +124,40 @@ export class ProductEditorController {
|
||||
};
|
||||
}
|
||||
|
||||
/** 定向刷新本次首次上架依赖的目录资源,并返回最终发布预检。 */
|
||||
@Post('product-platform-profiles/:profileId/publish-preflights')
|
||||
async publishPreflight(
|
||||
@Param('profileId') profileId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const parsed = productPublishPreflightRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const result = await this.editor.publishPreflight(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
parsed.data.variant_ids,
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 幂等地创建所选类目属性定义的刷新任务。 */
|
||||
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||
async attributeSchemaSync(
|
||||
@@ -149,41 +183,40 @@ export class ProductEditorController {
|
||||
|
||||
/** 从本地缓存中查询属性字典值。 */
|
||||
@Get(
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId',
|
||||
)
|
||||
async attributeDictionary(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Param('attributeId') attributeId: 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,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||
return {
|
||||
data: await this.editor.attributeDictionary(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
dictionaryId,
|
||||
query,
|
||||
cursor,
|
||||
optionalUuid('category_id', categoryId),
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
const result = await this.editor.attributeDictionary(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('attribute_id', attributeId),
|
||||
query,
|
||||
cursor,
|
||||
optionalUuid('category_id', categoryId),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
|
||||
@Post(
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId/syncs',
|
||||
)
|
||||
async attributeDictionarySync(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Param('attributeId') attributeId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('q') query: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@@ -192,8 +225,6 @@ export class ProductEditorController {
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||
const result = await this.editor.requestAttributeDictionarySync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
@@ -201,7 +232,7 @@ export class ProductEditorController {
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
requiredUuid('category_id', categoryId),
|
||||
dictionaryId,
|
||||
requiredUuid('attribute_id', attributeId),
|
||||
query ?? '',
|
||||
);
|
||||
response.status(result.status);
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ProfileRow = {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
platform_product_category_id: string | null;
|
||||
store_product_category_id: string | null;
|
||||
platform_type_id: string | null;
|
||||
category_source: string | null;
|
||||
@@ -59,12 +60,13 @@ export type OperationRow = {
|
||||
|
||||
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
|
||||
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.platform_product_category_id,
|
||||
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,
|
||||
s.currency_code
|
||||
FROM atlas.product_platform_profiles p
|
||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||
LEFT JOIN atlas.store_product_categories category
|
||||
ON category.id = p.store_product_category_id
|
||||
AND category.store_id = p.target_store_id`;
|
||||
LEFT JOIN atlas.platform_product_categories category
|
||||
ON category.id = p.platform_product_category_id
|
||||
AND category.platform_id = p.platform_id`;
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import type {
|
||||
ProductAttributeSchema,
|
||||
ProductCatalogCacheMetadata,
|
||||
ProductCatalogRefreshRun,
|
||||
ProductEditorValidation,
|
||||
ProductPublishPreflight,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import {
|
||||
enqueuePlatformCatalogRun,
|
||||
type PlatformCatalogRunRow,
|
||||
} from '../platform-catalog/platform-catalog-run.js';
|
||||
import {
|
||||
productCatalogCacheMetadata as metadata,
|
||||
productCatalogResourceStatus as statusOf,
|
||||
productCatalogStateWithRun as withRun,
|
||||
type ProductCatalogResourceState as ResourceState,
|
||||
} from './product-editor.catalog-cache.js';
|
||||
import type { ProfileRow } from './product-editor.models.js';
|
||||
|
||||
type PublishPreflightAccess = {
|
||||
requireProfileForPlatform: (
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
) => Promise<ProfileRow>;
|
||||
requireActiveCategory: (
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<unknown>;
|
||||
attributeSchemaForCategory: (
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<ProductAttributeSchema[]>;
|
||||
validation: (
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantIds: string[],
|
||||
) => Promise<ProductEditorValidation>;
|
||||
};
|
||||
|
||||
type PreparedCatalog = {
|
||||
schema: ProductCatalogCacheMetadata;
|
||||
dictionaries: ProductPublishPreflight['catalog']['dictionaries'];
|
||||
runs: PlatformCatalogRunRow[];
|
||||
blockingRunIds: string[];
|
||||
hasBlockingResources: boolean;
|
||||
};
|
||||
|
||||
type UsedDictionary = {
|
||||
attribute_id: string;
|
||||
platform_attribute_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 首次发布预检只刷新当前商品实际依赖的平台目录资源。
|
||||
* API 只创建平台任务并观察 Worker 结果,绝不在请求进程直接调用 Ozon。
|
||||
*/
|
||||
export class ProductEditorPublishPreflightOperations {
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly access: PublishPreflightAccess,
|
||||
) {}
|
||||
|
||||
async preflight(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantIds: string[],
|
||||
): Promise<{ status: number; body: { data: ProductPublishPreflight } }> {
|
||||
const deadline = Date.now() + 5_000;
|
||||
const observedRuns = new Map<string, PlatformCatalogRunRow>();
|
||||
let prepared = await this.prepare(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
variantIds,
|
||||
true,
|
||||
);
|
||||
rememberRuns(observedRuns, prepared.runs);
|
||||
|
||||
// 第一轮通常等待 schema;成功后第二轮才知道本商品实际引用哪些字典。
|
||||
for (
|
||||
let pass = 0;
|
||||
pass < 2 && prepared.blockingRunIds.length;
|
||||
pass += 1
|
||||
) {
|
||||
const succeeded = await this.waitForRuns(
|
||||
prepared.blockingRunIds,
|
||||
deadline,
|
||||
);
|
||||
prepared = await this.prepare(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
variantIds,
|
||||
succeeded && Date.now() < deadline,
|
||||
);
|
||||
rememberRuns(observedRuns, prepared.runs);
|
||||
if (!succeeded) break;
|
||||
}
|
||||
|
||||
const validation = await this.access.validation(
|
||||
workspaceId,
|
||||
profileId,
|
||||
platformId,
|
||||
variantIds,
|
||||
);
|
||||
const currentRuns = await this.currentRuns([...observedRuns.keys()]);
|
||||
const refreshing = prepared.hasBlockingResources;
|
||||
const result: ProductPublishPreflight = {
|
||||
status: refreshing
|
||||
? 'refreshing'
|
||||
: validation.ready
|
||||
? 'ready'
|
||||
: 'blocked',
|
||||
validation,
|
||||
catalog: {
|
||||
schema: prepared.schema,
|
||||
dictionaries: prepared.dictionaries,
|
||||
},
|
||||
refresh_runs: currentRuns.map(toRefreshRun),
|
||||
};
|
||||
return {
|
||||
status: refreshing ? 202 : 200,
|
||||
body: { data: result },
|
||||
};
|
||||
}
|
||||
|
||||
private async prepare(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantIds: string[],
|
||||
enqueue: boolean,
|
||||
): Promise<PreparedCatalog> {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
if (!profile.platform_product_category_id)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'首次上架前请先选择产品类目',
|
||||
);
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
profile.platform_product_category_id,
|
||||
);
|
||||
await this.requirePublishableVariants(
|
||||
transaction,
|
||||
profile,
|
||||
variantIds,
|
||||
);
|
||||
|
||||
let sourceRevision: number | null = null;
|
||||
const runs: PlatformCatalogRunRow[] = [];
|
||||
const blockingRunIds: string[] = [];
|
||||
const enqueueResource = async (input: {
|
||||
scope: 'attributes' | 'dictionary';
|
||||
scopeKey: string;
|
||||
categoryId: string;
|
||||
attributeId?: string;
|
||||
priority: 'high' | 'low';
|
||||
}) => {
|
||||
if (sourceRevision === null) {
|
||||
const source = await transaction.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',
|
||||
'平台目录同步来源尚未配置',
|
||||
);
|
||||
sourceRevision = source.rows[0].revision;
|
||||
}
|
||||
const run = await enqueuePlatformCatalogRun(transaction, {
|
||||
platformId,
|
||||
scope: input.scope,
|
||||
scopeKey: input.scopeKey,
|
||||
categoryId: input.categoryId,
|
||||
attributeId: input.attributeId,
|
||||
sourceRevision,
|
||||
trigger: 'publish',
|
||||
priority: input.priority,
|
||||
userId,
|
||||
});
|
||||
runs.push(run);
|
||||
return run;
|
||||
};
|
||||
|
||||
const categoryId = profile.platform_product_category_id;
|
||||
let schemaState = await this.resourceState(
|
||||
transaction,
|
||||
platformId,
|
||||
'attributes',
|
||||
`attributes:${categoryId}`,
|
||||
);
|
||||
const schemaStatus = statusOf(schemaState);
|
||||
if (enqueue && schemaStatus !== 'fresh') {
|
||||
const run = await enqueueResource({
|
||||
scope: 'attributes',
|
||||
scopeKey: `attributes:${categoryId}`,
|
||||
categoryId,
|
||||
priority: schemaStatus === 'stale' ? 'low' : 'high',
|
||||
});
|
||||
schemaState = withRun(schemaState, run);
|
||||
if (
|
||||
schemaStatus === 'expired' ||
|
||||
schemaStatus === 'missing'
|
||||
)
|
||||
blockingRunIds.push(run.id);
|
||||
} else if (
|
||||
schemaStatus === 'expired' ||
|
||||
schemaStatus === 'missing'
|
||||
) {
|
||||
if (schemaState.refresh_run_id)
|
||||
blockingRunIds.push(schemaState.refresh_run_id);
|
||||
}
|
||||
|
||||
const schemaUsable =
|
||||
schemaStatus === 'fresh' || schemaStatus === 'stale';
|
||||
const dictionaries: ProductPublishPreflight['catalog']['dictionaries'] =
|
||||
[];
|
||||
if (schemaUsable) {
|
||||
const schema = await this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
categoryId,
|
||||
);
|
||||
const used = await this.usedDictionaries(
|
||||
transaction,
|
||||
profileId,
|
||||
variantIds,
|
||||
schema,
|
||||
);
|
||||
for (const dictionary of used) {
|
||||
let state = await this.resourceState(
|
||||
transaction,
|
||||
platformId,
|
||||
'dictionary',
|
||||
`dictionary:${dictionary.attribute_id}`,
|
||||
);
|
||||
const status = statusOf(state);
|
||||
if (enqueue && status !== 'fresh') {
|
||||
const run = await enqueueResource({
|
||||
scope: 'dictionary',
|
||||
scopeKey: `dictionary:${dictionary.attribute_id}`,
|
||||
categoryId,
|
||||
attributeId: dictionary.attribute_id,
|
||||
priority: status === 'stale' ? 'low' : 'high',
|
||||
});
|
||||
state = withRun(state, run);
|
||||
if (status === 'expired' || status === 'missing')
|
||||
blockingRunIds.push(run.id);
|
||||
} else if (
|
||||
status === 'expired' ||
|
||||
status === 'missing'
|
||||
) {
|
||||
if (state.refresh_run_id)
|
||||
blockingRunIds.push(state.refresh_run_id);
|
||||
}
|
||||
dictionaries.push({
|
||||
...dictionary,
|
||||
cache: metadata(state, status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema: metadata(schemaState, schemaStatus),
|
||||
dictionaries,
|
||||
runs,
|
||||
blockingRunIds: [...new Set(blockingRunIds)],
|
||||
hasBlockingResources:
|
||||
!schemaUsable ||
|
||||
dictionaries.some(
|
||||
(item) =>
|
||||
item.cache.status === 'expired' ||
|
||||
item.cache.status === 'missing',
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 发布入口只能选择当前 profile 下尚未首次导入的活动 SKU。 */
|
||||
private async requirePublishableVariants(
|
||||
transaction: DatabaseTransaction,
|
||||
profile: ProfileRow,
|
||||
variantIds: string[],
|
||||
) {
|
||||
const result = await transaction.query<{ id: string; status: string }>(
|
||||
`SELECT variant.id, listing.status
|
||||
FROM atlas.product_variants variant
|
||||
JOIN atlas.product_variant_platform_listings listing
|
||||
ON listing.product_variant_id = variant.id
|
||||
AND listing.profile_id = $1
|
||||
WHERE variant.product_id = $2 AND variant.deleted_at IS NULL
|
||||
AND variant.id = ANY($3::uuid[])`,
|
||||
[profile.id, profile.product_id, variantIds],
|
||||
);
|
||||
if (result.rows.length !== new Set(variantIds).size)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'待发布 SKU 不属于当前商品,或已经被删除',
|
||||
);
|
||||
if (result.rows.some((item) => item.status === 'submitting'))
|
||||
throw new ApiError(
|
||||
409,
|
||||
'SKU_IMPORT_IN_PROGRESS',
|
||||
'所选 SKU 正在首次上架,请等待当前任务完成',
|
||||
);
|
||||
if (result.rows.some((item) => item.status === 'imported'))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SKU_ALREADY_IMPORTED',
|
||||
'所选 SKU 已经首次导入,不能再次上架',
|
||||
);
|
||||
if (
|
||||
result.rows.some(
|
||||
(item) => !['not_listed', 'failed'].includes(item.status),
|
||||
)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SKU_NOT_PUBLISHABLE',
|
||||
'所选 SKU 当前状态不允许首次上架',
|
||||
);
|
||||
}
|
||||
|
||||
/** 从已确认草稿中提取本次 SKU 集合真正使用的字典属性。 */
|
||||
private async usedDictionaries(
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
variantIds: string[],
|
||||
schema: ProductAttributeSchema[],
|
||||
): Promise<UsedDictionary[]> {
|
||||
const definitions = new Map(
|
||||
schema
|
||||
.filter(
|
||||
(item) =>
|
||||
item.value_type === 'dictionary' &&
|
||||
item.dictionary_id &&
|
||||
!item.editor_field,
|
||||
)
|
||||
.map((item) => [
|
||||
`${item.platform_attribute_id}\u0000${item.complex_id}`,
|
||||
item,
|
||||
]),
|
||||
);
|
||||
const attributes = await transaction.query<{
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: unknown;
|
||||
}>(
|
||||
`SELECT platform_attribute_id, complex_id, values
|
||||
FROM atlas.product_attributes
|
||||
WHERE profile_id = $1 AND confirmation_status = 'confirmed'
|
||||
AND (scope = 'common' OR product_variant_id = ANY($2::uuid[]))`,
|
||||
[profileId, variantIds],
|
||||
);
|
||||
const used = new Map<string, UsedDictionary>();
|
||||
for (const attribute of attributes.rows) {
|
||||
if (!hasStringValue(attribute.values)) continue;
|
||||
const definition = definitions.get(
|
||||
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
|
||||
);
|
||||
if (!definition || used.has(definition.attribute_id)) continue;
|
||||
used.set(definition.attribute_id, {
|
||||
attribute_id: definition.attribute_id,
|
||||
platform_attribute_id: definition.platform_attribute_id,
|
||||
name: definition.name,
|
||||
});
|
||||
}
|
||||
return [...used.values()];
|
||||
}
|
||||
|
||||
private async resourceState(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
scope: 'attributes' | 'dictionary',
|
||||
scopeKey: string,
|
||||
): Promise<ResourceState> {
|
||||
const result = await transaction.query<ResourceState>(
|
||||
`SELECT state.last_success_at,
|
||||
coalesce(state.is_complete, false) AS is_complete,
|
||||
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
|
||||
ELSE policy.dictionary_fresh_days END AS fresh_days,
|
||||
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
|
||||
ELSE policy.dictionary_expire_days END AS expire_days,
|
||||
run.id AS refresh_run_id, run.status AS refresh_status
|
||||
FROM atlas.platform_catalog_refresh_policies policy
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = policy.platform_id AND state.scope_key = $3
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, status
|
||||
FROM atlas.platform_catalog_sync_runs
|
||||
WHERE platform_id = $1 AND scope_key = $3
|
||||
AND status IN ('queued', 'running')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) run ON true
|
||||
WHERE policy.platform_id = $1`,
|
||||
[platformId, scope, scopeKey],
|
||||
);
|
||||
if (!result.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATALOG_POLICY_REQUIRED',
|
||||
'平台目录刷新策略尚未初始化',
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/** 多个高优先级资源共用同一个五秒窗口,避免字典数量线性放大请求时长。 */
|
||||
private async waitForRuns(runIds: string[], deadline: number) {
|
||||
while (Date.now() < deadline) {
|
||||
const result = await this.database.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT id, status FROM atlas.platform_catalog_sync_runs
|
||||
WHERE id = ANY($1::uuid[])`,
|
||||
[runIds],
|
||||
);
|
||||
const statusById = new Map(
|
||||
result.rows.map((item) => [item.id, item.status]),
|
||||
);
|
||||
const statuses = runIds.map(
|
||||
(id) => statusById.get(id) ?? 'cancelled',
|
||||
);
|
||||
if (statuses.every((status) => status === 'succeeded')) return true;
|
||||
if (
|
||||
statuses.some((status) =>
|
||||
['failed', 'cancelled'].includes(status),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 等待结束后重新读取任务状态,避免响应继续展示排队时的旧快照。 */
|
||||
private async currentRuns(runIds: string[]) {
|
||||
if (!runIds.length) return [];
|
||||
const result = await this.database.query<PlatformCatalogRunRow>(
|
||||
`SELECT id, platform_id, scope, scope_key, status, trigger_kind,
|
||||
priority, created_at
|
||||
FROM atlas.platform_catalog_sync_runs
|
||||
WHERE id = ANY($1::uuid[])
|
||||
ORDER BY created_at, id`,
|
||||
[runIds],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRuns(
|
||||
target: Map<string, PlatformCatalogRunRow>,
|
||||
runs: PlatformCatalogRunRow[],
|
||||
) {
|
||||
for (const run of runs) target.set(run.id, run);
|
||||
}
|
||||
|
||||
function toRefreshRun(run: PlatformCatalogRunRow): ProductCatalogRefreshRun {
|
||||
return {
|
||||
id: run.id,
|
||||
platform_id: run.platform_id,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
trigger_kind: run.trigger_kind,
|
||||
priority: run.priority,
|
||||
created_at: new Date(run.created_at).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function hasStringValue(value: unknown) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.some((item) => typeof item === 'string' && item.trim())
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,12 @@ import type {
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
Operation,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import {
|
||||
type OperationRow,
|
||||
type ProductRow,
|
||||
type ProfileRow,
|
||||
profileSelect,
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
||||
import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
||||
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
||||
import {
|
||||
latestProductAiSourceMaterial,
|
||||
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
export class ProductEditorService {
|
||||
private readonly catalog: ProductEditorCatalogOperations;
|
||||
private readonly validationOperations: ProductEditorValidationOperations;
|
||||
private readonly publishPreflightOperations: ProductEditorPublishPreflightOperations;
|
||||
|
||||
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
||||
public constructor(
|
||||
@@ -48,13 +48,10 @@ export class ProductEditorService {
|
||||
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
||||
requireProfileForPlatform: (...args) =>
|
||||
this.requireProfileForPlatform(...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,
|
||||
@@ -66,6 +63,17 @@ export class ProductEditorService {
|
||||
this.attributeSchemaForCategory(...args),
|
||||
},
|
||||
);
|
||||
this.publishPreflightOperations =
|
||||
new ProductEditorPublishPreflightOperations(this.database, {
|
||||
requireProfileForPlatform: (...args) =>
|
||||
this.requireProfileForPlatform(...args),
|
||||
requireActiveCategory: (...args) =>
|
||||
this.requireActiveCategory(...args),
|
||||
attributeSchemaForCategory: (...args) =>
|
||||
this.attributeSchemaForCategory(...args),
|
||||
validation: (...args) =>
|
||||
this.validationOperations.validation(...args),
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建或复用平台发布配置,并返回编辑器导航上下文。 */
|
||||
@@ -405,7 +413,7 @@ export class ProductEditorService {
|
||||
)
|
||||
: [],
|
||||
category: {
|
||||
id: profile.store_product_category_id,
|
||||
id: profile.platform_product_category_id,
|
||||
platform_type_id: profile.platform_type_id,
|
||||
source: profile.category_source,
|
||||
name: profile.category_name,
|
||||
@@ -468,37 +476,35 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取发布配置当前或指定目标店铺的缓存类目。 */
|
||||
/** 读取当前平台共享的缓存类目。 */
|
||||
async categoryOptions(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
search: string | undefined,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.catalog.categoryOptions(
|
||||
workspaceId,
|
||||
profileId,
|
||||
platformId,
|
||||
search,
|
||||
storeId,
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取有效启用类目的缓存动态属性定义。 */
|
||||
async attributeSchema(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.catalog.attributeSchema(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
categoryId,
|
||||
storeId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -515,26 +521,43 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 仅从店铺本地缓存查询当前类目的字典值。 */
|
||||
async attributeDictionary(
|
||||
/** 为指定待发布 SKU 定向刷新目录依赖并执行最终本地校验。 */
|
||||
async publishPreflight(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
variantIds: string[],
|
||||
) {
|
||||
return this.publishPreflightOperations.preflight(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
variantIds,
|
||||
);
|
||||
}
|
||||
|
||||
/** 从平台级缓存查询当前类型属性的字典值。 */
|
||||
async attributeDictionary(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
attributeId: string,
|
||||
search: string | undefined,
|
||||
cursor: string | undefined,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.catalog.attributeDictionary(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
dictionaryId,
|
||||
attributeId,
|
||||
search,
|
||||
cursor,
|
||||
categoryId,
|
||||
storeId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -565,7 +588,7 @@ export class ProductEditorService {
|
||||
platformId: string,
|
||||
key: string,
|
||||
categoryId: string,
|
||||
dictionaryId: string,
|
||||
attributeId: string,
|
||||
query: string,
|
||||
) {
|
||||
return this.catalog.requestAttributeDictionarySync(
|
||||
@@ -575,7 +598,7 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
key,
|
||||
categoryId,
|
||||
dictionaryId,
|
||||
attributeId,
|
||||
query,
|
||||
);
|
||||
}
|
||||
@@ -629,18 +652,21 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
input.profile.store_product_category_id === undefined
|
||||
? profile.store_product_category_id
|
||||
: input.profile.store_product_category_id;
|
||||
await this.resolveSelectedPlatformCategoryId(
|
||||
transaction,
|
||||
profile,
|
||||
input.profile.platform_product_category_id,
|
||||
input.profile.store_product_category_id,
|
||||
);
|
||||
const selectedCategory = selectedCategoryId
|
||||
? await this.requireActiveCategory(
|
||||
transaction,
|
||||
input.profile.target_store_id,
|
||||
platformId,
|
||||
selectedCategoryId,
|
||||
)
|
||||
: null;
|
||||
const categoryChanged =
|
||||
selectedCategoryId !== profile.store_product_category_id;
|
||||
selectedCategoryId !== profile.platform_product_category_id;
|
||||
const categorySource = selectedCategory
|
||||
? categoryChanged
|
||||
? 'manual'
|
||||
@@ -699,16 +725,24 @@ export class ProductEditorService {
|
||||
input.ai_source_material,
|
||||
input.ai_source_overridden_fields,
|
||||
);
|
||||
const compatibleStoreCategoryId = selectedCategory
|
||||
? await this.findCompatibleStoreCategoryId(
|
||||
transaction,
|
||||
input.profile.target_store_id,
|
||||
selectedCategory.id,
|
||||
)
|
||||
: null;
|
||||
|
||||
const updatedProfile = await transaction.query<{
|
||||
revision: number;
|
||||
}>(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
||||
tags = $5::jsonb, store_product_category_id = $6,
|
||||
platform_type_id = $7, category_source = $8,
|
||||
tags = $5::jsonb, platform_product_category_id = $6,
|
||||
store_product_category_id = $7, platform_type_id = $8,
|
||||
category_source = $9,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $9 AND product_id = $10 AND platform_id = $11 AND revision = $12
|
||||
WHERE id = $10 AND product_id = $11 AND platform_id = $12 AND revision = $13
|
||||
RETURNING revision`,
|
||||
[
|
||||
input.profile.target_store_id,
|
||||
@@ -717,6 +751,7 @@ export class ProductEditorService {
|
||||
input.profile.description,
|
||||
JSON.stringify(input.profile.tags),
|
||||
selectedCategory?.id ?? null,
|
||||
compatibleStoreCategoryId,
|
||||
selectedCategory?.external_type_id ?? null,
|
||||
categorySource,
|
||||
profile.id,
|
||||
@@ -951,7 +986,6 @@ export class ProductEditorService {
|
||||
productId,
|
||||
profile,
|
||||
platformId,
|
||||
targetStoreId: input.profile.target_store_id,
|
||||
categoryId: selectedCategory?.id ?? null,
|
||||
attributes: input.attributes,
|
||||
retainedVariantIds: retainedIds,
|
||||
@@ -1175,7 +1209,6 @@ export class ProductEditorService {
|
||||
productId: string;
|
||||
profile: ProfileRow;
|
||||
platformId: string;
|
||||
targetStoreId: string;
|
||||
categoryId: string | null;
|
||||
attributes: SaveProductEditorDraftRequest['attributes'];
|
||||
retainedVariantIds: string[];
|
||||
@@ -1197,15 +1230,15 @@ export class ProductEditorService {
|
||||
a.confirmation_status,
|
||||
coalesce(schema.name, a.platform_attribute_id) AS original_name
|
||||
FROM atlas.product_attributes a
|
||||
LEFT JOIN atlas.store_product_category_attributes schema
|
||||
ON schema.store_id = $1
|
||||
AND schema.store_product_category_id IS NOT DISTINCT FROM $2::uuid
|
||||
LEFT JOIN atlas.platform_product_category_attributes schema
|
||||
ON schema.platform_id = $1
|
||||
AND schema.platform_product_category_id IS NOT DISTINCT FROM $2::uuid
|
||||
AND schema.platform_attribute_id = a.platform_attribute_id
|
||||
AND schema.complex_id = a.complex_id
|
||||
WHERE a.profile_id = $3`,
|
||||
[
|
||||
input.profile.target_store_id,
|
||||
input.profile.store_product_category_id,
|
||||
input.platformId,
|
||||
input.profile.platform_product_category_id,
|
||||
input.profile.id,
|
||||
],
|
||||
);
|
||||
@@ -1218,9 +1251,43 @@ export class ProductEditorService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const schemaUsable = await this.platformResourceUsable(
|
||||
transaction,
|
||||
input.platformId,
|
||||
`attributes:${input.categoryId}`,
|
||||
'attributes',
|
||||
);
|
||||
if (!schemaUsable) {
|
||||
const categoryUnchanged =
|
||||
input.categoryId === input.profile.platform_product_category_id;
|
||||
const submitted = (input.attributes ?? []).filter(
|
||||
(attribute) =>
|
||||
!attribute.variant_id ||
|
||||
input.retainedVariantIds.includes(attribute.variant_id),
|
||||
);
|
||||
const retainedExisting = existing.rows.filter(
|
||||
(attribute) =>
|
||||
!attribute.product_variant_id ||
|
||||
input.retainedVariantIds.includes(
|
||||
attribute.product_variant_id,
|
||||
),
|
||||
);
|
||||
if (
|
||||
!categoryUnchanged ||
|
||||
!sameAttributeDrafts(submitted, retainedExisting)
|
||||
) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'ATTRIBUTE_SCHEMA_REFRESH_PENDING',
|
||||
'Ozon 类目属性正在更新,当前只能保留已有属性草稿,请稍后重试',
|
||||
);
|
||||
}
|
||||
// 过期 schema 不能校验或改写属性;其他商品字段仍允许独立保存。
|
||||
return;
|
||||
}
|
||||
const schema = await this.attributeSchemaForCategory(
|
||||
transaction,
|
||||
input.profile.target_store_id,
|
||||
input.platformId,
|
||||
input.categoryId,
|
||||
);
|
||||
const schemaByKey = new Map(
|
||||
@@ -1323,10 +1390,13 @@ export class ProductEditorService {
|
||||
}
|
||||
await this.assertAttributeValues(
|
||||
transaction,
|
||||
input.targetStoreId,
|
||||
input.categoryId,
|
||||
input.platformId,
|
||||
definition,
|
||||
attribute.values,
|
||||
sameStringValues(
|
||||
existingByKey.get(savedKey)?.values,
|
||||
attribute.values,
|
||||
),
|
||||
);
|
||||
valid.push(attribute);
|
||||
}
|
||||
@@ -1353,15 +1423,17 @@ export class ProductEditorService {
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.product_unmapped_attributes
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id,
|
||||
original_store_product_category_id, original_platform_attribute_id,
|
||||
original_complex_id, original_name, values, language_code, reason)
|
||||
original_platform_category_id, original_store_product_category_id,
|
||||
original_platform_attribute_id, original_complex_id, original_name,
|
||||
values, language_code, reason)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||
$5, $6, $7, $8, $9::jsonb, $10, 'category_changed')`,
|
||||
$5, $6, $7, $8, $9, $10::jsonb, $11, 'category_changed')`,
|
||||
[
|
||||
input.productId,
|
||||
input.profile.id,
|
||||
input.platformId,
|
||||
attribute.product_variant_id,
|
||||
input.profile.platform_product_category_id,
|
||||
input.profile.store_product_category_id,
|
||||
attribute.platform_attribute_id,
|
||||
attribute.complex_id,
|
||||
@@ -1402,12 +1474,12 @@ export class ProductEditorService {
|
||||
/** 校验草稿属性的类型、数量、字典值与 SKU 归属。 */
|
||||
private async assertAttributeValues(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
categoryId: string,
|
||||
platformId: string,
|
||||
definition: Awaited<
|
||||
ReturnType<ProductEditorService['attributeSchemaForCategory']>
|
||||
>[number],
|
||||
values: string[],
|
||||
unchanged: boolean,
|
||||
) {
|
||||
if (
|
||||
values.length > definition.max_value_count ||
|
||||
@@ -1447,13 +1519,27 @@ export class ProductEditorService {
|
||||
);
|
||||
if (definition.value_type !== 'dictionary' || !definition.dictionary_id)
|
||||
return;
|
||||
if (
|
||||
!unchanged &&
|
||||
!(await this.platformResourceUsable(
|
||||
transaction,
|
||||
platformId,
|
||||
`dictionary:${definition.attribute_id}`,
|
||||
'dictionary',
|
||||
))
|
||||
) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'ATTRIBUTE_DICTIONARY_REFRESH_PENDING',
|
||||
`${definition.name} 的 Ozon 候选值正在更新,请稍后重试`,
|
||||
);
|
||||
}
|
||||
const dictionary = await transaction.query<{ value_id: string }>(
|
||||
`SELECT external_value_id AS value_id
|
||||
FROM atlas.store_attribute_dictionary_values
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3 AND is_active
|
||||
AND external_value_id = ANY($4::text[])`,
|
||||
[storeId, categoryId, definition.dictionary_id, values],
|
||||
FROM atlas.platform_catalog_dictionary_values
|
||||
WHERE attribute_id = $1 AND is_active
|
||||
AND external_value_id = ANY($2::text[])`,
|
||||
[definition.attribute_id, values],
|
||||
);
|
||||
if (
|
||||
new Set(dictionary.rows.map((item) => item.value_id)).size !==
|
||||
@@ -1466,13 +1552,14 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 返回店铺有效类目的标准化属性定义。 */
|
||||
/** 返回平台有效类型的标准化属性定义。 */
|
||||
private async attributeSchemaForCategory(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{
|
||||
attribute_id: string;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
name: string;
|
||||
@@ -1484,150 +1571,35 @@ export class ProductEditorService {
|
||||
dictionary_id: string | null;
|
||||
is_aspect: boolean;
|
||||
}>(
|
||||
`SELECT platform_attribute_id, complex_id, name, value_type,
|
||||
`SELECT id AS attribute_id, platform_attribute_id, complex_id, name, value_type,
|
||||
is_required, is_collection, max_value_count, dictionary_id, is_aspect
|
||||
FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2 AND is_active
|
||||
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
||||
[storeId, categoryId],
|
||||
[platformId, categoryId],
|
||||
);
|
||||
return bindEditorFields(result.rows);
|
||||
}
|
||||
|
||||
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
|
||||
private async requestCatalogSync(input: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
profileId: string;
|
||||
platformId: string;
|
||||
key: string;
|
||||
scope: 'attributes' | 'dictionary';
|
||||
path: string;
|
||||
categoryId?: string;
|
||||
dictionaryId?: string;
|
||||
query?: string;
|
||||
}) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
method: 'POST',
|
||||
path: `/api/product-platform-profiles/${input.profileId}/${input.path}`,
|
||||
key: input.key,
|
||||
request: {
|
||||
platform_id: input.platformId,
|
||||
category_id: input.categoryId ?? null,
|
||||
dictionary_id: input.dictionaryId ?? null,
|
||||
query: input.query ?? null,
|
||||
},
|
||||
},
|
||||
async (transaction) => {
|
||||
const profile = await this.requireProfileForPlatform(
|
||||
transaction,
|
||||
input.profileId,
|
||||
input.platformId,
|
||||
);
|
||||
if (!input.categoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
await this.requireActiveCategory(
|
||||
transaction,
|
||||
profile.target_store_id,
|
||||
input.categoryId,
|
||||
);
|
||||
if (input.scope === 'dictionary') {
|
||||
const allowed = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3 LIMIT 1`,
|
||||
[
|
||||
profile.target_store_id,
|
||||
input.categoryId,
|
||||
input.dictionaryId,
|
||||
],
|
||||
);
|
||||
if (!allowed.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'ATTRIBUTE_DICTIONARY_INVALID',
|
||||
'字典不属于当前类目的属性',
|
||||
);
|
||||
}
|
||||
const source = await transaction.query<{
|
||||
store_id: string;
|
||||
secret_id: string;
|
||||
platform_code: string;
|
||||
}>(
|
||||
`SELECT store.id AS store_id, secret.id AS secret_id, platform.code AS platform_code
|
||||
FROM atlas.stores store
|
||||
JOIN atlas.platforms platform ON platform.id = store.platform_id
|
||||
JOIN atlas.store_secrets secret ON secret.store_id = store.id
|
||||
WHERE store.id = $1 AND store.platform_id = $2
|
||||
AND store.deleted_at IS NULL AND store.status <> 'disabled'
|
||||
AND store.credential_status = 'valid'
|
||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||
LIMIT 1`,
|
||||
[profile.target_store_id, profile.platform_id],
|
||||
);
|
||||
const store = source.rows[0];
|
||||
if (!store)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'目标店铺凭据尚未验证通过,无法同步 Ozon 类目数据',
|
||||
);
|
||||
if (store.platform_code.toUpperCase() !== 'OZON')
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_CATALOG_UNSUPPORTED',
|
||||
'当前平台暂不支持类目缓存同步',
|
||||
);
|
||||
const kinds = {
|
||||
attributes: 'store_category_attribute_sync',
|
||||
dictionary: 'store_attribute_dictionary_sync',
|
||||
} as const;
|
||||
const eventTypes = {
|
||||
attributes: 'store_category_attribute_sync.requested',
|
||||
dictionary: 'store_attribute_dictionary_sync.requested',
|
||||
} as const;
|
||||
const created = await transaction.query<OperationRow>(
|
||||
`INSERT INTO atlas.operations
|
||||
(workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'product_platform_profile', $2, $3)
|
||||
RETURNING id, kind, status, created_at, completed_at, error_code, error_message`,
|
||||
[kinds[input.scope], profile.id, input.userId],
|
||||
);
|
||||
const operation = toEditorOperation(created.rows[0]);
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.outbox_events
|
||||
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'product_platform_profile', $1, $2, $3::jsonb, $4)`,
|
||||
[
|
||||
profile.id,
|
||||
eventTypes[input.scope],
|
||||
JSON.stringify({
|
||||
store_id: store.store_id,
|
||||
secret_id: store.secret_id,
|
||||
operation_id: operation.id,
|
||||
platform_id: profile.platform_id,
|
||||
scope: input.scope,
|
||||
...(input.categoryId
|
||||
? { category_id: input.categoryId }
|
||||
: {}),
|
||||
...(input.dictionaryId
|
||||
? { dictionary_id: input.dictionaryId }
|
||||
: {}),
|
||||
...(input.query ? { query: input.query } : {}),
|
||||
}),
|
||||
operation.id,
|
||||
],
|
||||
);
|
||||
return { status: 202, body: { data: { operation } } };
|
||||
},
|
||||
/** expired/missing 平台资源不能用于新值校验,但旧草稿仍可原样保留。 */
|
||||
private async platformResourceUsable(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
scopeKey: string,
|
||||
scope: 'attributes' | 'dictionary',
|
||||
) {
|
||||
const result = await transaction.query<{ usable: boolean }>(
|
||||
`SELECT state.last_success_at IS NOT NULL
|
||||
AND state.last_success_at >= now() - make_interval(days =>
|
||||
CASE WHEN $3 = 'attributes' THEN policy.attribute_expire_days
|
||||
ELSE policy.dictionary_expire_days END) AS usable
|
||||
FROM atlas.platform_catalog_refresh_policies policy
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = policy.platform_id AND state.scope_key = $2
|
||||
WHERE policy.platform_id = $1`,
|
||||
[platformId, scopeKey, scope],
|
||||
);
|
||||
return result.rows[0]?.usable === true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1639,7 +1611,7 @@ export class ProductEditorService {
|
||||
product: ProductRow,
|
||||
profile: ProfileRow,
|
||||
): Promise<ProfileRow> {
|
||||
if (profile.store_product_category_id) return profile;
|
||||
if (profile.platform_product_category_id) return profile;
|
||||
const categories = latestProductAiSourceMaterial(product).categories;
|
||||
const sourcePath = normalizeCapturedCategoryPath(categories);
|
||||
if (!sourcePath) return profile;
|
||||
@@ -1659,22 +1631,22 @@ export class ProductEditorService {
|
||||
external_type_id: string | null;
|
||||
}>(
|
||||
`SELECT id, external_type_id
|
||||
FROM atlas.store_product_categories
|
||||
WHERE store_id = $1 AND is_active
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1 AND is_active
|
||||
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
|
||||
ORDER BY id
|
||||
LIMIT 1`,
|
||||
[profile.target_store_id, sourcePath],
|
||||
[profile.platform_id, sourcePath],
|
||||
);
|
||||
const matched = category.rows[0];
|
||||
if (!matched) return profile;
|
||||
|
||||
const updated = await transaction.query<{ id: string }>(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET store_product_category_id = $1, platform_type_id = $2,
|
||||
SET platform_product_category_id = $1, platform_type_id = $2,
|
||||
category_source = 'source_capture', revision = revision + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $3 AND store_product_category_id IS NULL
|
||||
WHERE id = $3 AND platform_product_category_id IS NULL
|
||||
RETURNING id`,
|
||||
[matched.id, matched.external_type_id, profile.id],
|
||||
);
|
||||
@@ -1683,10 +1655,61 @@ export class ProductEditorService {
|
||||
: profile;
|
||||
}
|
||||
|
||||
/** 确认类目属于目标店铺且仍处于启用状态。 */
|
||||
private async requireActiveCategory(
|
||||
/** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */
|
||||
private async resolveSelectedPlatformCategoryId(
|
||||
transaction: DatabaseTransaction,
|
||||
profile: ProfileRow,
|
||||
platformCategoryId: string | null | undefined,
|
||||
legacyStoreCategoryId: string | null | undefined,
|
||||
) {
|
||||
if (platformCategoryId !== undefined) return platformCategoryId;
|
||||
if (legacyStoreCategoryId === undefined)
|
||||
return profile.platform_product_category_id;
|
||||
if (legacyStoreCategoryId === null) return null;
|
||||
const mapped = await transaction.query<{ id: string }>(
|
||||
`SELECT platform_category.id
|
||||
FROM atlas.store_product_categories store_category
|
||||
JOIN atlas.platform_product_categories platform_category
|
||||
ON platform_category.platform_id = store_category.platform_id
|
||||
AND platform_category.external_category_id = store_category.external_category_id
|
||||
AND platform_category.external_type_id IS NOT DISTINCT FROM store_category.external_type_id
|
||||
WHERE store_category.id = $1 AND store_category.platform_id = $2
|
||||
LIMIT 1`,
|
||||
[legacyStoreCategoryId, profile.platform_id],
|
||||
);
|
||||
if (!mapped.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_INVALID',
|
||||
'旧类目无法映射到当前平台目录,请重新选择类目',
|
||||
);
|
||||
return mapped.rows[0].id;
|
||||
}
|
||||
|
||||
/** 为回滚兼容尽量保留目标店铺对应的旧类目引用,不参与当前读取和校验。 */
|
||||
private async findCompatibleStoreCategoryId(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformCategoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{ id: string }>(
|
||||
`SELECT store_category.id
|
||||
FROM atlas.platform_product_categories platform_category
|
||||
JOIN atlas.store_product_categories store_category
|
||||
ON store_category.platform_id = platform_category.platform_id
|
||||
AND store_category.external_category_id = platform_category.external_category_id
|
||||
AND store_category.external_type_id IS NOT DISTINCT FROM platform_category.external_type_id
|
||||
WHERE platform_category.id = $1 AND store_category.store_id = $2
|
||||
LIMIT 1`,
|
||||
[platformCategoryId, storeId],
|
||||
);
|
||||
return result.rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** 确认类目属于当前平台且仍处于启用状态。 */
|
||||
private async requireActiveCategory(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{
|
||||
@@ -1694,15 +1717,15 @@ export class ProductEditorService {
|
||||
external_type_id: string | null;
|
||||
}>(
|
||||
`SELECT id, external_type_id
|
||||
FROM atlas.store_product_categories
|
||||
WHERE id = $1 AND store_id = $2 AND is_active`,
|
||||
[categoryId, storeId],
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||
[categoryId, platformId],
|
||||
);
|
||||
if (!result.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_INVALID',
|
||||
'类目不存在、已停用或不属于当前店铺',
|
||||
'类目不存在、已停用或不属于当前平台',
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -2084,6 +2107,44 @@ function stringValues(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function sameStringValues(existing: unknown, submitted: string[]) {
|
||||
const current = stringValues(existing);
|
||||
return (
|
||||
current.length === submitted.length &&
|
||||
current.every((value, index) => value === submitted[index])
|
||||
);
|
||||
}
|
||||
|
||||
function sameAttributeDrafts(
|
||||
submitted: SaveProductEditorDraftRequest['attributes'],
|
||||
existing: Array<{
|
||||
scope: 'common' | 'variant';
|
||||
product_variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: unknown;
|
||||
language_code: string | null;
|
||||
}>,
|
||||
) {
|
||||
if (!submitted) return true;
|
||||
if (submitted.length !== existing.length) return false;
|
||||
const byKey = new Map(
|
||||
existing.map((attribute) => [savedAttributeKey(attribute), attribute]),
|
||||
);
|
||||
return submitted.every((attribute) => {
|
||||
const current = byKey.get(
|
||||
savedAttributeKey({
|
||||
scope: attribute.scope,
|
||||
variant_id: attribute.variant_id,
|
||||
platform_attribute_id: attribute.platform_attribute_id,
|
||||
complex_id: attribute.complex_id,
|
||||
language_code: attribute.language_code,
|
||||
}),
|
||||
);
|
||||
return !!current && sameStringValues(current.values, attribute.values);
|
||||
});
|
||||
}
|
||||
|
||||
function listingSummary(
|
||||
row:
|
||||
| {
|
||||
@@ -2120,19 +2181,3 @@ function listingSummary(
|
||||
};
|
||||
return { status: 'not_listed' as const, detail: '等待首次上架' };
|
||||
}
|
||||
|
||||
function toEditorOperation(row: OperationRow): Operation {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
created_at: new Date(row.created_at).toISOString(),
|
||||
completed_at: row.completed_at
|
||||
? new Date(row.completed_at).toISOString()
|
||||
: null,
|
||||
error:
|
||||
row.error_code && row.error_message
|
||||
? { code: row.error_code, message: row.error_message }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProductEditorValidation,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { ProductRow, ProfileRow } from './product-editor.models.js';
|
||||
|
||||
type ValidationAccess = {
|
||||
@@ -17,7 +18,7 @@ type ValidationAccess = {
|
||||
) => Promise<ProductRow>;
|
||||
attributeSchemaForCategory: (
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<ProductAttributeSchema[]>;
|
||||
};
|
||||
@@ -45,6 +46,12 @@ type AttributeValidationRow = {
|
||||
confirmation_status: string;
|
||||
};
|
||||
|
||||
type ScopedValidationIssue = {
|
||||
scope: 'common' | 'variant';
|
||||
variant_id: string | null;
|
||||
issue: ProductEditorValidation['profile']['issues'][number];
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算商品草稿的首次上架就绪状态。
|
||||
* 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。
|
||||
@@ -61,6 +68,7 @@ export class ProductEditorValidationOperations {
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantIds?: string[],
|
||||
): Promise<ProductEditorValidation> {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
@@ -78,16 +86,41 @@ export class ProductEditorValidationOperations {
|
||||
transaction,
|
||||
profile.id,
|
||||
profile.product_id,
|
||||
variantIds,
|
||||
);
|
||||
if (variantIds && variants.length !== new Set(variantIds).size)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'待发布 SKU 不属于当前商品,或已经被删除',
|
||||
);
|
||||
const attributes = await this.readAttributes(
|
||||
transaction,
|
||||
profile.id,
|
||||
variantIds,
|
||||
);
|
||||
const schema = profile.store_product_category_id
|
||||
? await this.access.attributeSchemaForCategory(
|
||||
const schemaUsable = profile.platform_product_category_id
|
||||
? await this.schemaUsable(
|
||||
transaction,
|
||||
profile.target_store_id,
|
||||
profile.store_product_category_id,
|
||||
profile.platform_id,
|
||||
profile.platform_product_category_id,
|
||||
)
|
||||
: false;
|
||||
const schema =
|
||||
profile.platform_product_category_id && schemaUsable
|
||||
? await this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
profile.platform_product_category_id,
|
||||
)
|
||||
: [];
|
||||
|
||||
const dictionaryIssues = schema.length
|
||||
? await this.dictionaryIssues(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
schema,
|
||||
attributes,
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -97,8 +130,24 @@ export class ProductEditorValidationOperations {
|
||||
schema,
|
||||
attributes,
|
||||
);
|
||||
profileIssues.push(
|
||||
...dictionaryIssues
|
||||
.filter((item) => item.scope === 'common')
|
||||
.map((item) => item.issue),
|
||||
);
|
||||
const variantResults = variants.map((variant) =>
|
||||
this.variantResult(variant, schema, attributes),
|
||||
this.variantResult(
|
||||
variant,
|
||||
schema,
|
||||
attributes,
|
||||
dictionaryIssues
|
||||
.filter(
|
||||
(item) =>
|
||||
item.scope === 'variant' &&
|
||||
item.variant_id === variant.id,
|
||||
)
|
||||
.map((item) => item.issue),
|
||||
),
|
||||
);
|
||||
const publishableVariants = variantResults.filter(
|
||||
(variant) => variant.status !== 'not_publishable_again',
|
||||
@@ -119,6 +168,26 @@ export class ProductEditorValidationOperations {
|
||||
);
|
||||
}
|
||||
|
||||
/** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */
|
||||
private async schemaUsable(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{ usable: boolean }>(
|
||||
`SELECT state.last_success_at IS NOT NULL
|
||||
AND state.last_success_at >= now() -
|
||||
make_interval(days => policy.attribute_expire_days) AS usable
|
||||
FROM atlas.platform_catalog_refresh_policies policy
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = policy.platform_id
|
||||
AND state.scope_key = 'attributes:' || $2
|
||||
WHERE policy.platform_id = $1`,
|
||||
[platformId, categoryId],
|
||||
);
|
||||
return result.rows[0]?.usable === true;
|
||||
}
|
||||
|
||||
/** 汇总商品级基础信息与通用类目属性问题。 */
|
||||
private profileIssues(
|
||||
product: ProductRow,
|
||||
@@ -145,13 +214,13 @@ export class ProductEditorValidationOperations {
|
||||
field: 'common.brand',
|
||||
message: '请填写品牌',
|
||||
});
|
||||
if (!profile.store_product_category_id)
|
||||
if (!profile.platform_product_category_id)
|
||||
issues.push({
|
||||
code: 'CATEGORY_REQUIRED',
|
||||
field: 'profile.store_product_category_id',
|
||||
field: 'profile.platform_product_category_id',
|
||||
message: '请选择产品类目',
|
||||
});
|
||||
if (profile.store_product_category_id && !schema.length)
|
||||
if (profile.platform_product_category_id && !schema.length)
|
||||
issues.push({
|
||||
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
|
||||
field: 'attributes',
|
||||
@@ -177,6 +246,7 @@ export class ProductEditorValidationOperations {
|
||||
variant: VariantValidationRow,
|
||||
schema: ProductAttributeSchema[],
|
||||
attributes: AttributeValidationRow[],
|
||||
dictionaryIssues: ProductEditorValidation['profile']['issues'] = [],
|
||||
): ProductEditorValidation['variants'][number] {
|
||||
if (variant.listing_status === 'imported')
|
||||
return {
|
||||
@@ -187,8 +257,15 @@ export class ProductEditorValidationOperations {
|
||||
issues: [],
|
||||
};
|
||||
|
||||
const issues: ProductEditorValidation['variants'][number]['issues'] =
|
||||
[];
|
||||
const issues: ProductEditorValidation['variants'][number]['issues'] = [
|
||||
...dictionaryIssues,
|
||||
];
|
||||
if (variant.listing_status === 'submitting')
|
||||
issues.push({
|
||||
code: 'SKU_IMPORT_IN_PROGRESS',
|
||||
field: `variants.${variant.id}.listing`,
|
||||
message: '该 SKU 正在首次上架,请等待当前任务完成',
|
||||
});
|
||||
if (!variant.offer_id?.trim())
|
||||
issues.push({
|
||||
code: 'OFFER_ID_REQUIRED',
|
||||
@@ -253,6 +330,7 @@ export class ProductEditorValidationOperations {
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
productId: string,
|
||||
variantIds?: string[],
|
||||
) {
|
||||
const variants = await transaction.query<VariantValidationRow>(
|
||||
`SELECT v.id, v.display_name, listing.status AS listing_status,
|
||||
@@ -269,12 +347,13 @@ export class ProductEditorValidationOperations {
|
||||
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
|
||||
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
|
||||
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],
|
||||
[profileId, productId, variantIds ?? null],
|
||||
);
|
||||
return variants.rows;
|
||||
}
|
||||
@@ -283,15 +362,130 @@ export class ProductEditorValidationOperations {
|
||||
private async readAttributes(
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
variantIds?: 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],
|
||||
FROM atlas.product_attributes
|
||||
WHERE profile_id = $1
|
||||
AND ($2::uuid[] IS NULL OR scope = 'common'
|
||||
OR product_variant_id = ANY($2::uuid[]))`,
|
||||
[profileId, variantIds ?? null],
|
||||
);
|
||||
return attributes.rows;
|
||||
}
|
||||
|
||||
/** 发布校验只检查草稿实际引用的字典,并要求平台缓存仍在有效期内。 */
|
||||
private async dictionaryIssues(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
schema: ProductAttributeSchema[],
|
||||
attributes: AttributeValidationRow[],
|
||||
): Promise<ScopedValidationIssue[]> {
|
||||
const definitions = new Map(
|
||||
schema
|
||||
.filter(
|
||||
(item) =>
|
||||
item.value_type === 'dictionary' &&
|
||||
item.dictionary_id &&
|
||||
!item.editor_field,
|
||||
)
|
||||
.map((item) => [
|
||||
`${item.platform_attribute_id}\u0000${item.complex_id}`,
|
||||
item,
|
||||
]),
|
||||
);
|
||||
const used = attributes.flatMap((attribute) => {
|
||||
if (attribute.confirmation_status !== 'confirmed') return [];
|
||||
const values = stringValues(attribute.values).filter((value) =>
|
||||
value.trim(),
|
||||
);
|
||||
const definition = definitions.get(
|
||||
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
|
||||
);
|
||||
return definition && values.length
|
||||
? [{ attribute, definition, values }]
|
||||
: [];
|
||||
});
|
||||
const attributeIds = [
|
||||
...new Set(used.map((item) => item.definition.attribute_id)),
|
||||
];
|
||||
if (!attributeIds.length) return [];
|
||||
|
||||
const states = await transaction.query<{
|
||||
attribute_id: string;
|
||||
usable: boolean;
|
||||
}>(
|
||||
`SELECT requested.attribute_id,
|
||||
state.last_success_at IS NOT NULL
|
||||
AND state.last_success_at >= now() -
|
||||
make_interval(days => policy.dictionary_expire_days) AS usable
|
||||
FROM unnest($2::uuid[]) requested(attribute_id)
|
||||
JOIN atlas.platform_catalog_refresh_policies policy
|
||||
ON policy.platform_id = $1
|
||||
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||
ON state.platform_id = $1
|
||||
AND state.scope_key = 'dictionary:' || requested.attribute_id::text`,
|
||||
[platformId, attributeIds],
|
||||
);
|
||||
const usableByAttribute = new Map(
|
||||
states.rows.map((item) => [item.attribute_id, item.usable]),
|
||||
);
|
||||
const values = await transaction.query<{
|
||||
attribute_id: string;
|
||||
value_id: string;
|
||||
}>(
|
||||
`SELECT attribute_id, external_value_id AS value_id
|
||||
FROM atlas.platform_catalog_dictionary_values
|
||||
WHERE attribute_id = ANY($1::uuid[]) AND is_active`,
|
||||
[attributeIds],
|
||||
);
|
||||
const valueKeys = new Set(
|
||||
values.rows.map(
|
||||
(item) => `${item.attribute_id}\u0000${item.value_id}`,
|
||||
),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const issues: ScopedValidationIssue[] = [];
|
||||
for (const item of used) {
|
||||
const field =
|
||||
item.attribute.scope === 'variant'
|
||||
? `variants.${item.attribute.product_variant_id}.attributes.${item.definition.platform_attribute_id}.${item.definition.complex_id}`
|
||||
: `attributes.${item.definition.platform_attribute_id}.${item.definition.complex_id}`;
|
||||
const issue = usableByAttribute.get(item.definition.attribute_id)
|
||||
? item.values.every((value) =>
|
||||
valueKeys.has(
|
||||
`${item.definition.attribute_id}\u0000${value}`,
|
||||
),
|
||||
)
|
||||
? null
|
||||
: {
|
||||
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
|
||||
field,
|
||||
message: `${item.definition.name}包含已失效的 Ozon 候选值`,
|
||||
platform_attribute_id:
|
||||
item.definition.platform_attribute_id,
|
||||
}
|
||||
: {
|
||||
code: 'ATTRIBUTE_DICTIONARY_REFRESH_PENDING',
|
||||
field,
|
||||
message: `${item.definition.name}的 Ozon 候选值需要更新`,
|
||||
platform_attribute_id:
|
||||
item.definition.platform_attribute_id,
|
||||
};
|
||||
if (!issue) continue;
|
||||
const key = `${item.attribute.scope}\u0000${item.attribute.product_variant_id ?? ''}\u0000${issue.code}\u0000${field}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
issues.push({
|
||||
scope: item.attribute.scope,
|
||||
variant_id: item.attribute.product_variant_id,
|
||||
issue,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
/** 只有已确认且至少包含一个非空字符串的值才满足发布要求。 */
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { describe, it } from 'vitest';
|
||||
import {
|
||||
attributeBatchInputSchema,
|
||||
parseInput,
|
||||
refreshPolicyInputSchema,
|
||||
sourceInputSchema,
|
||||
syncInputSchema,
|
||||
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
|
||||
@@ -46,4 +48,41 @@ describe('平台目录系统管理员边界', () => {
|
||||
workspace_id: 'forbidden',
|
||||
}));
|
||||
});
|
||||
|
||||
it('刷新策略要求过期天数严格大于新鲜天数', () => {
|
||||
assert.deepEqual(parseInput(refreshPolicyInputSchema, {
|
||||
attribute_fresh_days: 14,
|
||||
attribute_expire_days: 21,
|
||||
dictionary_fresh_days: 14,
|
||||
dictionary_expire_days: 21,
|
||||
}), {
|
||||
attribute_fresh_days: 14,
|
||||
attribute_expire_days: 21,
|
||||
dictionary_fresh_days: 14,
|
||||
dictionary_expire_days: 21,
|
||||
});
|
||||
assert.throws(() => parseInput(refreshPolicyInputSchema, {
|
||||
attribute_fresh_days: 21,
|
||||
attribute_expire_days: 21,
|
||||
dictionary_fresh_days: 14,
|
||||
dictionary_expire_days: 21,
|
||||
}));
|
||||
assert.throws(() => parseInput(refreshPolicyInputSchema, {
|
||||
attribute_fresh_days: 14,
|
||||
attribute_expire_days: 21,
|
||||
dictionary_fresh_days: 0,
|
||||
dictionary_expire_days: 21,
|
||||
}));
|
||||
});
|
||||
|
||||
it('属性同步批次只接受三种冻结范围', () => {
|
||||
for (const mode of ['missing_expired', 'missing_stale_expired', 'all'] as const) {
|
||||
assert.deepEqual(parseInput(attributeBatchInputSchema, { mode }), { mode });
|
||||
}
|
||||
assert.throws(() => parseInput(attributeBatchInputSchema, { mode: 'stale_only' }));
|
||||
assert.throws(() => parseInput(attributeBatchInputSchema, {
|
||||
mode: 'all',
|
||||
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'vitest';
|
||||
import { productCatalogCacheStatus } from '../src/product-editor/product-editor.catalog-cache.js';
|
||||
|
||||
describe('商品编辑器平台目录新鲜度边界', () => {
|
||||
const day = 86_400_000;
|
||||
const now = Date.parse('2026-09-20T00:00:00.000Z');
|
||||
|
||||
it('从未成功同步时为 missing', () => {
|
||||
assert.equal(productCatalogCacheStatus(null, 14, 21, now), 'missing');
|
||||
});
|
||||
|
||||
it('14 天前为 fresh,恰好 14 天进入 stale', () => {
|
||||
assert.equal(
|
||||
productCatalogCacheStatus(
|
||||
new Date(now - 14 * day + 1),
|
||||
14,
|
||||
21,
|
||||
now,
|
||||
),
|
||||
'fresh',
|
||||
);
|
||||
assert.equal(
|
||||
productCatalogCacheStatus(new Date(now - 14 * day), 14, 21, now),
|
||||
'stale',
|
||||
);
|
||||
});
|
||||
|
||||
it('恰好 21 天仍 stale,严格超过才 expired', () => {
|
||||
assert.equal(
|
||||
productCatalogCacheStatus(new Date(now - 21 * day), 14, 21, now),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
productCatalogCacheStatus(
|
||||
new Date(now - 21 * day - 1),
|
||||
14,
|
||||
21,
|
||||
now,
|
||||
),
|
||||
'expired',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,28 +56,14 @@ async function seedStoreCategory(
|
||||
path: string,
|
||||
) {
|
||||
return 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`,
|
||||
[first.profileId],
|
||||
);
|
||||
const storeId = profile.rows[0]?.target_store_id;
|
||||
if (!storeId) throw new Error('test profile store is missing');
|
||||
const category = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.store_product_categories
|
||||
(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)
|
||||
`INSERT INTO atlas.platform_product_categories
|
||||
(platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
[
|
||||
storeId,
|
||||
platformId,
|
||||
externalCategoryId,
|
||||
externalTypeId,
|
||||
name,
|
||||
path,
|
||||
],
|
||||
[platformId, externalCategoryId, externalTypeId, name, path],
|
||||
);
|
||||
return { id: category.rows[0].id, storeId };
|
||||
return { id: category.rows[0].id };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,6 +167,40 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_catalog_sync_requests
|
||||
WHERE requested_by_user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_catalog_sync_runs
|
||||
WHERE requested_by_user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_catalog_resource_states
|
||||
WHERE category_id = ANY($1::uuid[])`,
|
||||
[seededCategoryIds],
|
||||
);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_catalog_dictionary_values
|
||||
WHERE platform_product_category_id = ANY($1::uuid[])`,
|
||||
[seededCategoryIds],
|
||||
);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_product_category_id = ANY($1::uuid[])`,
|
||||
[seededCategoryIds],
|
||||
);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_product_categories WHERE id = ANY($1::uuid[])`,
|
||||
[seededCategoryIds],
|
||||
);
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_catalog_sources
|
||||
WHERE updated_by_user_id = $1 AND client_id = 'm35-test-client'`,
|
||||
[userId],
|
||||
);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||
await app.close();
|
||||
});
|
||||
@@ -879,46 +899,73 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'箱包 › 背包 › M3.4 背包',
|
||||
);
|
||||
seededCategoryIds.push(categoryA.id, categoryB.id);
|
||||
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,
|
||||
const seededAttributes = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
(tx) =>
|
||||
tx.query<{ id: string; platform_attribute_id: string }>(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_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)`,
|
||||
($1, $2, 'material', '材质', 'dictionary', true, false, 1, $4, false),
|
||||
($1, $2, 'color', '颜色', 'dictionary', true, false, 1, $5, true),
|
||||
($1, $2, '85', '品牌', 'text', true, false, 1, NULL, false),
|
||||
($1, $2, '9048', '型号名称', 'text', true, false, 1, NULL, false),
|
||||
($1, $2, '8229', '类型', 'text', true, false, 1, NULL, false),
|
||||
($1, $2, '23171', '主题标签', 'text', false, true, 10, NULL, false),
|
||||
($1, $2, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
|
||||
($1, $2, '4191', '简介', 'text', true, false, 1, NULL, false),
|
||||
($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)
|
||||
RETURNING id, platform_attribute_id`,
|
||||
[
|
||||
platformId,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
),
|
||||
);
|
||||
const materialAttributeId = seededAttributes.rows.find(
|
||||
(item) => item.platform_attribute_id === 'material',
|
||||
)!.id;
|
||||
const colorAttributeId = seededAttributes.rows.find(
|
||||
(item) => item.platform_attribute_id === 'color',
|
||||
)!.id;
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_dictionary_values
|
||||
(platform_id, platform_product_category_id, attribute_id, dictionary_id,
|
||||
external_value_id, value)
|
||||
VALUES ($1, $2, $3, $4, 'cotton', '棉'),
|
||||
($1, $2, $5, $6, 'black', '黑色')`,
|
||||
[
|
||||
platformId,
|
||||
categoryA.id,
|
||||
materialAttributeId,
|
||||
materialDictionaryId,
|
||||
colorAttributeId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_resource_states
|
||||
(platform_id, scope, scope_key, category_id, attribute_id,
|
||||
last_success_at, is_complete)
|
||||
VALUES
|
||||
($1, 'attributes', 'attributes:' || $2::uuid, $2::uuid, NULL, now(), true),
|
||||
($1, 'attributes', 'attributes:' || $3::uuid, $3::uuid, NULL, now(), true),
|
||||
($1, 'dictionary', 'dictionary:' || $4::uuid, $2::uuid, $4::uuid, now(), true),
|
||||
($1, 'dictionary', 'dictionary:' || $5::uuid, $2::uuid, $5::uuid, now(), true)`,
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
materialAttributeId,
|
||||
colorAttributeId,
|
||||
],
|
||||
),
|
||||
);
|
||||
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,
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
const categories = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
@@ -968,7 +1015,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
title: before.body.data.profile.title,
|
||||
description: before.body.data.profile.description,
|
||||
tags: before.body.data.profile.tags,
|
||||
store_product_category_id: categoryA.id,
|
||||
platform_product_category_id: categoryA.id,
|
||||
category_source: 'manual',
|
||||
},
|
||||
attributes: [
|
||||
@@ -1024,56 +1071,56 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
assert.equal(schema.response.status, 200);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === 'color',
|
||||
).is_aspect,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '85',
|
||||
).editor_field,
|
||||
'brand',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '9048',
|
||||
).editor_field,
|
||||
'model',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '8229',
|
||||
).editor_field,
|
||||
'category',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '23171',
|
||||
).editor_field,
|
||||
'tags',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '11254',
|
||||
).editor_field,
|
||||
'rich_content',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
schema.body.data.items.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}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.deepEqual(dictionary.body.data.items, [
|
||||
@@ -1081,29 +1128,34 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
]);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id,
|
||||
`INSERT INTO atlas.platform_catalog_dictionary_values
|
||||
(platform_id, platform_product_category_id, attribute_id,
|
||||
dictionary_id, external_value_id, value)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||
SELECT $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],
|
||||
[
|
||||
platformId,
|
||||
categoryA.id,
|
||||
materialAttributeId,
|
||||
materialDictionaryId,
|
||||
],
|
||||
),
|
||||
);
|
||||
const dictionaryPageOne = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?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)}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?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`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}&cursor=invalid`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(invalidDictionaryCursor.response.status, 400);
|
||||
@@ -1254,7 +1306,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
afterSave.body.data.source_material_state.overridden_fields,
|
||||
profile: {
|
||||
...draft.profile,
|
||||
store_product_category_id: categoryB.id,
|
||||
platform_product_category_id: categoryB.id,
|
||||
},
|
||||
attributes: afterSave.body.data.attributes.map(
|
||||
(attribute: any) => ({
|
||||
@@ -1339,7 +1391,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET store_product_category_id = NULL, platform_type_id = NULL,
|
||||
SET platform_product_category_id = NULL,
|
||||
store_product_category_id = NULL, platform_type_id = NULL,
|
||||
category_source = NULL
|
||||
WHERE id = $1`,
|
||||
[first.profileId],
|
||||
@@ -1361,7 +1414,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET store_product_category_id = $1, platform_type_id = 'manual-type',
|
||||
SET platform_product_category_id = $1, platform_type_id = 'manual-type',
|
||||
category_source = 'manual'
|
||||
WHERE id = $2`,
|
||||
[manual.id, first.profileId],
|
||||
@@ -1388,31 +1441,32 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
seededCategoryIds.push(category.id);
|
||||
seededDictionaryIds.push(dictionaryId);
|
||||
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],
|
||||
),
|
||||
const dictionaryAttribute = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
(tx) =>
|
||||
tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES ($1, $2, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $3, false)
|
||||
RETURNING id`,
|
||||
[platformId, category.id, dictionaryId],
|
||||
),
|
||||
);
|
||||
const dictionaryAttributeId = dictionaryAttribute.rows[0].id;
|
||||
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',
|
||||
[first.profileId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||
encryption_key_version, fingerprint, status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||||
$2, $3, 'test', $4, 'valid')`,
|
||||
`INSERT INTO atlas.platform_catalog_sources
|
||||
(platform_id, client_id, secret_ciphertext, secret_nonce,
|
||||
secret_fingerprint, encryption_key_version, updated_by_user_id)
|
||||
VALUES ($1, 'm35-test-client', $2, $3, $4, 'test', $5)
|
||||
ON CONFLICT (platform_id) DO NOTHING`,
|
||||
[
|
||||
profile.rows[0].target_store_id,
|
||||
platformId,
|
||||
Buffer.from('m35-test-ciphertext'),
|
||||
Buffer.from('m35-test-nonce'),
|
||||
randomUUID(),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -1427,12 +1481,9 @@ 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.run.scope, 'attributes');
|
||||
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/${dictionaryAttributeId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
@@ -1442,12 +1493,9 @@ 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.run.scope, 'dictionary_search');
|
||||
const initialDictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryAttributeId}/syncs?category_id=${category.id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
@@ -1459,34 +1507,231 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.equal(initialDictionary.response.status, 202);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const events = await tx.query<{
|
||||
event_type: string;
|
||||
event_payload: unknown;
|
||||
scope: string;
|
||||
scope_key: string;
|
||||
}>(
|
||||
`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],
|
||||
`SELECT run.scope, run.scope_key
|
||||
FROM atlas.platform_catalog_outbox_events event
|
||||
JOIN atlas.platform_catalog_sync_runs run ON run.id = event.run_id
|
||||
WHERE run.requested_by_user_id = $1 AND run.category_id = $2
|
||||
ORDER BY run.created_at`,
|
||||
[userId, category.id],
|
||||
);
|
||||
assert.equal(events.rows.length, 3);
|
||||
assert.deepEqual(
|
||||
events.rows.map((event) => event.event_type).sort(),
|
||||
[
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
'store_category_attribute_sync.requested',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(events.rows.map((event) => event.scope).sort(), [
|
||||
'attributes',
|
||||
'dictionary',
|
||||
'dictionary_search',
|
||||
]);
|
||||
assert.ok(
|
||||
events.rows.every(
|
||||
(event) =>
|
||||
!JSON.stringify(event.event_payload).includes(
|
||||
'm35-test-ciphertext',
|
||||
),
|
||||
!JSON.stringify(event).includes('m35-test-ciphertext'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('发布预检只刷新当前类型和实际使用的字典,并按过期状态提升优先级', async () => {
|
||||
const category = await seedStoreCategory(
|
||||
`publish-preflight-${randomUUID()}`,
|
||||
'publish-preflight-type',
|
||||
'发布预检类目',
|
||||
'测试 › 发布预检类目',
|
||||
);
|
||||
seededCategoryIds.push(category.id);
|
||||
const definitions = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
(tx) =>
|
||||
tx.query<{ id: string; platform_attribute_id: string }>(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_product_category_id,
|
||||
platform_attribute_id, name, value_type, is_required,
|
||||
is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES
|
||||
($1, $2, 'publish-used', '已用字典', 'dictionary', false,
|
||||
false, 1, $3, false),
|
||||
($1, $2, 'publish-unused', '未用字典', 'dictionary', false,
|
||||
false, 1, $4, false)
|
||||
RETURNING id, platform_attribute_id`,
|
||||
[
|
||||
platformId,
|
||||
category.id,
|
||||
`publish-used-${randomUUID()}`,
|
||||
`publish-unused-${randomUUID()}`,
|
||||
],
|
||||
),
|
||||
);
|
||||
const usedAttributeId = definitions.rows.find(
|
||||
(item) => item.platform_attribute_id === 'publish-used',
|
||||
)!.id;
|
||||
const unusedAttributeId = definitions.rows.find(
|
||||
(item) => item.platform_attribute_id === 'publish-unused',
|
||||
)!.id;
|
||||
const editor = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
{
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
},
|
||||
);
|
||||
const variantId = editor.body.data.variants.find(
|
||||
(item: { id: string; listing: { status: string } }) =>
|
||||
['not_listed', 'failed'].includes(item.listing.status),
|
||||
).id as string;
|
||||
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET platform_product_category_id = $1,
|
||||
platform_type_id = 'publish-preflight-type',
|
||||
category_source = 'manual'
|
||||
WHERE id = $2`,
|
||||
[category.id, first.profileId],
|
||||
);
|
||||
await tx.query(
|
||||
`DELETE FROM atlas.product_attributes WHERE profile_id = $1`,
|
||||
[first.profileId],
|
||||
);
|
||||
await 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', 'publish-used', '0', '["used-value"]'::jsonb,
|
||||
'manual', 'confirmed')`,
|
||||
[first.productId, first.profileId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_dictionary_values
|
||||
(platform_id, platform_product_category_id, attribute_id,
|
||||
dictionary_id, external_value_id, value)
|
||||
SELECT $1, $2, id, dictionary_id, 'used-value', '已用候选值'
|
||||
FROM atlas.platform_product_category_attributes WHERE id = $3`,
|
||||
[platformId, category.id, usedAttributeId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_resource_states
|
||||
(platform_id, scope, scope_key, category_id, attribute_id,
|
||||
last_success_at, is_complete)
|
||||
VALUES
|
||||
($1, 'attributes', 'attributes:' || $2::uuid, $2, NULL,
|
||||
now() - interval '15 days', true),
|
||||
($1, 'dictionary', 'dictionary:' || $3::uuid, $2, $3,
|
||||
now() - interval '15 days', true)`,
|
||||
[platformId, category.id, usedAttributeId],
|
||||
);
|
||||
});
|
||||
|
||||
const stale = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/publish-preflights`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
body: JSON.stringify({ variant_ids: [variantId] }),
|
||||
},
|
||||
);
|
||||
assert.equal(stale.response.status, 200);
|
||||
assert.equal(stale.body.data.catalog.schema.status, 'stale');
|
||||
assert.deepEqual(
|
||||
stale.body.data.catalog.dictionaries.map(
|
||||
(item: { attribute_id: string; cache: { status: string } }) => [
|
||||
item.attribute_id,
|
||||
item.cache.status,
|
||||
],
|
||||
),
|
||||
[[usedAttributeId, 'stale']],
|
||||
);
|
||||
assert.equal(stale.body.data.refresh_runs.length, 2);
|
||||
assert.ok(
|
||||
stale.body.data.refresh_runs.every(
|
||||
(run: { priority: string; trigger_kind: string }) =>
|
||||
run.priority === 'low' && run.trigger_kind === 'publish',
|
||||
),
|
||||
);
|
||||
await database.query(
|
||||
`UPDATE atlas.platform_catalog_dictionary_values
|
||||
SET is_active = false WHERE attribute_id = $1`,
|
||||
[usedAttributeId],
|
||||
);
|
||||
const invalidValue = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
invalidValue.body.data.profile.issues.some(
|
||||
(issue: { code: string }) =>
|
||||
issue.code === 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
|
||||
),
|
||||
);
|
||||
await database.query(
|
||||
`UPDATE atlas.platform_catalog_dictionary_values
|
||||
SET is_active = true WHERE attribute_id = $1`,
|
||||
[usedAttributeId],
|
||||
);
|
||||
await database.query(
|
||||
`UPDATE atlas.platform_catalog_resource_states
|
||||
SET last_success_at = now() - interval '22 days'
|
||||
WHERE platform_id = $1
|
||||
AND scope_key = 'dictionary:' || $2::uuid`,
|
||||
[platformId, usedAttributeId],
|
||||
);
|
||||
|
||||
const expired = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/publish-preflights`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
body: JSON.stringify({ variant_ids: [variantId] }),
|
||||
},
|
||||
);
|
||||
assert.equal(expired.response.status, 202);
|
||||
assert.equal(expired.body.data.status, 'refreshing');
|
||||
assert.equal(expired.body.data.catalog.schema.status, 'stale');
|
||||
assert.equal(
|
||||
expired.body.data.catalog.dictionaries[0].cache.status,
|
||||
'expired',
|
||||
);
|
||||
await database.query(
|
||||
`UPDATE atlas.platform_catalog_resource_states
|
||||
SET last_success_at = now() - interval '22 days'
|
||||
WHERE platform_id = $1
|
||||
AND scope_key = 'attributes:' || $2::uuid`,
|
||||
[platformId, category.id],
|
||||
);
|
||||
const expiredSchema = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/publish-preflights`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
body: JSON.stringify({ variant_ids: [variantId] }),
|
||||
},
|
||||
);
|
||||
assert.equal(expiredSchema.response.status, 202);
|
||||
assert.equal(expiredSchema.body.data.catalog.schema.status, 'expired');
|
||||
assert.deepEqual(expiredSchema.body.data.catalog.dictionaries, []);
|
||||
const runs = await database.query<{
|
||||
scope_key: string;
|
||||
priority: string;
|
||||
}>(
|
||||
`SELECT DISTINCT ON (scope_key) scope_key, priority
|
||||
FROM atlas.platform_catalog_sync_runs
|
||||
WHERE platform_id = $1
|
||||
AND scope_key IN ('attributes:' || $2::uuid,
|
||||
'dictionary:' || $3::uuid,
|
||||
'dictionary:' || $4::uuid)
|
||||
ORDER BY scope_key, created_at DESC`,
|
||||
[platformId, category.id, usedAttributeId, unusedAttributeId],
|
||||
);
|
||||
assert.equal(runs.rows.length, 2);
|
||||
assert.ok(runs.rows.every((run) => run.priority === 'high'));
|
||||
assert.ok(
|
||||
!runs.rows.some(
|
||||
(run) => run.scope_key === `dictionary:${unusedAttributeId}`,
|
||||
),
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||
const before = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
@@ -1550,7 +1795,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
title: before.body.data.profile.title,
|
||||
description: before.body.data.profile.description,
|
||||
tags: before.body.data.profile.tags,
|
||||
store_product_category_id: before.body.data.profile.category.id,
|
||||
platform_product_category_id:
|
||||
before.body.data.profile.category.id,
|
||||
category_source: before.body.data.profile.category.source,
|
||||
},
|
||||
attributes: before.body.data.attributes.map((attribute: any) => ({
|
||||
|
||||
Reference in New Issue
Block a user