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 { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
import { DATABASE } from '../database/database.module.js';
|
import { DATABASE } from '../database/database.module.js';
|
||||||
import { encryptStoreSecret } from '../stores/store-secret.crypto.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;
|
id: string;
|
||||||
platform_id: string;
|
platform_id: string;
|
||||||
scope: string;
|
mode: AttributeBatchInput['mode'];
|
||||||
scope_key: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
trigger_kind: string;
|
total_count: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
started_at: Date | null;
|
||||||
|
completed_at: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -71,9 +78,15 @@ export class PlatformCatalogAdminCommands {
|
|||||||
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||||
[platformId],
|
[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,
|
platformId, scope: 'tree', scopeKey: 'tree', sourceRevision: revision,
|
||||||
trigger: 'source_configured', userId,
|
trigger: 'source_configured', priority: 'high', userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -103,9 +116,9 @@ export class PlatformCatalogAdminCommands {
|
|||||||
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
|
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
|
||||||
}
|
}
|
||||||
const target = await this.resolveTarget(tx, platformId, input);
|
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,
|
`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
|
FROM atlas.platform_catalog_sync_requests request
|
||||||
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
|
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`,
|
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
|
||||||
@@ -117,7 +130,7 @@ export class PlatformCatalogAdminCommands {
|
|||||||
}
|
}
|
||||||
return presentRun(prior.rows[0]);
|
return presentRun(prior.rows[0]);
|
||||||
}
|
}
|
||||||
const run = await enqueueRun(tx, {
|
const run = await enqueuePlatformCatalogRun(tx, {
|
||||||
platformId,
|
platformId,
|
||||||
scope: input.scope,
|
scope: input.scope,
|
||||||
scopeKey: target.scopeKey,
|
scopeKey: target.scopeKey,
|
||||||
@@ -125,6 +138,9 @@ export class PlatformCatalogAdminCommands {
|
|||||||
attributeId: target.attributeId,
|
attributeId: target.attributeId,
|
||||||
sourceRevision: source.rows[0].revision,
|
sourceRevision: source.rows[0].revision,
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
|
priority: input.scope === 'tree'
|
||||||
|
? 'high'
|
||||||
|
: await resourcePriority(tx, platformId, input.scope, target.scopeKey),
|
||||||
userId,
|
userId,
|
||||||
idempotencyKey,
|
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) {
|
private async resolveTarget(tx: DatabaseTransaction, platformId: string, input: SyncInput) {
|
||||||
if (input.scope === 'tree') return { scopeKey: 'tree' };
|
if (input.scope === 'tree') return { scopeKey: 'tree' };
|
||||||
if (input.scope === 'attributes') {
|
if (input.scope === 'attributes') {
|
||||||
@@ -167,53 +328,97 @@ export class PlatformCatalogAdminCommands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnqueueInput = {
|
function presentRun(run: PlatformCatalogRunRow) {
|
||||||
platformId: string;
|
|
||||||
scope: 'tree' | 'attributes' | 'dictionary';
|
|
||||||
scopeKey: string;
|
|
||||||
categoryId?: string;
|
|
||||||
attributeId?: string;
|
|
||||||
sourceRevision: number;
|
|
||||||
trigger: 'manual' | 'scheduled' | 'source_configured';
|
|
||||||
userId?: string;
|
|
||||||
idempotencyKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 同一事务写入持久任务和平台 Outbox;活跃作用域复用已有任务。 */
|
|
||||||
export async function enqueueRun(tx: DatabaseTransaction, input: EnqueueInput): Promise<RunRow> {
|
|
||||||
const active = await tx.query<RunRow>(
|
|
||||||
`SELECT id, platform_id, scope, scope_key, status, trigger_kind, created_at
|
|
||||||
FROM atlas.platform_catalog_sync_runs
|
|
||||||
WHERE platform_id = $1 AND scope_key = $2 AND status IN ('queued', 'running')
|
|
||||||
LIMIT 1`,
|
|
||||||
[input.platformId, input.scopeKey],
|
|
||||||
);
|
|
||||||
if (active.rows[0]) return active.rows[0];
|
|
||||||
const inserted = await tx.query<RunRow>(
|
|
||||||
`INSERT INTO atlas.platform_catalog_sync_runs
|
|
||||||
(platform_id, scope, scope_key, category_id, attribute_id, trigger_kind,
|
|
||||||
source_revision, requested_by_user_id, idempotency_key)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
RETURNING id, platform_id, scope, scope_key, status, trigger_kind, created_at`,
|
|
||||||
[input.platformId, input.scope, input.scopeKey, input.categoryId ?? null,
|
|
||||||
input.attributeId ?? null, input.trigger, input.sourceRevision,
|
|
||||||
input.userId ?? null, input.idempotencyKey ?? null],
|
|
||||||
);
|
|
||||||
const run = inserted.rows[0];
|
|
||||||
await tx.query(
|
|
||||||
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id) VALUES ($1, $2)`,
|
|
||||||
[run.id, input.platformId],
|
|
||||||
);
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
function presentRun(run: RunRow) {
|
|
||||||
return {
|
return {
|
||||||
id: run.id,
|
id: run.id,
|
||||||
platform_id: run.platform_id,
|
platform_id: run.platform_id,
|
||||||
scope: run.scope,
|
scope: run.scope,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
trigger: run.trigger_kind,
|
trigger: run.trigger_kind,
|
||||||
|
priority: run.priority,
|
||||||
created_at: run.created_at,
|
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 { DATABASE } from '../database/database.module.js';
|
||||||
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
|
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
|
||||||
import {
|
import {
|
||||||
parseInput, parseListLimit, parseUuid, sourceInputSchema, syncInputSchema,
|
attributeBatchInputSchema, parseInput, parseListLimit, parseUuid,
|
||||||
|
refreshPolicyInputSchema, sourceInputSchema, syncInputSchema,
|
||||||
} from './platform-catalog-admin.models.js';
|
} from './platform-catalog-admin.models.js';
|
||||||
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
||||||
import { hasSystemAdminGrant, SystemAdminGuard } from './system-admin.guard.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')) };
|
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')
|
@Get('platforms/:platformId/categories/:categoryId/attributes')
|
||||||
public async attributes(
|
public async attributes(
|
||||||
@Param('platformId') platformId: string,
|
@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(),
|
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 SyncInput = z.infer<typeof syncInputSchema>;
|
||||||
export type SourceInput = z.infer<typeof sourceInputSchema>;
|
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 {
|
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
|
||||||
const parsed = schema.safeParse(input);
|
const parsed = schema.safeParse(input);
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ export class PlatformCatalogAdminReads {
|
|||||||
const rows = await this.database.query<{
|
const rows = await this.database.query<{
|
||||||
id: string; code: string; name: string; status: string;
|
id: string; code: string; name: string; status: string;
|
||||||
client_id: string | null; source_updated_at: Date | null;
|
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,
|
`SELECT p.id, p.code, p.name, p.status,
|
||||||
source.client_id, source.updated_at AS source_updated_at,
|
source.client_id, source.updated_at AS source_updated_at,
|
||||||
(SELECT count(*) FROM atlas.platform_product_categories category
|
(SELECT count(*) FROM atlas.platform_product_categories category
|
||||||
WHERE category.platform_id = p.id AND category.is_active)::text AS category_count,
|
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
|
tree_state.last_success_at AS last_tree_sync_at,
|
||||||
WHERE run.platform_id = p.id AND run.scope = 'tree' AND run.status = 'succeeded') AS last_tree_sync_at,
|
tree_state.last_success_at + interval '7 days' AS next_tree_sync_at,
|
||||||
(SELECT CASE WHEN run.status = 'failed' THEN run.error_message ELSE NULL END
|
tree_state.last_error_message AS last_error
|
||||||
FROM atlas.platform_catalog_sync_runs run
|
|
||||||
WHERE run.platform_id = p.id AND run.scope = 'tree'
|
|
||||||
ORDER BY run.created_at DESC LIMIT 1) AS last_error
|
|
||||||
FROM atlas.platforms p
|
FROM atlas.platforms p
|
||||||
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
|
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`,
|
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||||
);
|
);
|
||||||
return rows.rows.map((row) => ({
|
return rows.rows.map((row) => ({
|
||||||
@@ -40,6 +40,7 @@ export class PlatformCatalogAdminReads {
|
|||||||
summary: {
|
summary: {
|
||||||
category_count: Number(row.category_count),
|
category_count: Number(row.category_count),
|
||||||
last_tree_sync_at: row.last_tree_sync_at,
|
last_tree_sync_at: row.last_tree_sync_at,
|
||||||
|
next_tree_sync_at: row.next_tree_sync_at,
|
||||||
last_error: row.last_error,
|
last_error: row.last_error,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -48,15 +49,111 @@ export class PlatformCatalogAdminReads {
|
|||||||
public async categories(platformId: string) {
|
public async categories(platformId: string) {
|
||||||
await this.requirePlatform(platformId);
|
await this.requirePlatform(platformId);
|
||||||
const result = await this.database.query(
|
const result = await this.database.query(
|
||||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path,
|
`SELECT category.id, category.parent_id, category.external_category_id,
|
||||||
is_active, synced_at
|
category.external_type_id, category.name, category.path,
|
||||||
FROM atlas.platform_product_categories
|
category.is_active, category.synced_at,
|
||||||
WHERE platform_id = $1 ORDER BY path, id LIMIT 20000`,
|
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],
|
[platformId],
|
||||||
);
|
);
|
||||||
return result.rows;
|
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) {
|
public async attributes(platformId: string, categoryId: string) {
|
||||||
await this.requireCategory(platformId, categoryId);
|
await this.requireCategory(platformId, categoryId);
|
||||||
const result = await this.database.query(
|
const result = await this.database.query(
|
||||||
@@ -65,9 +162,45 @@ export class PlatformCatalogAdminReads {
|
|||||||
attribute.value_type, attribute.is_required, attribute.is_aspect,
|
attribute.value_type, attribute.is_required, attribute.is_aspect,
|
||||||
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
|
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
|
||||||
attribute.category_dependent, attribute.is_active, attribute.synced_at,
|
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
|
(SELECT count(*)::int FROM atlas.platform_catalog_dictionary_values value
|
||||||
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
|
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
|
||||||
FROM atlas.platform_product_category_attributes attribute
|
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
|
WHERE attribute.platform_id = $1 AND attribute.platform_product_category_id = $2
|
||||||
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
|
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
|
||||||
attribute.name, attribute.platform_attribute_id`,
|
attribute.name, attribute.platform_attribute_id`,
|
||||||
@@ -108,10 +241,8 @@ export class PlatformCatalogAdminReads {
|
|||||||
[attributeId, q],
|
[attributeId, q],
|
||||||
),
|
),
|
||||||
this.database.query<{ is_complete: boolean }>(
|
this.database.query<{ is_complete: boolean }>(
|
||||||
`SELECT COALESCE((result->>'is_complete')::boolean, false) AS is_complete
|
`SELECT is_complete FROM atlas.platform_catalog_resource_states
|
||||||
FROM atlas.platform_catalog_sync_runs
|
WHERE platform_id = $1 AND scope_key = $2`,
|
||||||
WHERE platform_id = $1 AND scope_key = $2 AND status = 'succeeded'
|
|
||||||
ORDER BY completed_at DESC LIMIT 1`,
|
|
||||||
[platformId, `dictionary:${attributeId}`],
|
[platformId, `dictionary:${attributeId}`],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -130,7 +261,7 @@ export class PlatformCatalogAdminReads {
|
|||||||
await this.requirePlatform(platformId);
|
await this.requirePlatform(platformId);
|
||||||
const result = await this.database.query(
|
const result = await this.database.query(
|
||||||
`SELECT id, platform_id, scope, scope_key, category_id, attribute_id,
|
`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
|
attempt_count, created_at, started_at, completed_at
|
||||||
FROM atlas.platform_catalog_sync_runs
|
FROM atlas.platform_catalog_sync_runs
|
||||||
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||||
@@ -139,6 +270,32 @@ export class PlatformCatalogAdminReads {
|
|||||||
return result.rows;
|
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) {
|
public async requirePlatform(platformId: string) {
|
||||||
const result = await this.database.query<{ code: string }>(
|
const result = await this.database.query<{ code: string }>(
|
||||||
'SELECT code FROM atlas.platforms WHERE id = $1',
|
'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', '类目不属于当前平台');
|
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 { ProductAttributeSchema } from '@atlas/contracts';
|
||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import { ApiError } from '../common/api-error.js';
|
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';
|
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 };
|
type DictionaryCursor = { value: string; value_id: string; query: string };
|
||||||
|
type RefreshScope = 'attributes' | 'dictionary' | 'dictionary_search';
|
||||||
/** 游标仅记录排序位置;类目、字典及搜索条件仍由当前请求的权限校验约束。 */
|
type RunRow = PlatformCatalogRunRow;
|
||||||
function parseDictionaryCursor(cursor: string | undefined): DictionaryCursor | null {
|
|
||||||
if (!cursor) return null;
|
|
||||||
if (cursor.length > 2_000) throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
||||||
if (
|
|
||||||
parsed && typeof parsed === 'object' &&
|
|
||||||
typeof (parsed as DictionaryCursor).value === 'string' &&
|
|
||||||
typeof (parsed as DictionaryCursor).value_id === 'string' &&
|
|
||||||
typeof (parsed as DictionaryCursor).query === 'string'
|
|
||||||
) return parsed as DictionaryCursor;
|
|
||||||
} catch {
|
|
||||||
// 无效游标统一返回参数错误。
|
|
||||||
}
|
|
||||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
|
|
||||||
}
|
|
||||||
|
|
||||||
type CatalogAccess = {
|
type CatalogAccess = {
|
||||||
requireProfileForPlatform: (
|
requireProfileForPlatform: (
|
||||||
@@ -47,58 +23,67 @@ type CatalogAccess = {
|
|||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
) => Promise<ProfileRow>;
|
) => Promise<ProfileRow>;
|
||||||
requireEditableStore: (
|
|
||||||
transaction: DatabaseTransaction,
|
|
||||||
storeId: string,
|
|
||||||
platformId: string,
|
|
||||||
) => Promise<void>;
|
|
||||||
requireActiveCategory: (
|
requireActiveCategory: (
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
platformId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
attributeSchemaForCategory: (
|
attributeSchemaForCategory: (
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
platformId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) => Promise<ProductAttributeSchema[]>;
|
) => 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 {
|
export class ProductEditorCatalogOperations {
|
||||||
/** 注入工作空间事务入口及共享的权限、归属校验能力。 */
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
private readonly access: CatalogAccess,
|
private readonly access: CatalogAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 读取当前或指定目标店铺的完整类目缓存。 */
|
|
||||||
async categoryOptions(
|
async categoryOptions(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
storeId?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
async (transaction) => {
|
||||||
const profile = await this.access.requireProfileForPlatform(
|
await this.access.requireProfileForPlatform(
|
||||||
transaction,
|
transaction,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
|
||||||
if (storeId)
|
|
||||||
await this.access.requireEditableStore(
|
|
||||||
transaction,
|
|
||||||
storeId,
|
|
||||||
platformId,
|
|
||||||
);
|
|
||||||
const categories = await transaction.query<{
|
const categories = await transaction.query<{
|
||||||
id: string;
|
id: string;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
@@ -109,151 +94,93 @@ export class ProductEditorCatalogOperations {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||||
FROM atlas.store_product_categories
|
FROM atlas.platform_product_categories
|
||||||
WHERE store_id = $1
|
WHERE platform_id = $1
|
||||||
ORDER BY path, name, id
|
ORDER BY path, name, id
|
||||||
LIMIT 10000`,
|
LIMIT 10000`,
|
||||||
[selectedStoreId],
|
[platformId],
|
||||||
);
|
);
|
||||||
// 保留参数以保持既有 HTTP 契约;当前缓存树始终完整返回。
|
// 搜索由 Cascader 在完整平台树上执行,保持父子路径完整。
|
||||||
void search;
|
void search;
|
||||||
return categories.rows;
|
return categories.rows;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前或指定类目的已缓存动态属性定义。 */
|
|
||||||
async attributeSchema(
|
async attributeSchema(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
const prepared = await this.readAttributeSchema(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
userId,
|
||||||
const profile = await this.access.requireProfileForPlatform(
|
|
||||||
transaction,
|
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
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,
|
categoryId,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return this.access.attributeSchemaForCategory(
|
if (!prepared.waitForRunId)
|
||||||
transaction,
|
return { status: 200, body: { data: prepared.result } };
|
||||||
selectedStoreId,
|
await this.waitForRun(prepared.waitForRunId);
|
||||||
selectedCategoryId,
|
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(
|
async attributeDictionary(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
dictionaryId: string,
|
attributeId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
cursor: string | undefined,
|
cursor: string | undefined,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
|
||||||
) {
|
) {
|
||||||
const after = parseDictionaryCursor(cursor);
|
const after = parseDictionaryCursor(cursor);
|
||||||
return this.database.workspaceTransaction(
|
const prepared = await this.readDictionary(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
userId,
|
||||||
const profile = await this.access.requireProfileForPlatform(
|
|
||||||
transaction,
|
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
);
|
attributeId,
|
||||||
const selectedCategoryId =
|
search,
|
||||||
categoryId ?? profile.store_product_category_id;
|
after,
|
||||||
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,
|
categoryId,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
const allowed = await transaction.query<{ dictionary_id: string }>(
|
if (!prepared.waitForRunId)
|
||||||
`SELECT dictionary_id
|
return { status: 200, body: { data: prepared.result } };
|
||||||
FROM atlas.store_product_category_attributes
|
await this.waitForRun(prepared.waitForRunId);
|
||||||
WHERE store_id = $1 AND store_product_category_id = $2
|
const completed = await this.readDictionary(
|
||||||
AND dictionary_id = $3
|
workspaceId,
|
||||||
LIMIT 1`,
|
userId,
|
||||||
[selectedStoreId, selectedCategoryId, dictionaryId],
|
profileId,
|
||||||
);
|
platformId,
|
||||||
if (!allowed.rows[0])
|
attributeId,
|
||||||
throw new ApiError(
|
search,
|
||||||
422,
|
after,
|
||||||
'ATTRIBUTE_DICTIONARY_INVALID',
|
categoryId,
|
||||||
'字典不属于当前类目的属性',
|
false,
|
||||||
);
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
const usable =
|
||||||
|
completed.result.cache.status === 'fresh' ||
|
||||||
|
completed.result.cache.status === 'stale';
|
||||||
|
return { status: usable ? 200 : 202, body: { data: completed.result } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建类目属性定义同步任务。 */
|
|
||||||
async requestAttributeSchemaSync(
|
async requestAttributeSchemaSync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -262,19 +189,33 @@ export class ProductEditorCatalogOperations {
|
|||||||
key: string,
|
key: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
return this.access.requestCatalogSync({
|
const run = await this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
async (transaction) => {
|
||||||
|
await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
key,
|
);
|
||||||
scope: 'attributes',
|
await this.access.requireActiveCategory(
|
||||||
path: 'attribute-schema-syncs',
|
transaction,
|
||||||
|
platformId,
|
||||||
categoryId,
|
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(
|
async requestAttributeDictionarySync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -282,26 +223,435 @@ export class ProductEditorCatalogOperations {
|
|||||||
platformId: string,
|
platformId: string,
|
||||||
key: string,
|
key: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
dictionaryId: string,
|
attributeId: string,
|
||||||
query: string,
|
query: string,
|
||||||
) {
|
) {
|
||||||
if (query.trim().length === 1)
|
const needle = query.trim();
|
||||||
|
if (needle.length === 1)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'DICTIONARY_SEARCH_REQUIRED',
|
'DICTIONARY_SEARCH_REQUIRED',
|
||||||
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
||||||
);
|
);
|
||||||
return this.access.requestCatalogSync({
|
const run = await this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
async (transaction) => {
|
||||||
|
await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
key,
|
);
|
||||||
scope: 'dictionary',
|
await this.access.requireActiveCategory(
|
||||||
path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
transaction,
|
||||||
|
platformId,
|
||||||
categoryId,
|
categoryId,
|
||||||
dictionaryId,
|
);
|
||||||
query: query.trim(),
|
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';
|
} from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
|
productPublishPreflightRequestSchema,
|
||||||
saveProductEditorDraftRequestSchema,
|
saveProductEditorDraftRequestSchema,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
@@ -68,12 +69,11 @@ export class ProductEditorController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回目标店铺已缓存的类目树,供编辑器选择。 */
|
/** 返回当前平台共享的类目树,供编辑器选择。 */
|
||||||
@Get('product-platform-profiles/:profileId/category-options')
|
@Get('product-platform-profiles/:profileId/category-options')
|
||||||
async categoryOptions(
|
async categoryOptions(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Query('q') query: string | undefined,
|
@Query('q') query: string | undefined,
|
||||||
@Query('store_id') storeId: string | undefined,
|
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
@@ -83,7 +83,6 @@ export class ProductEditorController {
|
|||||||
requiredUuid('profile_id', profileId),
|
requiredUuid('profile_id', profileId),
|
||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
query,
|
query,
|
||||||
optionalUuid('store_id', storeId),
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -93,19 +92,20 @@ export class ProductEditorController {
|
|||||||
async attributeSchema(
|
async attributeSchema(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Query('category_id') categoryId: string | undefined,
|
@Query('category_id') categoryId: string | undefined,
|
||||||
@Query('store_id') storeId: string | undefined,
|
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return {
|
const result = await this.editor.attributeSchema(
|
||||||
data: await this.editor.attributeSchema(
|
|
||||||
workspace.id,
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
requiredUuid('profile_id', profileId),
|
requiredUuid('profile_id', profileId),
|
||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
optionalUuid('category_id', categoryId),
|
optionalUuid('category_id', categoryId),
|
||||||
optionalUuid('store_id', storeId),
|
);
|
||||||
),
|
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')
|
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||||
async attributeSchemaSync(
|
async attributeSchemaSync(
|
||||||
@@ -149,41 +183,40 @@ export class ProductEditorController {
|
|||||||
|
|
||||||
/** 从本地缓存中查询属性字典值。 */
|
/** 从本地缓存中查询属性字典值。 */
|
||||||
@Get(
|
@Get(
|
||||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId',
|
||||||
)
|
)
|
||||||
async attributeDictionary(
|
async attributeDictionary(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Param('dictionaryId') dictionaryId: string,
|
@Param('attributeId') attributeId: string,
|
||||||
@Query('q') query: string | undefined,
|
@Query('q') query: string | undefined,
|
||||||
@Query('cursor') cursor: string | undefined,
|
@Query('cursor') cursor: string | undefined,
|
||||||
@Query('category_id') categoryId: string | undefined,
|
@Query('category_id') categoryId: string | undefined,
|
||||||
@Query('store_id') storeId: string | undefined,
|
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
const result = await this.editor.attributeDictionary(
|
||||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
|
||||||
return {
|
|
||||||
data: await this.editor.attributeDictionary(
|
|
||||||
workspace.id,
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
requiredUuid('profile_id', profileId),
|
requiredUuid('profile_id', profileId),
|
||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
dictionaryId,
|
requiredUuid('attribute_id', attributeId),
|
||||||
query,
|
query,
|
||||||
cursor,
|
cursor,
|
||||||
optionalUuid('category_id', categoryId),
|
optionalUuid('category_id', categoryId),
|
||||||
optionalUuid('store_id', storeId),
|
);
|
||||||
),
|
response.status(result.status);
|
||||||
};
|
return result.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
|
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
|
||||||
@Post(
|
@Post(
|
||||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId/syncs',
|
||||||
)
|
)
|
||||||
async attributeDictionarySync(
|
async attributeDictionarySync(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@Param('dictionaryId') dictionaryId: string,
|
@Param('attributeId') attributeId: string,
|
||||||
@Query('category_id') categoryId: string | undefined,
|
@Query('category_id') categoryId: string | undefined,
|
||||||
@Query('q') query: string | undefined,
|
@Query('q') query: string | undefined,
|
||||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
@@ -192,8 +225,6 @@ export class ProductEditorController {
|
|||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
|
||||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
|
||||||
const result = await this.editor.requestAttributeDictionarySync(
|
const result = await this.editor.requestAttributeDictionarySync(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
request.auth.userId,
|
request.auth.userId,
|
||||||
@@ -201,7 +232,7 @@ export class ProductEditorController {
|
|||||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
requiredUuid('Idempotency-Key', key),
|
requiredUuid('Idempotency-Key', key),
|
||||||
requiredUuid('category_id', categoryId),
|
requiredUuid('category_id', categoryId),
|
||||||
dictionaryId,
|
requiredUuid('attribute_id', attributeId),
|
||||||
query ?? '',
|
query ?? '',
|
||||||
);
|
);
|
||||||
response.status(result.status);
|
response.status(result.status);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type ProfileRow = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
tags: unknown;
|
tags: unknown;
|
||||||
|
platform_product_category_id: string | null;
|
||||||
store_product_category_id: string | null;
|
store_product_category_id: string | null;
|
||||||
platform_type_id: string | null;
|
platform_type_id: string | null;
|
||||||
category_source: 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,
|
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,
|
p.platform_type_id, p.category_source, category.name AS category_name,
|
||||||
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||||
s.currency_code
|
s.currency_code
|
||||||
FROM atlas.product_platform_profiles p
|
FROM atlas.product_platform_profiles p
|
||||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||||
LEFT JOIN atlas.store_product_categories category
|
LEFT JOIN atlas.platform_product_categories category
|
||||||
ON category.id = p.store_product_category_id
|
ON category.id = p.platform_product_category_id
|
||||||
AND category.store_id = p.target_store_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,
|
ProductEditorContext,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
Operation,
|
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
import { DATABASE } from '../database/database.module.js';
|
import { DATABASE } from '../database/database.module.js';
|
||||||
import {
|
import {
|
||||||
type OperationRow,
|
|
||||||
type ProductRow,
|
type ProductRow,
|
||||||
type ProfileRow,
|
type ProfileRow,
|
||||||
profileSelect,
|
profileSelect,
|
||||||
@@ -18,6 +16,7 @@ import {
|
|||||||
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
||||||
import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
||||||
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||||
|
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
||||||
import {
|
import {
|
||||||
latestProductAiSourceMaterial,
|
latestProductAiSourceMaterial,
|
||||||
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
||||||
@@ -38,6 +37,7 @@ import {
|
|||||||
export class ProductEditorService {
|
export class ProductEditorService {
|
||||||
private readonly catalog: ProductEditorCatalogOperations;
|
private readonly catalog: ProductEditorCatalogOperations;
|
||||||
private readonly validationOperations: ProductEditorValidationOperations;
|
private readonly validationOperations: ProductEditorValidationOperations;
|
||||||
|
private readonly publishPreflightOperations: ProductEditorPublishPreflightOperations;
|
||||||
|
|
||||||
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
||||||
public constructor(
|
public constructor(
|
||||||
@@ -48,13 +48,10 @@ export class ProductEditorService {
|
|||||||
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
||||||
requireProfileForPlatform: (...args) =>
|
requireProfileForPlatform: (...args) =>
|
||||||
this.requireProfileForPlatform(...args),
|
this.requireProfileForPlatform(...args),
|
||||||
requireEditableStore: (...args) =>
|
|
||||||
this.requireEditableStore(...args),
|
|
||||||
requireActiveCategory: (...args) =>
|
requireActiveCategory: (...args) =>
|
||||||
this.requireActiveCategory(...args),
|
this.requireActiveCategory(...args),
|
||||||
attributeSchemaForCategory: (...args) =>
|
attributeSchemaForCategory: (...args) =>
|
||||||
this.attributeSchemaForCategory(...args),
|
this.attributeSchemaForCategory(...args),
|
||||||
requestCatalogSync: (input) => this.requestCatalogSync(input),
|
|
||||||
});
|
});
|
||||||
this.validationOperations = new ProductEditorValidationOperations(
|
this.validationOperations = new ProductEditorValidationOperations(
|
||||||
this.database,
|
this.database,
|
||||||
@@ -66,6 +63,17 @@ export class ProductEditorService {
|
|||||||
this.attributeSchemaForCategory(...args),
|
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: {
|
category: {
|
||||||
id: profile.store_product_category_id,
|
id: profile.platform_product_category_id,
|
||||||
platform_type_id: profile.platform_type_id,
|
platform_type_id: profile.platform_type_id,
|
||||||
source: profile.category_source,
|
source: profile.category_source,
|
||||||
name: profile.category_name,
|
name: profile.category_name,
|
||||||
@@ -468,37 +476,35 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取发布配置当前或指定目标店铺的缓存类目。 */
|
/** 读取当前平台共享的缓存类目。 */
|
||||||
async categoryOptions(
|
async categoryOptions(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
storeId?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.catalog.categoryOptions(
|
return this.catalog.categoryOptions(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
search,
|
search,
|
||||||
storeId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取有效启用类目的缓存动态属性定义。 */
|
/** 获取有效启用类目的缓存动态属性定义。 */
|
||||||
async attributeSchema(
|
async attributeSchema(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.catalog.attributeSchema(
|
return this.catalog.attributeSchema(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
userId,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
categoryId,
|
categoryId,
|
||||||
storeId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,26 +521,43 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅从店铺本地缓存查询当前类目的字典值。 */
|
/** 为指定待发布 SKU 定向刷新目录依赖并执行最终本地校验。 */
|
||||||
async attributeDictionary(
|
async publishPreflight(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: 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,
|
search: string | undefined,
|
||||||
cursor: string | undefined,
|
cursor: string | undefined,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.catalog.attributeDictionary(
|
return this.catalog.attributeDictionary(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
userId,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
dictionaryId,
|
attributeId,
|
||||||
search,
|
search,
|
||||||
cursor,
|
cursor,
|
||||||
categoryId,
|
categoryId,
|
||||||
storeId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +588,7 @@ export class ProductEditorService {
|
|||||||
platformId: string,
|
platformId: string,
|
||||||
key: string,
|
key: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
dictionaryId: string,
|
attributeId: string,
|
||||||
query: string,
|
query: string,
|
||||||
) {
|
) {
|
||||||
return this.catalog.requestAttributeDictionarySync(
|
return this.catalog.requestAttributeDictionarySync(
|
||||||
@@ -575,7 +598,7 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
key,
|
key,
|
||||||
categoryId,
|
categoryId,
|
||||||
dictionaryId,
|
attributeId,
|
||||||
query,
|
query,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -629,18 +652,21 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
);
|
);
|
||||||
const selectedCategoryId =
|
const selectedCategoryId =
|
||||||
input.profile.store_product_category_id === undefined
|
await this.resolveSelectedPlatformCategoryId(
|
||||||
? profile.store_product_category_id
|
transaction,
|
||||||
: input.profile.store_product_category_id;
|
profile,
|
||||||
|
input.profile.platform_product_category_id,
|
||||||
|
input.profile.store_product_category_id,
|
||||||
|
);
|
||||||
const selectedCategory = selectedCategoryId
|
const selectedCategory = selectedCategoryId
|
||||||
? await this.requireActiveCategory(
|
? await this.requireActiveCategory(
|
||||||
transaction,
|
transaction,
|
||||||
input.profile.target_store_id,
|
platformId,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const categoryChanged =
|
const categoryChanged =
|
||||||
selectedCategoryId !== profile.store_product_category_id;
|
selectedCategoryId !== profile.platform_product_category_id;
|
||||||
const categorySource = selectedCategory
|
const categorySource = selectedCategory
|
||||||
? categoryChanged
|
? categoryChanged
|
||||||
? 'manual'
|
? 'manual'
|
||||||
@@ -699,16 +725,24 @@ export class ProductEditorService {
|
|||||||
input.ai_source_material,
|
input.ai_source_material,
|
||||||
input.ai_source_overridden_fields,
|
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<{
|
const updatedProfile = await transaction.query<{
|
||||||
revision: number;
|
revision: number;
|
||||||
}>(
|
}>(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`UPDATE atlas.product_platform_profiles
|
||||||
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
||||||
tags = $5::jsonb, store_product_category_id = $6,
|
tags = $5::jsonb, platform_product_category_id = $6,
|
||||||
platform_type_id = $7, category_source = $8,
|
store_product_category_id = $7, platform_type_id = $8,
|
||||||
|
category_source = $9,
|
||||||
revision = revision + 1, updated_at = now()
|
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`,
|
RETURNING revision`,
|
||||||
[
|
[
|
||||||
input.profile.target_store_id,
|
input.profile.target_store_id,
|
||||||
@@ -717,6 +751,7 @@ export class ProductEditorService {
|
|||||||
input.profile.description,
|
input.profile.description,
|
||||||
JSON.stringify(input.profile.tags),
|
JSON.stringify(input.profile.tags),
|
||||||
selectedCategory?.id ?? null,
|
selectedCategory?.id ?? null,
|
||||||
|
compatibleStoreCategoryId,
|
||||||
selectedCategory?.external_type_id ?? null,
|
selectedCategory?.external_type_id ?? null,
|
||||||
categorySource,
|
categorySource,
|
||||||
profile.id,
|
profile.id,
|
||||||
@@ -951,7 +986,6 @@ export class ProductEditorService {
|
|||||||
productId,
|
productId,
|
||||||
profile,
|
profile,
|
||||||
platformId,
|
platformId,
|
||||||
targetStoreId: input.profile.target_store_id,
|
|
||||||
categoryId: selectedCategory?.id ?? null,
|
categoryId: selectedCategory?.id ?? null,
|
||||||
attributes: input.attributes,
|
attributes: input.attributes,
|
||||||
retainedVariantIds: retainedIds,
|
retainedVariantIds: retainedIds,
|
||||||
@@ -1175,7 +1209,6 @@ export class ProductEditorService {
|
|||||||
productId: string;
|
productId: string;
|
||||||
profile: ProfileRow;
|
profile: ProfileRow;
|
||||||
platformId: string;
|
platformId: string;
|
||||||
targetStoreId: string;
|
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
attributes: SaveProductEditorDraftRequest['attributes'];
|
attributes: SaveProductEditorDraftRequest['attributes'];
|
||||||
retainedVariantIds: string[];
|
retainedVariantIds: string[];
|
||||||
@@ -1197,15 +1230,15 @@ export class ProductEditorService {
|
|||||||
a.confirmation_status,
|
a.confirmation_status,
|
||||||
coalesce(schema.name, a.platform_attribute_id) AS original_name
|
coalesce(schema.name, a.platform_attribute_id) AS original_name
|
||||||
FROM atlas.product_attributes a
|
FROM atlas.product_attributes a
|
||||||
LEFT JOIN atlas.store_product_category_attributes schema
|
LEFT JOIN atlas.platform_product_category_attributes schema
|
||||||
ON schema.store_id = $1
|
ON schema.platform_id = $1
|
||||||
AND schema.store_product_category_id IS NOT DISTINCT FROM $2::uuid
|
AND schema.platform_product_category_id IS NOT DISTINCT FROM $2::uuid
|
||||||
AND schema.platform_attribute_id = a.platform_attribute_id
|
AND schema.platform_attribute_id = a.platform_attribute_id
|
||||||
AND schema.complex_id = a.complex_id
|
AND schema.complex_id = a.complex_id
|
||||||
WHERE a.profile_id = $3`,
|
WHERE a.profile_id = $3`,
|
||||||
[
|
[
|
||||||
input.profile.target_store_id,
|
input.platformId,
|
||||||
input.profile.store_product_category_id,
|
input.profile.platform_product_category_id,
|
||||||
input.profile.id,
|
input.profile.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1218,9 +1251,43 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
return;
|
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(
|
const schema = await this.attributeSchemaForCategory(
|
||||||
transaction,
|
transaction,
|
||||||
input.profile.target_store_id,
|
input.platformId,
|
||||||
input.categoryId,
|
input.categoryId,
|
||||||
);
|
);
|
||||||
const schemaByKey = new Map(
|
const schemaByKey = new Map(
|
||||||
@@ -1323,10 +1390,13 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
await this.assertAttributeValues(
|
await this.assertAttributeValues(
|
||||||
transaction,
|
transaction,
|
||||||
input.targetStoreId,
|
input.platformId,
|
||||||
input.categoryId,
|
|
||||||
definition,
|
definition,
|
||||||
attribute.values,
|
attribute.values,
|
||||||
|
sameStringValues(
|
||||||
|
existingByKey.get(savedKey)?.values,
|
||||||
|
attribute.values,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
valid.push(attribute);
|
valid.push(attribute);
|
||||||
}
|
}
|
||||||
@@ -1353,15 +1423,17 @@ export class ProductEditorService {
|
|||||||
await transaction.query(
|
await transaction.query(
|
||||||
`INSERT INTO atlas.product_unmapped_attributes
|
`INSERT INTO atlas.product_unmapped_attributes
|
||||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id,
|
(workspace_id, product_id, profile_id, platform_id, product_variant_id,
|
||||||
original_store_product_category_id, original_platform_attribute_id,
|
original_platform_category_id, original_store_product_category_id,
|
||||||
original_complex_id, original_name, values, language_code, reason)
|
original_platform_attribute_id, original_complex_id, original_name,
|
||||||
|
values, language_code, reason)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
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.productId,
|
||||||
input.profile.id,
|
input.profile.id,
|
||||||
input.platformId,
|
input.platformId,
|
||||||
attribute.product_variant_id,
|
attribute.product_variant_id,
|
||||||
|
input.profile.platform_product_category_id,
|
||||||
input.profile.store_product_category_id,
|
input.profile.store_product_category_id,
|
||||||
attribute.platform_attribute_id,
|
attribute.platform_attribute_id,
|
||||||
attribute.complex_id,
|
attribute.complex_id,
|
||||||
@@ -1402,12 +1474,12 @@ export class ProductEditorService {
|
|||||||
/** 校验草稿属性的类型、数量、字典值与 SKU 归属。 */
|
/** 校验草稿属性的类型、数量、字典值与 SKU 归属。 */
|
||||||
private async assertAttributeValues(
|
private async assertAttributeValues(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
platformId: string,
|
||||||
categoryId: string,
|
|
||||||
definition: Awaited<
|
definition: Awaited<
|
||||||
ReturnType<ProductEditorService['attributeSchemaForCategory']>
|
ReturnType<ProductEditorService['attributeSchemaForCategory']>
|
||||||
>[number],
|
>[number],
|
||||||
values: string[],
|
values: string[],
|
||||||
|
unchanged: boolean,
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
values.length > definition.max_value_count ||
|
values.length > definition.max_value_count ||
|
||||||
@@ -1447,13 +1519,27 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
if (definition.value_type !== 'dictionary' || !definition.dictionary_id)
|
if (definition.value_type !== 'dictionary' || !definition.dictionary_id)
|
||||||
return;
|
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 }>(
|
const dictionary = await transaction.query<{ value_id: string }>(
|
||||||
`SELECT external_value_id AS value_id
|
`SELECT external_value_id AS value_id
|
||||||
FROM atlas.store_attribute_dictionary_values
|
FROM atlas.platform_catalog_dictionary_values
|
||||||
WHERE store_id = $1 AND store_product_category_id = $2
|
WHERE attribute_id = $1 AND is_active
|
||||||
AND dictionary_id = $3 AND is_active
|
AND external_value_id = ANY($2::text[])`,
|
||||||
AND external_value_id = ANY($4::text[])`,
|
[definition.attribute_id, values],
|
||||||
[storeId, categoryId, definition.dictionary_id, values],
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
new Set(dictionary.rows.map((item) => item.value_id)).size !==
|
new Set(dictionary.rows.map((item) => item.value_id)).size !==
|
||||||
@@ -1466,13 +1552,14 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回店铺有效类目的标准化属性定义。 */
|
/** 返回平台有效类型的标准化属性定义。 */
|
||||||
private async attributeSchemaForCategory(
|
private async attributeSchemaForCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
platformId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
const result = await transaction.query<{
|
const result = await transaction.query<{
|
||||||
|
attribute_id: string;
|
||||||
platform_attribute_id: string;
|
platform_attribute_id: string;
|
||||||
complex_id: string;
|
complex_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1484,150 +1571,35 @@ export class ProductEditorService {
|
|||||||
dictionary_id: string | null;
|
dictionary_id: string | null;
|
||||||
is_aspect: boolean;
|
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
|
is_required, is_collection, max_value_count, dictionary_id, is_aspect
|
||||||
FROM atlas.store_product_category_attributes
|
FROM atlas.platform_product_category_attributes
|
||||||
WHERE store_id = $1 AND store_product_category_id = $2
|
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`,
|
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
||||||
[storeId, categoryId],
|
[platformId, categoryId],
|
||||||
);
|
);
|
||||||
return bindEditorFields(result.rows);
|
return bindEditorFields(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
|
/** expired/missing 平台资源不能用于新值校验,但旧草稿仍可原样保留。 */
|
||||||
private async requestCatalogSync(input: {
|
private async platformResourceUsable(
|
||||||
workspaceId: string;
|
transaction: DatabaseTransaction,
|
||||||
userId: string;
|
platformId: string,
|
||||||
profileId: string;
|
scopeKey: string,
|
||||||
platformId: string;
|
scope: 'attributes' | 'dictionary',
|
||||||
key: string;
|
) {
|
||||||
scope: 'attributes' | 'dictionary';
|
const result = await transaction.query<{ usable: boolean }>(
|
||||||
path: string;
|
`SELECT state.last_success_at IS NOT NULL
|
||||||
categoryId?: string;
|
AND state.last_success_at >= now() - make_interval(days =>
|
||||||
dictionaryId?: string;
|
CASE WHEN $3 = 'attributes' THEN policy.attribute_expire_days
|
||||||
query?: string;
|
ELSE policy.dictionary_expire_days END) AS usable
|
||||||
}) {
|
FROM atlas.platform_catalog_refresh_policies policy
|
||||||
return this.idempotency.run(
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
{
|
ON state.platform_id = policy.platform_id AND state.scope_key = $2
|
||||||
workspaceId: input.workspaceId,
|
WHERE policy.platform_id = $1`,
|
||||||
userId: input.userId,
|
[platformId, scopeKey, scope],
|
||||||
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 } } };
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
return result.rows[0]?.usable === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1639,7 +1611,7 @@ export class ProductEditorService {
|
|||||||
product: ProductRow,
|
product: ProductRow,
|
||||||
profile: ProfileRow,
|
profile: ProfileRow,
|
||||||
): Promise<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 categories = latestProductAiSourceMaterial(product).categories;
|
||||||
const sourcePath = normalizeCapturedCategoryPath(categories);
|
const sourcePath = normalizeCapturedCategoryPath(categories);
|
||||||
if (!sourcePath) return profile;
|
if (!sourcePath) return profile;
|
||||||
@@ -1659,22 +1631,22 @@ export class ProductEditorService {
|
|||||||
external_type_id: string | null;
|
external_type_id: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, external_type_id
|
`SELECT id, external_type_id
|
||||||
FROM atlas.store_product_categories
|
FROM atlas.platform_product_categories
|
||||||
WHERE store_id = $1 AND is_active
|
WHERE platform_id = $1 AND is_active
|
||||||
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
|
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[profile.target_store_id, sourcePath],
|
[profile.platform_id, sourcePath],
|
||||||
);
|
);
|
||||||
const matched = category.rows[0];
|
const matched = category.rows[0];
|
||||||
if (!matched) return profile;
|
if (!matched) return profile;
|
||||||
|
|
||||||
const updated = await transaction.query<{ id: string }>(
|
const updated = await transaction.query<{ id: string }>(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`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,
|
category_source = 'source_capture', revision = revision + 1,
|
||||||
updated_at = now()
|
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`,
|
RETURNING id`,
|
||||||
[matched.id, matched.external_type_id, profile.id],
|
[matched.id, matched.external_type_id, profile.id],
|
||||||
);
|
);
|
||||||
@@ -1683,10 +1655,61 @@ export class ProductEditorService {
|
|||||||
: profile;
|
: profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确认类目属于目标店铺且仍处于启用状态。 */
|
/** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */
|
||||||
private async requireActiveCategory(
|
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,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
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,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
const result = await transaction.query<{
|
const result = await transaction.query<{
|
||||||
@@ -1694,15 +1717,15 @@ export class ProductEditorService {
|
|||||||
external_type_id: string | null;
|
external_type_id: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, external_type_id
|
`SELECT id, external_type_id
|
||||||
FROM atlas.store_product_categories
|
FROM atlas.platform_product_categories
|
||||||
WHERE id = $1 AND store_id = $2 AND is_active`,
|
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||||
[categoryId, storeId],
|
[categoryId, platformId],
|
||||||
);
|
);
|
||||||
if (!result.rows[0])
|
if (!result.rows[0])
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'CATEGORY_INVALID',
|
'CATEGORY_INVALID',
|
||||||
'类目不存在、已停用或不属于当前店铺',
|
'类目不存在、已停用或不属于当前平台',
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
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(
|
function listingSummary(
|
||||||
row:
|
row:
|
||||||
| {
|
| {
|
||||||
@@ -2120,19 +2181,3 @@ function listingSummary(
|
|||||||
};
|
};
|
||||||
return { status: 'not_listed' as const, detail: '等待首次上架' };
|
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,
|
ProductEditorValidation,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
import type { ProductRow, ProfileRow } from './product-editor.models.js';
|
import type { ProductRow, ProfileRow } from './product-editor.models.js';
|
||||||
|
|
||||||
type ValidationAccess = {
|
type ValidationAccess = {
|
||||||
@@ -17,7 +18,7 @@ type ValidationAccess = {
|
|||||||
) => Promise<ProductRow>;
|
) => Promise<ProductRow>;
|
||||||
attributeSchemaForCategory: (
|
attributeSchemaForCategory: (
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
platformId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) => Promise<ProductAttributeSchema[]>;
|
) => Promise<ProductAttributeSchema[]>;
|
||||||
};
|
};
|
||||||
@@ -45,6 +46,12 @@ type AttributeValidationRow = {
|
|||||||
confirmation_status: string;
|
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,
|
workspaceId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
|
variantIds?: string[],
|
||||||
): Promise<ProductEditorValidation> {
|
): Promise<ProductEditorValidation> {
|
||||||
return this.database.workspaceTransaction(
|
return this.database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -78,16 +86,41 @@ export class ProductEditorValidationOperations {
|
|||||||
transaction,
|
transaction,
|
||||||
profile.id,
|
profile.id,
|
||||||
profile.product_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(
|
const attributes = await this.readAttributes(
|
||||||
transaction,
|
transaction,
|
||||||
profile.id,
|
profile.id,
|
||||||
|
variantIds,
|
||||||
);
|
);
|
||||||
const schema = profile.store_product_category_id
|
const schemaUsable = profile.platform_product_category_id
|
||||||
|
? await this.schemaUsable(
|
||||||
|
transaction,
|
||||||
|
profile.platform_id,
|
||||||
|
profile.platform_product_category_id,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
const schema =
|
||||||
|
profile.platform_product_category_id && schemaUsable
|
||||||
? await this.access.attributeSchemaForCategory(
|
? await this.access.attributeSchemaForCategory(
|
||||||
transaction,
|
transaction,
|
||||||
profile.target_store_id,
|
profile.platform_id,
|
||||||
profile.store_product_category_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,
|
schema,
|
||||||
attributes,
|
attributes,
|
||||||
);
|
);
|
||||||
|
profileIssues.push(
|
||||||
|
...dictionaryIssues
|
||||||
|
.filter((item) => item.scope === 'common')
|
||||||
|
.map((item) => item.issue),
|
||||||
|
);
|
||||||
const variantResults = variants.map((variant) =>
|
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(
|
const publishableVariants = variantResults.filter(
|
||||||
(variant) => variant.status !== 'not_publishable_again',
|
(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(
|
private profileIssues(
|
||||||
product: ProductRow,
|
product: ProductRow,
|
||||||
@@ -145,13 +214,13 @@ export class ProductEditorValidationOperations {
|
|||||||
field: 'common.brand',
|
field: 'common.brand',
|
||||||
message: '请填写品牌',
|
message: '请填写品牌',
|
||||||
});
|
});
|
||||||
if (!profile.store_product_category_id)
|
if (!profile.platform_product_category_id)
|
||||||
issues.push({
|
issues.push({
|
||||||
code: 'CATEGORY_REQUIRED',
|
code: 'CATEGORY_REQUIRED',
|
||||||
field: 'profile.store_product_category_id',
|
field: 'profile.platform_product_category_id',
|
||||||
message: '请选择产品类目',
|
message: '请选择产品类目',
|
||||||
});
|
});
|
||||||
if (profile.store_product_category_id && !schema.length)
|
if (profile.platform_product_category_id && !schema.length)
|
||||||
issues.push({
|
issues.push({
|
||||||
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
|
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
|
||||||
field: 'attributes',
|
field: 'attributes',
|
||||||
@@ -177,6 +246,7 @@ export class ProductEditorValidationOperations {
|
|||||||
variant: VariantValidationRow,
|
variant: VariantValidationRow,
|
||||||
schema: ProductAttributeSchema[],
|
schema: ProductAttributeSchema[],
|
||||||
attributes: AttributeValidationRow[],
|
attributes: AttributeValidationRow[],
|
||||||
|
dictionaryIssues: ProductEditorValidation['profile']['issues'] = [],
|
||||||
): ProductEditorValidation['variants'][number] {
|
): ProductEditorValidation['variants'][number] {
|
||||||
if (variant.listing_status === 'imported')
|
if (variant.listing_status === 'imported')
|
||||||
return {
|
return {
|
||||||
@@ -187,8 +257,15 @@ export class ProductEditorValidationOperations {
|
|||||||
issues: [],
|
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())
|
if (!variant.offer_id?.trim())
|
||||||
issues.push({
|
issues.push({
|
||||||
code: 'OFFER_ID_REQUIRED',
|
code: 'OFFER_ID_REQUIRED',
|
||||||
@@ -253,6 +330,7 @@ export class ProductEditorValidationOperations {
|
|||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
|
variantIds?: string[],
|
||||||
) {
|
) {
|
||||||
const variants = await transaction.query<VariantValidationRow>(
|
const variants = await transaction.query<VariantValidationRow>(
|
||||||
`SELECT v.id, v.display_name, listing.status AS listing_status,
|
`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
|
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
|
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
|
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,
|
GROUP BY v.id, v.display_name, listing.status, listing.offer_id,
|
||||||
listing.selling_price, product.default_package_length_mm,
|
listing.selling_price, product.default_package_length_mm,
|
||||||
product.default_package_width_mm, product.default_package_height_mm,
|
product.default_package_width_mm, product.default_package_height_mm,
|
||||||
product.default_shipping_weight_g, product.package_spec_status
|
product.default_shipping_weight_g, product.package_spec_status
|
||||||
ORDER BY v.sort_order, v.id`,
|
ORDER BY v.sort_order, v.id`,
|
||||||
[profileId, productId],
|
[profileId, productId, variantIds ?? null],
|
||||||
);
|
);
|
||||||
return variants.rows;
|
return variants.rows;
|
||||||
}
|
}
|
||||||
@@ -283,15 +362,130 @@ export class ProductEditorValidationOperations {
|
|||||||
private async readAttributes(
|
private async readAttributes(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
|
variantIds?: string[],
|
||||||
) {
|
) {
|
||||||
const attributes = await transaction.query<AttributeValidationRow>(
|
const attributes = await transaction.query<AttributeValidationRow>(
|
||||||
`SELECT scope, product_variant_id, platform_attribute_id, complex_id,
|
`SELECT scope, product_variant_id, platform_attribute_id, complex_id,
|
||||||
values, confirmation_status
|
values, confirmation_status
|
||||||
FROM atlas.product_attributes WHERE profile_id = $1`,
|
FROM atlas.product_attributes
|
||||||
[profileId],
|
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;
|
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 type { Database } from '@atlas/database';
|
||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
attributeBatchInputSchema,
|
||||||
parseInput,
|
parseInput,
|
||||||
|
refreshPolicyInputSchema,
|
||||||
sourceInputSchema,
|
sourceInputSchema,
|
||||||
syncInputSchema,
|
syncInputSchema,
|
||||||
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
|
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
|
||||||
@@ -46,4 +48,41 @@ describe('平台目录系统管理员边界', () => {
|
|||||||
workspace_id: 'forbidden',
|
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,
|
path: string,
|
||||||
) {
|
) {
|
||||||
return database.workspaceTransaction(workspaceId, async (tx) => {
|
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 }>(
|
const category = await tx.query<{ id: string }>(
|
||||||
`INSERT INTO atlas.store_product_categories
|
`INSERT INTO atlas.platform_product_categories
|
||||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
|
(platform_id, external_category_id, external_type_id, name, path)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[
|
[platformId, externalCategoryId, externalTypeId, name, path],
|
||||||
storeId,
|
|
||||||
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', [
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||||
workspaceId,
|
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 database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -879,23 +899,25 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
'箱包 › 背包 › M3.4 背包',
|
'箱包 › 背包 › M3.4 背包',
|
||||||
);
|
);
|
||||||
seededCategoryIds.push(categoryA.id, categoryB.id);
|
seededCategoryIds.push(categoryA.id, categoryB.id);
|
||||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
const seededAttributes = await database.workspaceTransaction(
|
||||||
tx.query(
|
workspaceId,
|
||||||
`INSERT INTO atlas.store_product_category_attributes
|
(tx) =>
|
||||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
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)
|
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||||
VALUES
|
VALUES
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
|
($1, $2, 'material', '材质', 'dictionary', true, false, 1, $4, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
|
($1, $2, 'color', '颜色', 'dictionary', true, false, 1, $5, true),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '85', '品牌', 'text', true, false, 1, NULL, false),
|
($1, $2, '85', '品牌', 'text', true, false, 1, NULL, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '9048', '型号名称', 'text', true, false, 1, NULL, false),
|
($1, $2, '9048', '型号名称', 'text', true, false, 1, NULL, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '8229', '类型', 'text', true, false, 1, NULL, false),
|
($1, $2, '8229', '类型', 'text', true, false, 1, NULL, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '23171', '主题标签', 'text', false, true, 10, NULL, false),
|
($1, $2, '23171', '主题标签', 'text', false, true, 10, NULL, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
|
($1, $2, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '4191', '简介', 'text', true, false, 1, NULL, false),
|
($1, $2, '4191', '简介', 'text', true, false, 1, NULL, false),
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)
|
||||||
|
RETURNING id, platform_attribute_id`,
|
||||||
[
|
[
|
||||||
categoryA.storeId,
|
|
||||||
platformId,
|
platformId,
|
||||||
categoryA.id,
|
categoryA.id,
|
||||||
categoryB.id,
|
categoryB.id,
|
||||||
@@ -904,21 +926,46 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
const materialAttributeId = seededAttributes.rows.find(
|
||||||
tx.query(
|
(item) => item.platform_attribute_id === 'material',
|
||||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
)!.id;
|
||||||
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
|
const colorAttributeId = seededAttributes.rows.find(
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
|
(item) => item.platform_attribute_id === 'color',
|
||||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
|
)!.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', '黑色')`,
|
||||||
[
|
[
|
||||||
categoryA.storeId,
|
|
||||||
platformId,
|
platformId,
|
||||||
categoryA.id,
|
categoryA.id,
|
||||||
|
materialAttributeId,
|
||||||
materialDictionaryId,
|
materialDictionaryId,
|
||||||
|
colorAttributeId,
|
||||||
colorDictionaryId,
|
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)`,
|
||||||
|
[
|
||||||
|
platformId,
|
||||||
|
categoryA.id,
|
||||||
|
categoryB.id,
|
||||||
|
materialAttributeId,
|
||||||
|
colorAttributeId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
const categories = await request(
|
const categories = await request(
|
||||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
@@ -968,7 +1015,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
title: before.body.data.profile.title,
|
title: before.body.data.profile.title,
|
||||||
description: before.body.data.profile.description,
|
description: before.body.data.profile.description,
|
||||||
tags: before.body.data.profile.tags,
|
tags: before.body.data.profile.tags,
|
||||||
store_product_category_id: categoryA.id,
|
platform_product_category_id: categoryA.id,
|
||||||
category_source: 'manual',
|
category_source: 'manual',
|
||||||
},
|
},
|
||||||
attributes: [
|
attributes: [
|
||||||
@@ -1024,56 +1071,56 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(schema.response.status, 200);
|
assert.equal(schema.response.status, 200);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === 'color',
|
item.platform_attribute_id === 'color',
|
||||||
).is_aspect,
|
).is_aspect,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === '85',
|
item.platform_attribute_id === '85',
|
||||||
).editor_field,
|
).editor_field,
|
||||||
'brand',
|
'brand',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === '9048',
|
item.platform_attribute_id === '9048',
|
||||||
).editor_field,
|
).editor_field,
|
||||||
'model',
|
'model',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === '8229',
|
item.platform_attribute_id === '8229',
|
||||||
).editor_field,
|
).editor_field,
|
||||||
'category',
|
'category',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === '23171',
|
item.platform_attribute_id === '23171',
|
||||||
).editor_field,
|
).editor_field,
|
||||||
'tags',
|
'tags',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === '11254',
|
item.platform_attribute_id === '11254',
|
||||||
).editor_field,
|
).editor_field,
|
||||||
'rich_content',
|
'rich_content',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
schema.body.data.find(
|
schema.body.data.items.find(
|
||||||
(item: { platform_attribute_id: string }) =>
|
(item: { platform_attribute_id: string }) =>
|
||||||
item.platform_attribute_id === '4191',
|
item.platform_attribute_id === '4191',
|
||||||
).editor_field,
|
).editor_field,
|
||||||
'description',
|
'description',
|
||||||
);
|
);
|
||||||
const dictionary = await request(
|
const dictionary = await request(
|
||||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}`,
|
||||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
);
|
);
|
||||||
assert.deepEqual(dictionary.body.data.items, [
|
assert.deepEqual(dictionary.body.data.items, [
|
||||||
@@ -1081,29 +1128,34 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
]);
|
]);
|
||||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
tx.query(
|
tx.query(
|
||||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
`INSERT INTO atlas.platform_catalog_dictionary_values
|
||||||
(workspace_id, store_id, platform_id, store_product_category_id,
|
(platform_id, platform_product_category_id, attribute_id,
|
||||||
dictionary_id, external_value_id, value)
|
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')
|
'page-' || value::text, '测试选项' || lpad(value::text, 3, '0')
|
||||||
FROM generate_series(1, 205) AS series(value)`,
|
FROM generate_series(1, 205) AS series(value)`,
|
||||||
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId],
|
[
|
||||||
|
platformId,
|
||||||
|
categoryA.id,
|
||||||
|
materialAttributeId,
|
||||||
|
materialDictionaryId,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const dictionaryPageOne = await request(
|
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 }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
);
|
);
|
||||||
assert.equal(dictionaryPageOne.body.data.items.length, 200);
|
assert.equal(dictionaryPageOne.body.data.items.length, 200);
|
||||||
assert.ok(dictionaryPageOne.body.data.next_cursor);
|
assert.ok(dictionaryPageOne.body.data.next_cursor);
|
||||||
const dictionaryPageTwo = await request(
|
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 }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
);
|
);
|
||||||
assert.equal(dictionaryPageTwo.body.data.items.length, 6);
|
assert.equal(dictionaryPageTwo.body.data.items.length, 6);
|
||||||
assert.equal(dictionaryPageTwo.body.data.next_cursor, null);
|
assert.equal(dictionaryPageTwo.body.data.next_cursor, null);
|
||||||
const invalidDictionaryCursor = await request(
|
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 }) },
|
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||||
);
|
);
|
||||||
assert.equal(invalidDictionaryCursor.response.status, 400);
|
assert.equal(invalidDictionaryCursor.response.status, 400);
|
||||||
@@ -1254,7 +1306,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
afterSave.body.data.source_material_state.overridden_fields,
|
afterSave.body.data.source_material_state.overridden_fields,
|
||||||
profile: {
|
profile: {
|
||||||
...draft.profile,
|
...draft.profile,
|
||||||
store_product_category_id: categoryB.id,
|
platform_product_category_id: categoryB.id,
|
||||||
},
|
},
|
||||||
attributes: afterSave.body.data.attributes.map(
|
attributes: afterSave.body.data.attributes.map(
|
||||||
(attribute: any) => ({
|
(attribute: any) => ({
|
||||||
@@ -1339,7 +1391,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`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
|
category_source = NULL
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[first.profileId],
|
[first.profileId],
|
||||||
@@ -1361,7 +1414,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
tx.query(
|
tx.query(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`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'
|
category_source = 'manual'
|
||||||
WHERE id = $2`,
|
WHERE id = $2`,
|
||||||
[manual.id, first.profileId],
|
[manual.id, first.profileId],
|
||||||
@@ -1388,31 +1441,32 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
seededCategoryIds.push(category.id);
|
seededCategoryIds.push(category.id);
|
||||||
seededDictionaryIds.push(dictionaryId);
|
seededDictionaryIds.push(dictionaryId);
|
||||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
const dictionaryAttribute = await database.workspaceTransaction(
|
||||||
tx.query(
|
workspaceId,
|
||||||
`INSERT INTO atlas.store_product_category_attributes
|
(tx) =>
|
||||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
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)
|
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
|
VALUES ($1, $2, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $3, false)
|
||||||
[category.storeId, platformId, category.id, dictionaryId],
|
RETURNING id`,
|
||||||
|
[platformId, category.id, dictionaryId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const dictionaryAttributeId = dictionaryAttribute.rows[0].id;
|
||||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
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(
|
await tx.query(
|
||||||
`INSERT INTO atlas.store_secrets
|
`INSERT INTO atlas.platform_catalog_sources
|
||||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
(platform_id, client_id, secret_ciphertext, secret_nonce,
|
||||||
encryption_key_version, fingerprint, status)
|
secret_fingerprint, encryption_key_version, updated_by_user_id)
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
VALUES ($1, 'm35-test-client', $2, $3, $4, 'test', $5)
|
||||||
$2, $3, 'test', $4, 'valid')`,
|
ON CONFLICT (platform_id) DO NOTHING`,
|
||||||
[
|
[
|
||||||
profile.rows[0].target_store_id,
|
platformId,
|
||||||
Buffer.from('m35-test-ciphertext'),
|
Buffer.from('m35-test-ciphertext'),
|
||||||
Buffer.from('m35-test-nonce'),
|
Buffer.from('m35-test-nonce'),
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
|
userId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1427,12 +1481,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(schema.response.status, 202);
|
assert.equal(schema.response.status, 202);
|
||||||
assert.equal(
|
assert.equal(schema.body.data.run.scope, 'attributes');
|
||||||
schema.body.data.operation.kind,
|
|
||||||
'store_category_attribute_sync',
|
|
||||||
);
|
|
||||||
const dictionary = await request(
|
const dictionary = await request(
|
||||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
|
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryAttributeId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers({
|
headers: headers({
|
||||||
@@ -1442,12 +1493,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(dictionary.response.status, 202);
|
assert.equal(dictionary.response.status, 202);
|
||||||
assert.equal(
|
assert.equal(dictionary.body.data.run.scope, 'dictionary_search');
|
||||||
dictionary.body.data.operation.kind,
|
|
||||||
'store_attribute_dictionary_sync',
|
|
||||||
);
|
|
||||||
const initialDictionary = await request(
|
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',
|
method: 'POST',
|
||||||
headers: headers({
|
headers: headers({
|
||||||
@@ -1459,34 +1507,231 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
assert.equal(initialDictionary.response.status, 202);
|
assert.equal(initialDictionary.response.status, 202);
|
||||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
const events = await tx.query<{
|
const events = await tx.query<{
|
||||||
event_type: string;
|
scope: string;
|
||||||
event_payload: unknown;
|
scope_key: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT event_type, event_payload FROM atlas.outbox_events
|
`SELECT run.scope, run.scope_key
|
||||||
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
|
FROM atlas.platform_catalog_outbox_events event
|
||||||
ORDER BY created_at`,
|
JOIN atlas.platform_catalog_sync_runs run ON run.id = event.run_id
|
||||||
[first.profileId],
|
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.equal(events.rows.length, 3);
|
||||||
assert.deepEqual(
|
assert.deepEqual(events.rows.map((event) => event.scope).sort(), [
|
||||||
events.rows.map((event) => event.event_type).sort(),
|
'attributes',
|
||||||
[
|
'dictionary',
|
||||||
'store_attribute_dictionary_sync.requested',
|
'dictionary_search',
|
||||||
'store_attribute_dictionary_sync.requested',
|
]);
|
||||||
'store_category_attribute_sync.requested',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
events.rows.every(
|
events.rows.every(
|
||||||
(event) =>
|
(event) =>
|
||||||
!JSON.stringify(event.event_payload).includes(
|
!JSON.stringify(event).includes('m35-test-ciphertext'),
|
||||||
'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 () => {
|
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||||
const before = await request(
|
const before = await request(
|
||||||
`/api/products/${first.productId}/editor`,
|
`/api/products/${first.productId}/editor`,
|
||||||
@@ -1550,7 +1795,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
title: before.body.data.profile.title,
|
title: before.body.data.profile.title,
|
||||||
description: before.body.data.profile.description,
|
description: before.body.data.profile.description,
|
||||||
tags: before.body.data.profile.tags,
|
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,
|
category_source: before.body.data.profile.category.source,
|
||||||
},
|
},
|
||||||
attributes: before.body.data.attributes.map((attribute: any) => ({
|
attributes: before.body.data.attributes.map((attribute: any) => ({
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-admin-layout > .ant-layout {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.system-admin-sider {
|
.system-admin-sider {
|
||||||
background: var(--sider-bg) !important;
|
background: var(--sider-bg) !important;
|
||||||
}
|
}
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.system-admin-content {
|
.system-admin-content {
|
||||||
|
min-width: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function SystemAdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (access === 'checking') {
|
if (access === 'checking') {
|
||||||
return <div className="system-admin-gate"><Spin tip="正在验证系统管理员权限…" /></div>;
|
return <div className="system-admin-gate"><Spin description="正在验证系统管理员权限…" /></div>;
|
||||||
}
|
}
|
||||||
if (access === 'denied') {
|
if (access === 'denied') {
|
||||||
return <Result status="403" title="无权访问系统管理" subTitle="请联系系统维护者授予权限" extra={
|
return <Result status="403" title="无权访问系统管理" subTitle="请联系系统维护者授予权限" extra={
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
|||||||
title: snapshot.profile.title,
|
title: snapshot.profile.title,
|
||||||
description: snapshot.profile.description,
|
description: snapshot.profile.description,
|
||||||
tags: snapshot.profile.tags,
|
tags: snapshot.profile.tags,
|
||||||
store_product_category_id: snapshot.profile.category.id,
|
platform_product_category_id: snapshot.profile.category.id,
|
||||||
category_source: snapshot.profile.category.source as
|
category_source: snapshot.profile.category.source as
|
||||||
'source_capture' | 'ai_suggestion' | 'manual' | null,
|
'source_capture' | 'ai_suggestion' | 'manual' | null,
|
||||||
},
|
},
|
||||||
@@ -138,6 +138,7 @@ type CategoryTreeOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
isLeaf?: boolean;
|
isLeaf?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
children?: CategoryTreeOption[];
|
children?: CategoryTreeOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,12 +163,15 @@ function categoryTree(options: ProductEditorCategoryOption[]) {
|
|||||||
if (parent) parent.children!.push(node);
|
if (parent) parent.children!.push(node);
|
||||||
else roots.push(node);
|
else roots.push(node);
|
||||||
}
|
}
|
||||||
// Cascader navigation nodes must never be disabled: disabled folders cannot
|
// 导航节点不能禁用,否则无法展开;只有停用或不可发布的叶子节点禁用。
|
||||||
// be opened. `changeOnSelect` remains false (the default), so the component
|
|
||||||
// only commits a value for a leaf node.
|
|
||||||
for (const category of options) {
|
for (const category of options) {
|
||||||
const node = nodes.get(category.id)!;
|
const node = nodes.get(category.id)!;
|
||||||
node.isLeaf = !node.children?.length;
|
node.isLeaf = !node.children?.length;
|
||||||
|
node.disabled = node.isLeaf && (
|
||||||
|
!category.is_active ||
|
||||||
|
!category.external_type_id ||
|
||||||
|
category.external_type_id === '0'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const category of options) {
|
for (const category of options) {
|
||||||
const path: string[] = [];
|
const path: string[] = [];
|
||||||
@@ -363,8 +367,7 @@ const ProductEditPage = () => {
|
|||||||
const [form] = Form.useForm<EditorFormValues>();
|
const [form] = Form.useForm<EditorFormValues>();
|
||||||
// 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。
|
// 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。
|
||||||
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
|
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
|
||||||
const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form);
|
const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form);
|
||||||
const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form);
|
|
||||||
// 编辑器快照与采集来源:sourceDraft 是用户可能改写过的采集素材副本。
|
// 编辑器快照与采集来源:sourceDraft 是用户可能改写过的采集素材副本。
|
||||||
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
||||||
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
||||||
@@ -372,7 +375,7 @@ const ProductEditPage = () => {
|
|||||||
const [sourceOverriddenFields, setSourceOverriddenFields] = useState<ProductAiSourceField[]>(
|
const [sourceOverriddenFields, setSourceOverriddenFields] = useState<ProductAiSourceField[]>(
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
// 类目缓存:随店铺切换与目录同步(catalogRefresh)重新拉取。
|
// 类目缓存属于平台,同平台切换店铺不会重新加载或清空类目选择。
|
||||||
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
|
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
|
||||||
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
||||||
const [categorySearch, setCategorySearch] = useState('');
|
const [categorySearch, setCategorySearch] = useState('');
|
||||||
@@ -389,14 +392,13 @@ const ProductEditPage = () => {
|
|||||||
>({});
|
>({});
|
||||||
const [dictionaryCursors, setDictionaryCursors] = useState<Record<string, string | null>>({});
|
const [dictionaryCursors, setDictionaryCursors] = useState<Record<string, string | null>>({});
|
||||||
const dictionaryQueries = useRef<Record<string, string>>({});
|
const dictionaryQueries = useRef<Record<string, string>>({});
|
||||||
const dictionaryInitialSyncs = useRef(new Set<string>());
|
|
||||||
const dictionaryPageLoads = useRef(new Set<string>());
|
const dictionaryPageLoads = useRef(new Set<string>());
|
||||||
const dictionaryContext = `${snapshot?.profile.id ?? ''}:${selectedStoreId ?? ''}:${selectedCategoryId ?? ''}`;
|
const dictionaryPollAttempts = useRef<Record<string, number>>({});
|
||||||
|
const schemaPollAttempts = useRef(0);
|
||||||
|
const dictionaryContext = `${snapshot?.profile.id ?? ''}:${selectedCategoryId ?? ''}`;
|
||||||
const dictionaryContextRef = useRef(dictionaryContext);
|
const dictionaryContextRef = useRef(dictionaryContext);
|
||||||
dictionaryContextRef.current = dictionaryContext;
|
dictionaryContextRef.current = dictionaryContext;
|
||||||
const [catalogRefresh, setCatalogRefresh] = useState(0);
|
const [catalogRefresh, setCatalogRefresh] = useState(0);
|
||||||
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
|
|
||||||
const catalogSyncToken = useRef<symbol | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
||||||
@@ -406,9 +408,9 @@ const ProductEditPage = () => {
|
|||||||
const sourceSpecsApplied = useRef(false);
|
const sourceSpecsApplied = useRef(false);
|
||||||
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
|
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
|
||||||
const categoryNotFoundContent = categoryOptionsLoading
|
const categoryNotFoundContent = categoryOptionsLoading
|
||||||
? '正在读取该店铺的类目缓存…'
|
? '正在读取平台类目缓存…'
|
||||||
: categoryOptions.length === 0
|
: categoryOptions.length === 0
|
||||||
? '该店铺还没有可用类目缓存,请先验证店铺或稍后刷新'
|
? '该平台还没有可用类目缓存,请联系系统管理员同步'
|
||||||
: categorySearch.trim()
|
: categorySearch.trim()
|
||||||
? '未找到匹配的类目'
|
? '未找到匹配的类目'
|
||||||
: '暂无可选择的类目';
|
: '暂无可选择的类目';
|
||||||
@@ -448,7 +450,7 @@ const ProductEditPage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
// 类目缓存:切换店铺或目录同步完成后重新拉取。
|
// 类目树在平台级共享,只随平台或编辑上下文变化重新读取。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform) return;
|
if (!snapshot || !currentPlatform) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -456,7 +458,7 @@ const ProductEditPage = () => {
|
|||||||
setCategoryOptions([]);
|
setCategoryOptions([]);
|
||||||
setCategorySearch('');
|
setCategorySearch('');
|
||||||
void productEditorApi
|
void productEditorApi
|
||||||
.categoryOptions(snapshot.profile.id, currentPlatform.id, undefined, selectedStoreId)
|
.categoryOptions(snapshot.profile.id, currentPlatform.id)
|
||||||
.then((options) => {
|
.then((options) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
setCategoryOptions(options);
|
setCategoryOptions(options);
|
||||||
@@ -472,14 +474,15 @@ const ProductEditPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
|
}, [currentPlatform, snapshot]);
|
||||||
// 切换店铺或类目后,不复用旧类目的字典搜索、游标和自动同步记录。
|
// 切换类目后,不复用旧类型的字典搜索、游标和请求记录。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dictionaryQueries.current = {};
|
dictionaryQueries.current = {};
|
||||||
dictionaryInitialSyncs.current.clear();
|
|
||||||
dictionaryPageLoads.current.clear();
|
dictionaryPageLoads.current.clear();
|
||||||
|
dictionaryPollAttempts.current = {};
|
||||||
|
schemaPollAttempts.current = 0;
|
||||||
setDictionaryCursors({});
|
setDictionaryCursors({});
|
||||||
}, [selectedCategoryId, selectedStoreId, snapshot?.profile.id]);
|
}, [selectedCategoryId, snapshot?.profile.id]);
|
||||||
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
|
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
||||||
@@ -505,14 +508,10 @@ const ProductEditPage = () => {
|
|||||||
setDictionaryValues({});
|
setDictionaryValues({});
|
||||||
setDictionaryCursors({});
|
setDictionaryCursors({});
|
||||||
void productEditorApi
|
void productEditorApi
|
||||||
.attributeSchema(
|
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId)
|
||||||
snapshot.profile.id,
|
.then((result) => {
|
||||||
currentPlatform.id,
|
|
||||||
selectedCategoryId,
|
|
||||||
selectedStoreId
|
|
||||||
)
|
|
||||||
.then((schema) => {
|
|
||||||
if (active) {
|
if (active) {
|
||||||
|
const schema = result.items;
|
||||||
const currentAttributes = (form.getFieldValue('attributes') ??
|
const currentAttributes = (form.getFieldValue('attributes') ??
|
||||||
[]) as AttributeDraft[];
|
[]) as AttributeDraft[];
|
||||||
const missing = missingAttributeDrafts(currentAttributes, schema, snapshot);
|
const missing = missingAttributeDrafts(currentAttributes, schema, snapshot);
|
||||||
@@ -523,6 +522,17 @@ const ProductEditPage = () => {
|
|||||||
setAttributeSchemaCategoryId(selectedCategoryId);
|
setAttributeSchemaCategoryId(selectedCategoryId);
|
||||||
setAttributeSchemaCatalogRefresh(catalogRefresh);
|
setAttributeSchemaCatalogRefresh(catalogRefresh);
|
||||||
setAttributeSchemaLoading(false);
|
setAttributeSchemaLoading(false);
|
||||||
|
if (result.cache.status === 'expired' || result.cache.status === 'missing') {
|
||||||
|
schemaPollAttempts.current += 1;
|
||||||
|
if (schemaPollAttempts.current < 20) {
|
||||||
|
setAttributeSchemaError('正在通过高优先级任务更新 Ozon 类目属性…');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (active) setCatalogRefresh((version) => version + 1);
|
||||||
|
}, result.cache.retry_after_ms ?? 1_000);
|
||||||
|
} else {
|
||||||
|
setAttributeSchemaError('Ozon 类目属性更新仍未完成,请稍后重试');
|
||||||
|
}
|
||||||
|
} else schemaPollAttempts.current = 0;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -544,37 +554,35 @@ const ProductEditPage = () => {
|
|||||||
currentPlatform,
|
currentPlatform,
|
||||||
form,
|
form,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
selectedStoreId,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
]);
|
]);
|
||||||
// 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。
|
// 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||||
const dictionaryIds = [
|
const attributeIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
editableAttributeSchema
|
editableAttributeSchema
|
||||||
.map((attribute) => attribute.dictionary_id)
|
.filter((attribute) => !!attribute.dictionary_id)
|
||||||
.filter((dictionaryId): dictionaryId is string => !!dictionaryId)
|
.map((attribute) => attribute.attribute_id)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (!dictionaryIds.length) {
|
if (!attributeIds.length) {
|
||||||
setDictionaryValues({});
|
setDictionaryValues({});
|
||||||
setDictionaryCursors({});
|
setDictionaryCursors({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
dictionaryIds.map(
|
attributeIds.map(
|
||||||
async (dictionaryId) =>
|
async (attributeId) =>
|
||||||
[
|
[
|
||||||
dictionaryId,
|
attributeId,
|
||||||
await productEditorApi.attributeDictionary(
|
await productEditorApi.attributeDictionary(
|
||||||
snapshot.profile.id,
|
snapshot.profile.id,
|
||||||
currentPlatform.id,
|
currentPlatform.id,
|
||||||
dictionaryId,
|
attributeId,
|
||||||
undefined,
|
undefined,
|
||||||
selectedCategoryId,
|
selectedCategoryId
|
||||||
selectedStoreId
|
|
||||||
),
|
),
|
||||||
] as const
|
] as const
|
||||||
)
|
)
|
||||||
@@ -612,7 +620,6 @@ const ProductEditPage = () => {
|
|||||||
currentPlatform,
|
currentPlatform,
|
||||||
editableAttributeSchema,
|
editableAttributeSchema,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
selectedStoreId,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -798,7 +805,7 @@ const ProductEditPage = () => {
|
|||||||
okText: '知道了',
|
okText: '知道了',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
/** 轮询目录同步 operation 直至终态(最多 20 次 × 1s);超时返回 null。 */
|
/** 轮询工作空间 operation 直至终态(最多 20 次 × 1s);用于素材转存。 */
|
||||||
const waitForCatalogOperation = async (operationId: string) => {
|
const waitForCatalogOperation = async (operationId: string) => {
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
if (attempt) await wait(1_000);
|
if (attempt) await wait(1_000);
|
||||||
@@ -807,78 +814,33 @@ const ProductEditPage = () => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
/** 跟踪 Ozon 目录同步;schema 成功后刷新目录,字典只重读对应选项。 */
|
|
||||||
const runCatalogSync = async (
|
|
||||||
syncKey: string,
|
|
||||||
action: () => Promise<{ operation: { id: string } }>,
|
|
||||||
successMessage: string | null,
|
|
||||||
refreshCatalog = true
|
|
||||||
) => {
|
|
||||||
const token = Symbol(syncKey);
|
|
||||||
catalogSyncToken.current = token;
|
|
||||||
setCatalogSyncing(syncKey);
|
|
||||||
try {
|
|
||||||
const queued = await action();
|
|
||||||
const completed = await waitForCatalogOperation(queued.operation.id);
|
|
||||||
if (completed?.status === 'succeeded') {
|
|
||||||
if (successMessage) message.success(successMessage);
|
|
||||||
if (refreshCatalog) setCatalogRefresh((version) => version + 1);
|
|
||||||
return true;
|
|
||||||
} else if (completed?.status === 'failed') {
|
|
||||||
message.error(completed.error?.message ?? 'Ozon 同步失败,请稍后重试');
|
|
||||||
} else if (completed?.status === 'cancelled') {
|
|
||||||
message.warning(completed.error?.message ?? '同步已取消,请重新发起');
|
|
||||||
} else {
|
|
||||||
message.info('同步仍在队列中,稍后刷新页面即可读取最新缓存');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error(apiErrorMessage(error));
|
|
||||||
} finally {
|
|
||||||
if (catalogSyncToken.current === token) {
|
|
||||||
catalogSyncToken.current = null;
|
|
||||||
setCatalogSyncing(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
const syncAttributeSchema = () => {
|
const syncAttributeSchema = () => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
setCatalogRefresh((version) => version + 1);
|
||||||
void runCatalogSync(
|
|
||||||
`attributes:${selectedCategoryId}`,
|
|
||||||
() =>
|
|
||||||
productEditorApi.syncAttributeSchema(
|
|
||||||
snapshot.profile.id,
|
|
||||||
currentPlatform.id,
|
|
||||||
selectedCategoryId
|
|
||||||
),
|
|
||||||
'该类目的属性已同步'
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const loadDictionaryValues = async (dictionaryId: string, query = '', cursor?: string) => {
|
const loadDictionaryValues = async (attributeId: string, query = '', cursor?: string) => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||||
const requestContext = dictionaryContext;
|
const requestContext = dictionaryContext;
|
||||||
const requestKey = `${requestContext}:${dictionaryId}:${query}:${cursor ?? ''}`;
|
const requestKey = `${requestContext}:${attributeId}:${query}:${cursor ?? ''}`;
|
||||||
if (dictionaryPageLoads.current.has(requestKey)) return;
|
if (dictionaryPageLoads.current.has(requestKey)) return;
|
||||||
dictionaryPageLoads.current.add(requestKey);
|
dictionaryPageLoads.current.add(requestKey);
|
||||||
if (!cursor) dictionaryQueries.current[dictionaryId] = query;
|
if (!cursor) dictionaryQueries.current[attributeId] = query;
|
||||||
try {
|
try {
|
||||||
const page = await productEditorApi.attributeDictionary(
|
const page = await productEditorApi.attributeDictionary(
|
||||||
snapshot.profile.id,
|
snapshot.profile.id,
|
||||||
currentPlatform.id,
|
currentPlatform.id,
|
||||||
dictionaryId,
|
attributeId,
|
||||||
query,
|
query,
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
selectedStoreId,
|
|
||||||
cursor
|
cursor
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
dictionaryContextRef.current !== requestContext ||
|
dictionaryContextRef.current !== requestContext ||
|
||||||
dictionaryQueries.current[dictionaryId] !== query
|
dictionaryQueries.current[attributeId] !== query
|
||||||
) return;
|
) return;
|
||||||
setDictionaryValues((current) => ({
|
setDictionaryValues((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[dictionaryId]: cursor
|
[attributeId]: cursor
|
||||||
? [...(current[dictionaryId] ?? []), ...page.items].filter(
|
? [...(current[attributeId] ?? []), ...page.items].filter(
|
||||||
(value, index, all) =>
|
(value, index, all) =>
|
||||||
all.findIndex((item) => item.value_id === value.value_id) === index
|
all.findIndex((item) => item.value_id === value.value_id) === index
|
||||||
)
|
)
|
||||||
@@ -886,50 +848,37 @@ const ProductEditPage = () => {
|
|||||||
}));
|
}));
|
||||||
setDictionaryCursors((current) => ({
|
setDictionaryCursors((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[dictionaryId]: page.next_cursor,
|
[attributeId]: page.next_cursor,
|
||||||
}));
|
}));
|
||||||
|
const pollKey = `${attributeId}:${query}`;
|
||||||
|
if (page.cache.status === 'expired' || page.cache.status === 'missing') {
|
||||||
|
dictionaryPollAttempts.current[pollKey] =
|
||||||
|
(dictionaryPollAttempts.current[pollKey] ?? 0) + 1;
|
||||||
|
if (dictionaryPollAttempts.current[pollKey] < 20) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (dictionaryContextRef.current === requestContext)
|
||||||
|
void loadDictionaryValues(attributeId, query, cursor);
|
||||||
|
}, page.cache.retry_after_ms ?? 1_000);
|
||||||
|
}
|
||||||
|
} else dictionaryPollAttempts.current[pollKey] = 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(apiErrorMessage(error));
|
message.error(apiErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
dictionaryPageLoads.current.delete(requestKey);
|
dictionaryPageLoads.current.delete(requestKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const syncDictionaryValues = async (dictionaryId: string, query: string) => {
|
const syncDictionaryValues = async (attributeId: string, query: string) => {
|
||||||
if (!snapshot || !currentPlatform || !selectedCategoryId || query.length === 1)
|
if (!snapshot || !currentPlatform || !selectedCategoryId || query.length === 1)
|
||||||
return false;
|
return false;
|
||||||
const requestContext = dictionaryContext;
|
await loadDictionaryValues(attributeId, query);
|
||||||
const succeeded = await runCatalogSync(
|
return true;
|
||||||
`dictionary:${dictionaryId}:${query}`,
|
|
||||||
() =>
|
|
||||||
productEditorApi.syncAttributeDictionary(
|
|
||||||
snapshot.profile.id,
|
|
||||||
currentPlatform.id,
|
|
||||||
dictionaryId,
|
|
||||||
selectedCategoryId,
|
|
||||||
query
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
succeeded && dictionaryContextRef.current === requestContext &&
|
|
||||||
dictionaryQueries.current[dictionaryId] === query
|
|
||||||
) void loadDictionaryValues(dictionaryId, query);
|
|
||||||
return succeeded;
|
|
||||||
};
|
};
|
||||||
const openDictionaryValues = (dictionaryId: string) => {
|
const openDictionaryValues = (attributeId: string) => {
|
||||||
void loadDictionaryValues(dictionaryId, '');
|
void loadDictionaryValues(attributeId, '');
|
||||||
if (dictionaryInitialSyncs.current.has(dictionaryId)) return;
|
|
||||||
dictionaryInitialSyncs.current.add(dictionaryId);
|
|
||||||
const requestContext = dictionaryContext;
|
|
||||||
void syncDictionaryValues(dictionaryId, '').then((succeeded) => {
|
|
||||||
if (!succeeded && dictionaryContextRef.current === requestContext)
|
|
||||||
dictionaryInitialSyncs.current.delete(dictionaryId);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const loadMoreDictionaryValues = (dictionaryId: string, query: string) => {
|
const loadMoreDictionaryValues = (attributeId: string, query: string) => {
|
||||||
const cursor = dictionaryCursors[dictionaryId];
|
const cursor = dictionaryCursors[attributeId];
|
||||||
if (cursor) void loadDictionaryValues(dictionaryId, query, cursor);
|
if (cursor) void loadDictionaryValues(attributeId, query, cursor);
|
||||||
};
|
};
|
||||||
/** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */
|
/** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */
|
||||||
const materializeCandidate = async (transferId: string) => {
|
const materializeCandidate = async (transferId: string) => {
|
||||||
@@ -1038,7 +987,7 @@ const ProductEditPage = () => {
|
|||||||
activeAttributeSchema={activeAttributeSchema}
|
activeAttributeSchema={activeAttributeSchema}
|
||||||
attributeSchemaLoading={attributeSchemaLoading}
|
attributeSchemaLoading={attributeSchemaLoading}
|
||||||
attributeSchemaError={attributeSchemaError}
|
attributeSchemaError={attributeSchemaError}
|
||||||
catalogSyncing={catalogSyncing}
|
catalogSyncing={null}
|
||||||
dictionaryValues={dictionaryValues}
|
dictionaryValues={dictionaryValues}
|
||||||
onLoadDictionaryValues={(dictionaryId, query) =>
|
onLoadDictionaryValues={(dictionaryId, query) =>
|
||||||
void loadDictionaryValues(dictionaryId, query)
|
void loadDictionaryValues(dictionaryId, query)
|
||||||
@@ -1055,7 +1004,7 @@ const ProductEditPage = () => {
|
|||||||
collectionRecord={collectionRecord}
|
collectionRecord={collectionRecord}
|
||||||
materializingTransferId={materializingTransferId}
|
materializingTransferId={materializingTransferId}
|
||||||
activeAttributeSchema={activeAttributeSchema}
|
activeAttributeSchema={activeAttributeSchema}
|
||||||
catalogSyncing={catalogSyncing}
|
catalogSyncing={null}
|
||||||
dictionaryValues={dictionaryValues}
|
dictionaryValues={dictionaryValues}
|
||||||
onMaterialize={materializeCandidate}
|
onMaterialize={materializeCandidate}
|
||||||
onLoadDictionaryValues={(dictionaryId, query) =>
|
onLoadDictionaryValues={(dictionaryId, query) =>
|
||||||
|
|||||||
@@ -64,20 +64,20 @@ function DynamicAttributeField({
|
|||||||
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
||||||
const onSyncRef = useRef(onSyncDictionaryValues);
|
const onSyncRef = useRef(onSyncDictionaryValues);
|
||||||
onSyncRef.current = onSyncDictionaryValues;
|
onSyncRef.current = onSyncDictionaryValues;
|
||||||
useEffect(() => setDictionaryQuery(''), [definition.dictionary_id]);
|
useEffect(() => setDictionaryQuery(''), [definition.attribute_id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
|
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
|
||||||
const timer = window.setTimeout(
|
const timer = window.setTimeout(
|
||||||
() => onSyncRef.current(definition.dictionary_id!, dictionaryQuery.trim()),
|
() => onSyncRef.current(definition.attribute_id, dictionaryQuery.trim()),
|
||||||
400
|
400
|
||||||
);
|
);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [definition.dictionary_id, dictionaryQuery]);
|
}, [definition.attribute_id, definition.dictionary_id, dictionaryQuery]);
|
||||||
|
|
||||||
const valueName = ['attributes', index, 'values'] as NamePath;
|
const valueName = ['attributes', index, 'values'] as NamePath;
|
||||||
const scalarValueName = [...valueName, 0] as NamePath;
|
const scalarValueName = [...valueName, 0] as NamePath;
|
||||||
const dictionaryOptions = definition.dictionary_id
|
const dictionaryOptions = definition.dictionary_id
|
||||||
? (dictionaryValues[definition.dictionary_id] ?? []).map((value) => ({
|
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
|
||||||
value: value.value_id,
|
value: value.value_id,
|
||||||
label: value.value,
|
label: value.value,
|
||||||
}))
|
}))
|
||||||
@@ -95,20 +95,20 @@ function DynamicAttributeField({
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open || !definition.dictionary_id) return;
|
if (!open || !definition.dictionary_id) return;
|
||||||
setDictionaryQuery('');
|
setDictionaryQuery('');
|
||||||
onOpenDictionaryValues(definition.dictionary_id);
|
onOpenDictionaryValues(definition.attribute_id);
|
||||||
}}
|
}}
|
||||||
onSearch={(query) => {
|
onSearch={(query) => {
|
||||||
if (!definition.dictionary_id) return;
|
if (!definition.dictionary_id) return;
|
||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
setDictionaryQuery(needle);
|
setDictionaryQuery(needle);
|
||||||
onLoadDictionaryValues(definition.dictionary_id, needle);
|
onLoadDictionaryValues(definition.attribute_id, needle);
|
||||||
}}
|
}}
|
||||||
onPopupScroll={(event) => {
|
onPopupScroll={(event) => {
|
||||||
if (!definition.dictionary_id) return;
|
if (!definition.dictionary_id) return;
|
||||||
const target = event.currentTarget;
|
const target = event.currentTarget;
|
||||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
|
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
|
||||||
onLoadMoreDictionaryValues(
|
onLoadMoreDictionaryValues(
|
||||||
definition.dictionary_id,
|
definition.attribute_id,
|
||||||
dictionaryQuery.trim()
|
dictionaryQuery.trim()
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -118,20 +118,20 @@ function DynamicAttributeField({
|
|||||||
type="link"
|
type="link"
|
||||||
loading={
|
loading={
|
||||||
catalogSyncing ===
|
catalogSyncing ===
|
||||||
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
|
`dictionary:${definition.attribute_id}:${dictionaryQuery.trim()}`
|
||||||
}
|
}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
definition.dictionary_id &&
|
definition.dictionary_id &&
|
||||||
onSyncDictionaryValues(
|
onSyncDictionaryValues(
|
||||||
definition.dictionary_id,
|
definition.attribute_id,
|
||||||
dictionaryQuery.trim()
|
dictionaryQuery.trim()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
从 Ozon 搜索“{dictionaryQuery.trim()}”
|
从 Ozon 搜索“{dictionaryQuery.trim()}”
|
||||||
</Button>
|
</Button>
|
||||||
) : catalogSyncing === `dictionary:${definition.dictionary_id}:` ? (
|
) : catalogSyncing === `dictionary:${definition.attribute_id}:` ? (
|
||||||
'正在从 Ozon 加载选项…'
|
'正在从 Ozon 加载选项…'
|
||||||
) : (
|
) : (
|
||||||
'暂无选项,输入至少 2 个字符可从 Ozon 搜索'
|
'暂无选项,输入至少 2 个字符可从 Ozon 搜索'
|
||||||
|
|||||||
@@ -155,10 +155,10 @@ export function ProductAttributeSection({
|
|||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
loading={catalogSyncing === `attributes:${selectedCategoryId}`}
|
loading={attributeSchemaLoading}
|
||||||
onClick={onSyncAttributeSchema}
|
onClick={onSyncAttributeSchema}
|
||||||
>
|
>
|
||||||
同步此类目属性
|
重试更新
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type CategoryOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
isLeaf?: boolean;
|
isLeaf?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
children?: CategoryOption[];
|
children?: CategoryOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* 「01 主要信息」区块:上架店铺、产品类目、标题/型号/品牌与默认包装规格。
|
* 「01 主要信息」区块:上架店铺、产品类目、标题/型号/品牌与默认包装规格。
|
||||||
*
|
*
|
||||||
* 切换店铺会清空类目与属性草稿;切换类目会清空属性草稿并记录类目来源,
|
* 类目属于平台级共享参考数据,切换同平台店铺不会清空类目;切换类目时
|
||||||
* 避免跨类目残留脏数据。
|
* 清空动态属性草稿并记录类目来源,避免跨类型残留脏数据。
|
||||||
*/
|
*/
|
||||||
export function ProductMainInfoSection({
|
export function ProductMainInfoSection({
|
||||||
form,
|
form,
|
||||||
@@ -83,15 +84,10 @@ export function ProductMainInfoSection({
|
|||||||
value: store.id,
|
value: store.id,
|
||||||
label: `${store.name}(${store.currency_code})`,
|
label: `${store.name}(${store.currency_code})`,
|
||||||
}))}
|
}))}
|
||||||
onChange={() => {
|
|
||||||
form.setFieldValue(['profile', 'store_product_category_id'], null);
|
|
||||||
form.setFieldValue(['profile', 'category_source'], null);
|
|
||||||
form.setFieldValue('attributes', []);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['profile', 'store_product_category_id']}
|
name={['profile', 'platform_product_category_id']}
|
||||||
label="产品类目"
|
label="产品类目"
|
||||||
className="span-full"
|
className="span-full"
|
||||||
rules={[{ required: true, message: '请选择产品类目' }]}
|
rules={[{ required: true, message: '请选择产品类目' }]}
|
||||||
@@ -105,7 +101,7 @@ export function ProductMainInfoSection({
|
|||||||
classNames={{ popup: { root: 'editor-category-popup' } }}
|
classNames={{ popup: { root: 'editor-category-popup' } }}
|
||||||
showSearch
|
showSearch
|
||||||
onSearch={onCategorySearch}
|
onSearch={onCategorySearch}
|
||||||
placeholder="选择当前店铺可发布的产品类目"
|
placeholder="选择当前平台的产品类目"
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
notFoundContent={categoryNotFoundContent}
|
notFoundContent={categoryNotFoundContent}
|
||||||
getPopupContainer={(node) => node.parentElement ?? document.body}
|
getPopupContainer={(node) => node.parentElement ?? document.body}
|
||||||
|
|||||||
@@ -1,32 +1,192 @@
|
|||||||
.system-catalog-page { display: grid; gap: 16px; }
|
.system-catalog-page {
|
||||||
.system-catalog-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
display: grid;
|
||||||
.system-catalog-heading .ant-typography { margin-bottom: 2px; }
|
gap: 16px;
|
||||||
.system-catalog-platform-select { min-width: 190px; }
|
}
|
||||||
.system-catalog-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
.system-catalog-heading {
|
||||||
.system-catalog-summary .ant-card-body { padding: 16px 20px; }
|
display: flex;
|
||||||
.system-catalog-summary .ant-statistic-content { font-size: 19px; }
|
align-items: flex-end;
|
||||||
.system-catalog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
justify-content: space-between;
|
||||||
.system-catalog-browser { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); gap: 16px; align-items: stretch; }
|
gap: 16px;
|
||||||
.system-catalog-category-card, .system-catalog-attribute-card { min-width: 0; }
|
}
|
||||||
.system-catalog-tree-panel { display: grid; gap: 12px; max-height: 620px; overflow: auto; }
|
.system-catalog-heading .ant-typography {
|
||||||
.system-catalog-tree-panel .ant-tree { min-width: 0; }
|
margin-bottom: 2px;
|
||||||
.system-catalog-tree-label { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; flex-wrap: wrap; white-space: normal; }
|
}
|
||||||
.system-catalog-tree-label .ant-typography { font-size: 12px; }
|
.system-catalog-platform-select {
|
||||||
.system-catalog-attribute-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
min-width: 190px;
|
||||||
.system-catalog-attribute-name .ant-typography:last-child { font-size: 12px; }
|
}
|
||||||
.system-catalog-attribute-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
.system-catalog-summary {
|
||||||
.system-catalog-attribute-tags .ant-tag { margin: 0; }
|
display: grid;
|
||||||
.system-catalog-values-link { padding-inline: 0; }
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
.system-catalog-values-content { width: 100%; }
|
gap: 12px;
|
||||||
.system-catalog-wrap { white-space: normal; overflow-wrap: anywhere; }
|
}
|
||||||
.system-catalog-panel-loading { display: grid; min-height: 160px; place-items: center; }
|
.system-catalog-summary .ant-card-body {
|
||||||
.system-catalog-source-info { margin-bottom: 16px; }
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.system-catalog-summary .ant-statistic-content {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
.system-catalog-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.system-catalog-page .ant-input-number {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
.system-catalog-policy-modal .ant-input-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.system-catalog-policy-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.system-catalog-policy-loading {
|
||||||
|
display: grid;
|
||||||
|
min-height: 220px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.system-catalog-cache-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.system-catalog-cache-summary > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.system-catalog-cache-summary .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-batch-actions {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.system-catalog-active-batch-note {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.system-catalog-batch-table {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.system-catalog-batch-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.system-catalog-batch-progress .ant-progress {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 34%) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.system-catalog-category-card,
|
||||||
|
.system-catalog-attribute-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 620px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-search {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-scroll .ant-tree {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-label .ant-typography {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.system-catalog-attribute-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.system-catalog-attribute-name .ant-typography:last-child {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.system-catalog-attribute-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.system-catalog-attribute-tags .ant-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-values-link {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-dictionary-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.system-catalog-dictionary-cell .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
.system-catalog-values-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.system-catalog-wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.system-catalog-panel-loading {
|
||||||
|
display: grid;
|
||||||
|
min-height: 160px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.system-catalog-source-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.system-catalog-browser { grid-template-columns: 1fr; }
|
.system-catalog-summary {
|
||||||
.system-catalog-tree-panel { max-height: 340px; }
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.system-catalog-browser {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.system-catalog-tree-panel {
|
||||||
|
height: 340px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.system-catalog-heading { align-items: stretch; flex-direction: column; }
|
.system-catalog-heading {
|
||||||
.system-catalog-summary { grid-template-columns: 1fr; }
|
align-items: stretch;
|
||||||
.system-catalog-platform-select { flex: 1; }
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.system-catalog-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.system-catalog-platform-select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, App as AntdApp, Button, Card, Empty, Select, Space, Statistic,
|
Alert,
|
||||||
Table, Tag, Typography,
|
App as AntdApp,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorMessage } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
platformCatalogAdminApi,
|
platformCatalogAdminApi,
|
||||||
type AdminPlatform, type CatalogAttribute, type CatalogCategory,
|
type AdminPlatform,
|
||||||
type CatalogRun, type SyncRequest,
|
type AttributeBatchMode,
|
||||||
|
type AttributeSyncBatch,
|
||||||
|
type AttributeSyncSummary,
|
||||||
|
type CatalogAttribute,
|
||||||
|
type CatalogCategory,
|
||||||
|
type CatalogRefreshPolicy,
|
||||||
|
type CatalogRefreshPolicyInput,
|
||||||
|
type CatalogRun,
|
||||||
|
type SyncRequest,
|
||||||
} from '@/services/platformCatalogAdmin';
|
} from '@/services/platformCatalogAdmin';
|
||||||
|
import AttributeSyncControlCard from './components/AttributeSyncControlCard';
|
||||||
|
import CatalogCacheStatusTag from './components/CatalogCacheStatusTag';
|
||||||
|
import CatalogRefreshPolicyModal from './components/CatalogRefreshPolicyModal';
|
||||||
import CatalogSourceModal from './components/CatalogSourceModal';
|
import CatalogSourceModal from './components/CatalogSourceModal';
|
||||||
|
import CatalogSyncRunsTable from './components/CatalogSyncRunsTable';
|
||||||
import DictionaryValuesDrawer from './components/DictionaryValuesDrawer';
|
import DictionaryValuesDrawer from './components/DictionaryValuesDrawer';
|
||||||
import PlatformAttributesTable from './components/PlatformAttributesTable';
|
import PlatformAttributesTable from './components/PlatformAttributesTable';
|
||||||
import PlatformCategoryTree from './components/PlatformCategoryTree';
|
import PlatformCategoryTree from './components/PlatformCategoryTree';
|
||||||
@@ -21,16 +39,6 @@ function dateTime(value: string | null) {
|
|||||||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未同步';
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未同步';
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
|
||||||
queued: { label: '排队中', color: 'gold' },
|
|
||||||
running: { label: '同步中', color: 'blue' },
|
|
||||||
succeeded: { label: '成功', color: 'green' },
|
|
||||||
failed: { label: '失败', color: 'red' },
|
|
||||||
cancelled: { label: '已取消', color: 'default' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const scopeLabels = { tree: '类目树', attributes: '类型属性', dictionary: '字典候选值' };
|
|
||||||
|
|
||||||
export default function SystemCatalogPage() {
|
export default function SystemCatalogPage() {
|
||||||
const { message } = AntdApp.useApp();
|
const { message } = AntdApp.useApp();
|
||||||
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
|
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
|
||||||
@@ -38,24 +46,36 @@ export default function SystemCatalogPage() {
|
|||||||
const [categories, setCategories] = useState<CatalogCategory[]>([]);
|
const [categories, setCategories] = useState<CatalogCategory[]>([]);
|
||||||
const [attributes, setAttributes] = useState<CatalogAttribute[]>([]);
|
const [attributes, setAttributes] = useState<CatalogAttribute[]>([]);
|
||||||
const [runs, setRuns] = useState<CatalogRun[]>([]);
|
const [runs, setRuns] = useState<CatalogRun[]>([]);
|
||||||
|
const [refreshPolicy, setRefreshPolicy] = useState<CatalogRefreshPolicy | null>(null);
|
||||||
|
const [attributeSummary, setAttributeSummary] = useState<AttributeSyncSummary | null>(null);
|
||||||
|
const [attributeBatches, setAttributeBatches] = useState<AttributeSyncBatch[]>([]);
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||||
const [selectedAttribute, setSelectedAttribute] = useState<CatalogAttribute | null>(null);
|
const [selectedAttributeId, setSelectedAttributeId] = useState<string | null>(null);
|
||||||
const [sourceOpen, setSourceOpen] = useState(false);
|
const [sourceOpen, setSourceOpen] = useState(false);
|
||||||
|
const [refreshPolicyOpen, setRefreshPolicyOpen] = useState(false);
|
||||||
const [loadingPlatforms, setLoadingPlatforms] = useState(false);
|
const [loadingPlatforms, setLoadingPlatforms] = useState(false);
|
||||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||||
const [loadingAttributes, setLoadingAttributes] = useState(false);
|
const [loadingAttributes, setLoadingAttributes] = useState(false);
|
||||||
|
const [loadingManagement, setLoadingManagement] = useState(false);
|
||||||
|
const [refreshingRuns, setRefreshingRuns] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
const [syncingScope, setSyncingScope] = useState<string | null>(null);
|
const [syncingScope, setSyncingScope] = useState<string | null>(null);
|
||||||
|
const [startingBatchMode, setStartingBatchMode] = useState<AttributeBatchMode | null>(null);
|
||||||
const [refreshToken, setRefreshToken] = useState(0);
|
const [refreshToken, setRefreshToken] = useState(0);
|
||||||
const previousRunStatuses = useRef<Map<string, string>>(new Map());
|
const previousRunStatuses = useRef<Map<string, string>>(new Map());
|
||||||
const selectedCategoryIdRef = useRef<string | null>(null);
|
const selectedCategoryIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const currentPlatform = useMemo(
|
const currentPlatform = useMemo(
|
||||||
() => platforms.find((item) => item.id === platformId) ?? null,
|
() => platforms.find((item) => item.id === platformId) ?? null,
|
||||||
[platforms, platformId],
|
[platforms, platformId]
|
||||||
);
|
);
|
||||||
const selectedCategory = useMemo(
|
const selectedCategory = useMemo(
|
||||||
() => categories.find((item) => item.id === selectedCategoryId) ?? null,
|
() => categories.find((item) => item.id === selectedCategoryId) ?? null,
|
||||||
[categories, selectedCategoryId],
|
[categories, selectedCategoryId]
|
||||||
|
);
|
||||||
|
const selectedAttribute = useMemo(
|
||||||
|
() => attributes.find((item) => item.id === selectedAttributeId) ?? null,
|
||||||
|
[attributes, selectedAttributeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadPlatforms = useCallback(async () => {
|
const loadPlatforms = useCallback(async () => {
|
||||||
@@ -63,8 +83,13 @@ export default function SystemCatalogPage() {
|
|||||||
try {
|
try {
|
||||||
const items = await platformCatalogAdminApi.platforms();
|
const items = await platformCatalogAdminApi.platforms();
|
||||||
setPlatforms(items);
|
setPlatforms(items);
|
||||||
setPlatformId((previous) => previous && items.some((item) => item.id === previous)
|
setPlatformId((previous) =>
|
||||||
? previous : (items.find((item) => item.code.toUpperCase() === 'OZON')?.id ?? items[0]?.id ?? null));
|
previous && items.some((item) => item.id === previous)
|
||||||
|
? previous
|
||||||
|
: (items.find((item) => item.code.toUpperCase() === 'OZON')?.id ??
|
||||||
|
items[0]?.id ??
|
||||||
|
null)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(apiErrorMessage(error));
|
message.error(apiErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,7 +97,8 @@ export default function SystemCatalogPage() {
|
|||||||
}
|
}
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
const loadCategories = useCallback(async (id: string) => {
|
const loadCategories = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
setLoadingCategories(true);
|
setLoadingCategories(true);
|
||||||
try {
|
try {
|
||||||
setCategories(await platformCatalogAdminApi.categories(id));
|
setCategories(await platformCatalogAdminApi.categories(id));
|
||||||
@@ -81,9 +107,12 @@ export default function SystemCatalogPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingCategories(false);
|
setLoadingCategories(false);
|
||||||
}
|
}
|
||||||
}, [message]);
|
},
|
||||||
|
[message]
|
||||||
|
);
|
||||||
|
|
||||||
const loadAttributes = useCallback(async (id: string, categoryId: string) => {
|
const loadAttributes = useCallback(
|
||||||
|
async (id: string, categoryId: string) => {
|
||||||
setLoadingAttributes(true);
|
setLoadingAttributes(true);
|
||||||
try {
|
try {
|
||||||
setAttributes(await platformCatalogAdminApi.attributes(id, categoryId));
|
setAttributes(await platformCatalogAdminApi.attributes(id, categoryId));
|
||||||
@@ -92,48 +121,93 @@ export default function SystemCatalogPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingAttributes(false);
|
setLoadingAttributes(false);
|
||||||
}
|
}
|
||||||
}, [message]);
|
},
|
||||||
|
[message]
|
||||||
|
);
|
||||||
|
|
||||||
const loadRuns = useCallback(async (id: string) => {
|
const loadManagement = useCallback(
|
||||||
|
async (id: string, reportError = true) => {
|
||||||
|
if (reportError) setLoadingManagement(true);
|
||||||
|
try {
|
||||||
|
const [policy, summary, batches] = await Promise.all([
|
||||||
|
platformCatalogAdminApi.refreshPolicy(id),
|
||||||
|
platformCatalogAdminApi.attributeSyncSummary(id),
|
||||||
|
platformCatalogAdminApi.attributeSyncBatches(id),
|
||||||
|
]);
|
||||||
|
setRefreshPolicy(policy);
|
||||||
|
setAttributeSummary(summary);
|
||||||
|
setAttributeBatches(batches);
|
||||||
|
} catch (error) {
|
||||||
|
if (reportError) message.error(apiErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
if (reportError) setLoadingManagement(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[message]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadRuns = useCallback(
|
||||||
|
async (id: string, reportError = false) => {
|
||||||
try {
|
try {
|
||||||
const next = await platformCatalogAdminApi.runs(id);
|
const next = await platformCatalogAdminApi.runs(id);
|
||||||
const completed = next.some((run) => run.status === 'succeeded' &&
|
const completed = next.some(
|
||||||
|
(run) =>
|
||||||
|
run.status === 'succeeded' &&
|
||||||
previousRunStatuses.current.has(run.id) &&
|
previousRunStatuses.current.has(run.id) &&
|
||||||
previousRunStatuses.current.get(run.id) !== 'succeeded');
|
previousRunStatuses.current.get(run.id) !== 'succeeded'
|
||||||
|
);
|
||||||
previousRunStatuses.current = new Map(next.map((run) => [run.id, run.status]));
|
previousRunStatuses.current = new Map(next.map((run) => [run.id, run.status]));
|
||||||
setRuns(next);
|
setRuns(next);
|
||||||
if (completed) {
|
if (completed) {
|
||||||
setRefreshToken((value) => value + 1);
|
setRefreshToken((value) => value + 1);
|
||||||
void loadPlatforms();
|
void loadPlatforms();
|
||||||
void loadCategories(id);
|
void loadCategories(id);
|
||||||
if (selectedCategoryIdRef.current) void loadAttributes(id, selectedCategoryIdRef.current);
|
void loadManagement(id, false);
|
||||||
|
if (selectedCategoryIdRef.current) {
|
||||||
|
void loadAttributes(id, selectedCategoryIdRef.current);
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// 轮询失败时保留最后一次任务列表,页面手动刷新仍可重试。
|
|
||||||
}
|
}
|
||||||
}, [loadPlatforms, loadCategories, loadAttributes]);
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// 自动轮询失败时保留最后一次结果;手动刷新必须给管理员明确反馈。
|
||||||
|
if (reportError) message.error(apiErrorMessage(error));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadPlatforms, loadCategories, loadAttributes, loadManagement, message]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => { void loadPlatforms(); }, [loadPlatforms]);
|
useEffect(() => {
|
||||||
|
void loadPlatforms();
|
||||||
|
}, [loadPlatforms]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!platformId) return;
|
if (!platformId) return;
|
||||||
setSelectedCategoryId(null);
|
setSelectedCategoryId(null);
|
||||||
selectedCategoryIdRef.current = null;
|
selectedCategoryIdRef.current = null;
|
||||||
setSelectedAttribute(null);
|
setSelectedAttributeId(null);
|
||||||
setCategories([]);
|
setCategories([]);
|
||||||
setAttributes([]);
|
setAttributes([]);
|
||||||
|
setRefreshPolicy(null);
|
||||||
|
setAttributeSummary(null);
|
||||||
|
setAttributeBatches([]);
|
||||||
previousRunStatuses.current.clear();
|
previousRunStatuses.current.clear();
|
||||||
void loadCategories(platformId);
|
void loadCategories(platformId);
|
||||||
void loadRuns(platformId);
|
void loadRuns(platformId);
|
||||||
}, [platformId, loadCategories, loadRuns]);
|
void loadManagement(platformId);
|
||||||
|
}, [platformId, loadCategories, loadRuns, loadManagement]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!platformId || !selectedCategoryId) return;
|
if (!platformId || !selectedCategoryId) return;
|
||||||
|
setSelectedAttributeId(null);
|
||||||
void loadAttributes(platformId, selectedCategoryId);
|
void loadAttributes(platformId, selectedCategoryId);
|
||||||
}, [platformId, selectedCategoryId, loadAttributes]);
|
}, [platformId, selectedCategoryId, loadAttributes]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!platformId) return;
|
if (!platformId) return;
|
||||||
const timer = window.setInterval(() => void loadRuns(platformId), 5000);
|
const timer = window.setInterval(() => {
|
||||||
|
void loadRuns(platformId);
|
||||||
|
void loadManagement(platformId, false);
|
||||||
|
}, 5000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [platformId, loadRuns]);
|
}, [platformId, loadRuns, loadManagement]);
|
||||||
|
|
||||||
const sync = async (input: SyncRequest) => {
|
const sync = async (input: SyncRequest) => {
|
||||||
if (!platformId) return;
|
if (!platformId) return;
|
||||||
@@ -153,34 +227,82 @@ export default function SystemCatalogPage() {
|
|||||||
if (!platformId) return;
|
if (!platformId) return;
|
||||||
try {
|
try {
|
||||||
const result = await platformCatalogAdminApi.configureSource(platformId, input);
|
const result = await platformCatalogAdminApi.configureSource(platformId, input);
|
||||||
message.success(result.changed
|
message.success(
|
||||||
? '同步来源已保存,类目树同步已排队'
|
result.changed ? '同步来源已保存,类目树同步已排队' : '同步来源没有变化'
|
||||||
: '同步来源没有变化');
|
);
|
||||||
await loadPlatforms();
|
await Promise.all([loadPlatforms(), loadRuns(platformId)]);
|
||||||
await loadRuns(platformId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(apiErrorMessage(error));
|
message.error(apiErrorMessage(error));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const runColumns: ColumnsType<CatalogRun> = [
|
const saveRefreshPolicy = async (input: CatalogRefreshPolicyInput) => {
|
||||||
{ title: '范围', dataIndex: 'scope', key: 'scope', render: (scope: CatalogRun['scope']) => scopeLabels[scope] },
|
if (!platformId) return false;
|
||||||
{ title: '触发', dataIndex: 'trigger', key: 'trigger', render: (value: string) =>
|
setSavingPolicy(true);
|
||||||
value === 'scheduled' ? '定时' : value === 'source_configured' ? '配置来源' : '手动' },
|
try {
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: string) =>
|
setRefreshPolicy(await platformCatalogAdminApi.updateRefreshPolicy(platformId, input));
|
||||||
<Tag color={statusLabels[value]?.color}>{statusLabels[value]?.label ?? value}</Tag> },
|
message.success('缓存新鲜度设置已保存');
|
||||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: dateTime },
|
await Promise.all([
|
||||||
{ title: '结果', key: 'result', render: (_value, run) => run.error_message ??
|
loadManagement(platformId, false),
|
||||||
(run.status === 'succeeded' ? Object.entries(run.result)
|
loadCategories(platformId),
|
||||||
.map(([key, value]) => `${key}: ${String(value)}`).join(' · ') : '—') },
|
selectedCategoryId
|
||||||
];
|
? loadAttributes(platformId, selectedCategoryId)
|
||||||
|
: Promise.resolve(),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiErrorMessage(error));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return <div className="system-catalog-page">
|
const startAttributeBatch = async (mode: AttributeBatchMode) => {
|
||||||
|
if (!platformId) return;
|
||||||
|
setStartingBatchMode(mode);
|
||||||
|
try {
|
||||||
|
const batch = await platformCatalogAdminApi.createAttributeSyncBatch(platformId, mode);
|
||||||
|
setAttributeBatches((current) => [
|
||||||
|
batch,
|
||||||
|
...current.filter((item) => item.id !== batch.id),
|
||||||
|
]);
|
||||||
|
message.success(`类型属性同步批次已创建,共 ${batch.total_count} 个类型`);
|
||||||
|
await Promise.all([loadManagement(platformId, false), loadRuns(platformId)]);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setStartingBatchMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAll = () => {
|
||||||
|
void loadPlatforms();
|
||||||
|
if (!platformId) return;
|
||||||
|
void loadCategories(platformId);
|
||||||
|
void loadRuns(platformId);
|
||||||
|
void loadManagement(platformId);
|
||||||
|
if (selectedCategoryId) void loadAttributes(platformId, selectedCategoryId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshRuns = async (id: string) => {
|
||||||
|
setRefreshingRuns(true);
|
||||||
|
try {
|
||||||
|
if (await loadRuns(id, true)) message.success('同步任务刷新成功');
|
||||||
|
} finally {
|
||||||
|
setRefreshingRuns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-catalog-page">
|
||||||
<div className="system-catalog-heading">
|
<div className="system-catalog-heading">
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>平台类目与属性</Typography.Title>
|
<Typography.Title level={3}>平台类目与属性</Typography.Title>
|
||||||
<Typography.Text type="secondary">按平台查看商品类型、属性定义与候选值,并维护同步状态。</Typography.Text>
|
<Typography.Text type="secondary">
|
||||||
|
维护平台级商品类型、属性定义和候选值;同平台门店共享这些参考数据。
|
||||||
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
@@ -191,51 +313,109 @@ export default function SystemCatalogPage() {
|
|||||||
options={platforms.map((item) => ({ value: item.id, label: item.name }))}
|
options={platforms.map((item) => ({ value: item.id, label: item.name }))}
|
||||||
onChange={setPlatformId}
|
onChange={setPlatformId}
|
||||||
/>
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => {
|
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
|
||||||
void loadPlatforms();
|
刷新
|
||||||
if (platformId) { void loadCategories(platformId); void loadRuns(platformId); }
|
</Button>
|
||||||
if (platformId && selectedCategoryId) void loadAttributes(platformId, selectedCategoryId);
|
|
||||||
}}>刷新</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!currentPlatform ? <Card><Empty description="暂无平台" /></Card> : <>
|
{!currentPlatform ? (
|
||||||
{currentPlatform.summary.last_error && <Alert
|
<Card>
|
||||||
type="warning" showIcon message="最近一次类目树同步失败"
|
<Empty description="暂无平台" />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{currentPlatform.summary.last_error && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
title="最近一次类目树同步失败"
|
||||||
description={currentPlatform.summary.last_error}
|
description={currentPlatform.summary.last_error}
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
<div className="system-catalog-summary">
|
<div className="system-catalog-summary">
|
||||||
<Card><Statistic title="已缓存类目节点" value={currentPlatform.summary.category_count} /></Card>
|
<Card>
|
||||||
<Card><Statistic title="同步来源" value={currentPlatform.source.configured
|
<Statistic
|
||||||
? currentPlatform.source.client_id_hint ?? '已配置' : '未配置'} /></Card>
|
title="已缓存类目节点"
|
||||||
<Card><Statistic title="类目树最近成功" value={dateTime(currentPlatform.summary.last_tree_sync_at)} /></Card>
|
value={currentPlatform.summary.category_count}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="同步来源"
|
||||||
|
value={
|
||||||
|
currentPlatform.source.configured
|
||||||
|
? (currentPlatform.source.client_id_hint ?? '已配置')
|
||||||
|
: '未配置'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="类目树最近成功"
|
||||||
|
value={dateTime(currentPlatform.summary.last_tree_sync_at)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="下次自动同步"
|
||||||
|
value={dateTime(currentPlatform.summary.next_tree_sync_at)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-catalog-actions">
|
<div className="system-catalog-actions">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button icon={<KeyOutlined />} onClick={() => setSourceOpen(true)}
|
<Button
|
||||||
disabled={currentPlatform.code.toUpperCase() !== 'OZON'}>
|
icon={<KeyOutlined />}
|
||||||
|
onClick={() => setSourceOpen(true)}
|
||||||
|
disabled={currentPlatform.code.toUpperCase() !== 'OZON'}
|
||||||
|
>
|
||||||
配置同步来源
|
配置同步来源
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" icon={<CloudSyncOutlined />}
|
<Button
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
onClick={() => setRefreshPolicyOpen(true)}
|
||||||
|
>
|
||||||
|
缓存新鲜度设置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CloudSyncOutlined />}
|
||||||
disabled={!currentPlatform.source.configured}
|
disabled={!currentPlatform.source.configured}
|
||||||
loading={syncingScope === 'tree'}
|
loading={syncingScope === 'tree'}
|
||||||
onClick={() => void sync({ scope: 'tree' })}>
|
onClick={() => void sync({ scope: 'tree' })}
|
||||||
|
>
|
||||||
同步类目树
|
同步类目树
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<CloudSyncOutlined />}
|
|
||||||
disabled={!selectedCategory || !currentPlatform.source.configured}
|
|
||||||
loading={syncingScope === 'attributes'}
|
|
||||||
onClick={() => selectedCategoryId && void sync({ scope: 'attributes', category_id: selectedCategoryId })}>
|
|
||||||
同步当前类型属性
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Typography.Text type="secondary">同步在后台执行,失败时保留上次成功的数据。</Typography.Text>
|
<Typography.Text type="secondary">
|
||||||
|
同步由后台 Worker 执行,失败时保留上次成功缓存。
|
||||||
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AttributeSyncControlCard
|
||||||
|
summary={attributeSummary}
|
||||||
|
batches={attributeBatches}
|
||||||
|
loading={loadingManagement}
|
||||||
|
startingMode={startingBatchMode}
|
||||||
|
sourceConfigured={currentPlatform.source.configured}
|
||||||
|
onStart={startAttributeBatch}
|
||||||
|
onRefresh={() => void loadManagement(currentPlatform.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="system-catalog-browser">
|
<div className="system-catalog-browser">
|
||||||
<Card title="类目 / 商品类型" className="system-catalog-category-card"
|
<Card
|
||||||
extra={<Typography.Text type="secondary">{categories.length} 个节点</Typography.Text>}>
|
title="类目 / 商品类型"
|
||||||
|
className="system-catalog-category-card"
|
||||||
|
extra={
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{categories.length} 个节点
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
<PlatformCategoryTree
|
<PlatformCategoryTree
|
||||||
categories={categories} loading={loadingCategories}
|
categories={categories}
|
||||||
|
loading={loadingCategories}
|
||||||
selectedId={selectedCategoryId}
|
selectedId={selectedCategoryId}
|
||||||
onSelect={(category) => {
|
onSelect={(category) => {
|
||||||
selectedCategoryIdRef.current = category.id;
|
selectedCategoryIdRef.current = category.id;
|
||||||
@@ -243,30 +423,67 @@ export default function SystemCatalogPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={selectedCategory ? `${selectedCategory.name} · 属性定义` : '属性定义'}
|
<Card
|
||||||
|
title={
|
||||||
|
selectedCategory
|
||||||
|
? `${selectedCategory.name} · 属性定义`
|
||||||
|
: '属性定义'
|
||||||
|
}
|
||||||
className="system-catalog-attribute-card"
|
className="system-catalog-attribute-card"
|
||||||
extra={selectedCategory && <Typography.Text type="secondary">
|
extra={
|
||||||
类目 ID {selectedCategory.external_category_id} · 类型 ID {selectedCategory.external_type_id}
|
selectedCategory && (
|
||||||
</Typography.Text>}>
|
<Space wrap>
|
||||||
|
<CatalogCacheStatusTag
|
||||||
|
status={selectedCategory.attribute_cache_status}
|
||||||
|
error={selectedCategory.attribute_last_error}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
类目 ID {selectedCategory.external_category_id} · 类型
|
||||||
|
ID {selectedCategory.external_type_id}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<PlatformAttributesTable
|
<PlatformAttributesTable
|
||||||
attributes={attributes} loading={loadingAttributes}
|
attributes={attributes}
|
||||||
|
loading={loadingAttributes}
|
||||||
selectedCategoryName={selectedCategory?.name ?? null}
|
selectedCategoryName={selectedCategory?.name ?? null}
|
||||||
onValues={setSelectedAttribute}
|
onValues={(attribute) => setSelectedAttributeId(attribute.id)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<Card title="最近同步任务" extra={<Button type="link" onClick={() => void loadRuns(currentPlatform.id)}>刷新任务</Button>}>
|
<CatalogSyncRunsTable
|
||||||
<Table<CatalogRun> rowKey="id" size="small" columns={runColumns}
|
runs={runs}
|
||||||
dataSource={runs} pagination={{ pageSize: 10, showSizeChanger: false }}
|
refreshing={refreshingRuns}
|
||||||
scroll={{ x: 700 }} locale={{ emptyText: '暂无同步任务' }} />
|
onRefresh={() => refreshRuns(currentPlatform.id)}
|
||||||
</Card>
|
|
||||||
<CatalogSourceModal platform={currentPlatform} open={sourceOpen}
|
|
||||||
onClose={() => setSourceOpen(false)} onSave={saveSource} />
|
|
||||||
<DictionaryValuesDrawer
|
|
||||||
platformId={currentPlatform.id} attribute={selectedAttribute}
|
|
||||||
onClose={() => setSelectedAttribute(null)} refreshToken={refreshToken}
|
|
||||||
onSync={(attribute) => sync({ scope: 'dictionary', attribute_id: attribute.id })}
|
|
||||||
/>
|
/>
|
||||||
</>}
|
<CatalogSourceModal
|
||||||
</div>;
|
platform={currentPlatform}
|
||||||
|
open={sourceOpen}
|
||||||
|
onClose={() => setSourceOpen(false)}
|
||||||
|
onSave={saveSource}
|
||||||
|
/>
|
||||||
|
<CatalogRefreshPolicyModal
|
||||||
|
platformName={currentPlatform.name}
|
||||||
|
policy={refreshPolicy}
|
||||||
|
open={refreshPolicyOpen}
|
||||||
|
loading={loadingManagement}
|
||||||
|
saving={savingPolicy}
|
||||||
|
onClose={() => setRefreshPolicyOpen(false)}
|
||||||
|
onSave={saveRefreshPolicy}
|
||||||
|
/>
|
||||||
|
<DictionaryValuesDrawer
|
||||||
|
platformId={currentPlatform.id}
|
||||||
|
attribute={selectedAttribute}
|
||||||
|
onClose={() => setSelectedAttributeId(null)}
|
||||||
|
refreshToken={refreshToken}
|
||||||
|
onSync={(attribute) =>
|
||||||
|
sync({ scope: 'dictionary', attribute_id: attribute.id })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Button, Card, Empty, Popconfirm, Progress, Space, Table, Tag, Typography } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { CloudSyncOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
AttributeBatchMode,
|
||||||
|
AttributeSyncBatch,
|
||||||
|
AttributeSyncSummary,
|
||||||
|
} from '@/services/platformCatalogAdmin';
|
||||||
|
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
summary: AttributeSyncSummary | null;
|
||||||
|
batches: AttributeSyncBatch[];
|
||||||
|
loading: boolean;
|
||||||
|
startingMode: AttributeBatchMode | null;
|
||||||
|
sourceConfigured: boolean;
|
||||||
|
onStart: (mode: AttributeBatchMode) => Promise<void>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchModeLabels: Record<AttributeBatchMode, string> = {
|
||||||
|
missing_expired: '未缓存和过期',
|
||||||
|
missing_stale_expired: '未缓存和不新鲜',
|
||||||
|
all: '全部类型属性',
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchStatusLabels: Record<string, { label: string; color: string }> = {
|
||||||
|
queued: { label: '等待领取', color: 'gold' },
|
||||||
|
running: { label: '同步中', color: 'blue' },
|
||||||
|
succeeded: { label: '已完成', color: 'green' },
|
||||||
|
failed: { label: '部分失败', color: 'red' },
|
||||||
|
cancelled: { label: '已取消', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function dateTime(value: string | null) {
|
||||||
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedCount(batch: AttributeSyncBatch) {
|
||||||
|
return batch.succeeded_count + batch.failed_count + batch.cancelled_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttributeSyncControlCard({
|
||||||
|
summary, batches, loading, startingMode, sourceConfigured, onStart, onRefresh,
|
||||||
|
}: Props) {
|
||||||
|
const active = !!summary?.active_batch_id;
|
||||||
|
const missingExpiredCount = (summary?.missing ?? 0) + (summary?.expired ?? 0);
|
||||||
|
const notFreshCount = missingExpiredCount + (summary?.stale ?? 0);
|
||||||
|
const disabledBase = loading || active || !sourceConfigured;
|
||||||
|
const columns: ColumnsType<AttributeSyncBatch> = [
|
||||||
|
{
|
||||||
|
title: '范围', dataIndex: 'mode', key: 'mode', width: 170,
|
||||||
|
render: (mode: AttributeBatchMode) => batchModeLabels[mode],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '进度', key: 'progress',
|
||||||
|
render: (_value, batch) => {
|
||||||
|
const completed = completedCount(batch);
|
||||||
|
const percent = batch.total_count
|
||||||
|
? Math.round(completed / batch.total_count * 100) : 0;
|
||||||
|
return <div className="system-catalog-batch-progress">
|
||||||
|
<Progress percent={percent} size="small"
|
||||||
|
status={batch.status === 'failed' ? 'exception' : undefined} />
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
成功 {batch.succeeded_count} · 运行 {batch.running_count} · 等待 {batch.pending_count}
|
||||||
|
{batch.failed_count ? ` · 失败 ${batch.failed_count}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||||
|
render: (status: string) => <Tag color={batchStatusLabels[status]?.color}>
|
||||||
|
{batchStatusLabels[status]?.label ?? status}
|
||||||
|
</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180, render: dateTime },
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Card title="类型属性同步" extra={<Button type="link" icon={<ReloadOutlined />}
|
||||||
|
onClick={onRefresh}>刷新状态</Button>}>
|
||||||
|
<div className="system-catalog-cache-summary">
|
||||||
|
<span><CatalogCacheStatusTag status="fresh" />{summary?.fresh ?? 0}</span>
|
||||||
|
<span><CatalogCacheStatusTag status="stale" />{summary?.stale ?? 0}</span>
|
||||||
|
<span><CatalogCacheStatusTag status="expired" />{summary?.expired ?? 0}</span>
|
||||||
|
<span><CatalogCacheStatusTag status="missing" />{summary?.missing ?? 0}</span>
|
||||||
|
<Typography.Text type="secondary">共 {summary?.total ?? 0} 个活动商品类型</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Space wrap className="system-catalog-batch-actions">
|
||||||
|
<Button icon={<CloudSyncOutlined />}
|
||||||
|
disabled={disabledBase || missingExpiredCount === 0}
|
||||||
|
loading={startingMode === 'missing_expired'}
|
||||||
|
onClick={() => void onStart('missing_expired')}>
|
||||||
|
同步未缓存和过期类型属性({missingExpiredCount})
|
||||||
|
</Button>
|
||||||
|
<Button icon={<CloudSyncOutlined />}
|
||||||
|
disabled={disabledBase || notFreshCount === 0}
|
||||||
|
loading={startingMode === 'missing_stale_expired'}
|
||||||
|
onClick={() => void onStart('missing_stale_expired')}>
|
||||||
|
同步未缓存和不新鲜类型属性({notFreshCount})
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认同步全部类型属性?"
|
||||||
|
description={`将处理 ${summary?.total ?? 0} 个类型,包括仍然新鲜的数据。任务会在后台分批执行,可能持续较长时间。`}
|
||||||
|
okText="确认开始"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
disabled={disabledBase || !summary?.total}
|
||||||
|
onConfirm={() => onStart('all')}
|
||||||
|
>
|
||||||
|
<Button danger icon={<CloudSyncOutlined />}
|
||||||
|
disabled={disabledBase || !summary?.total}
|
||||||
|
loading={startingMode === 'all'}>
|
||||||
|
同步全部类型属性({summary?.total ?? 0})
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
{active && <Typography.Paragraph type="secondary" className="system-catalog-active-batch-note">
|
||||||
|
当前已有批次执行中;完成前不能创建另一个类型属性批次。
|
||||||
|
</Typography.Paragraph>}
|
||||||
|
<Table<AttributeSyncBatch>
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
className="system-catalog-batch-table"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={batches}
|
||||||
|
loading={loading && !batches.length}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 760 }}
|
||||||
|
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无属性同步批次" /> }}
|
||||||
|
/>
|
||||||
|
</Card>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Tag, Tooltip } from 'antd';
|
||||||
|
import type { CatalogCacheStatus } from '@/services/platformCatalogAdmin';
|
||||||
|
|
||||||
|
const statusPresentation: Record<CatalogCacheStatus, { label: string; color: string }> = {
|
||||||
|
fresh: { label: '新鲜', color: 'green' },
|
||||||
|
stale: { label: '待更新', color: 'gold' },
|
||||||
|
expired: { label: '已过期', color: 'red' },
|
||||||
|
missing: { label: '未缓存', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: CatalogCacheStatus | null | undefined;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CatalogCacheStatusTag({ status, error }: Props) {
|
||||||
|
if (!status) return null;
|
||||||
|
const presentation = statusPresentation[status];
|
||||||
|
const tag = <Tag color={presentation.color}>{presentation.label}</Tag>;
|
||||||
|
return error ? <Tooltip title={`最近错误:${error}`}>{tag}</Tooltip> : tag;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Alert, Col, Form, InputNumber, Modal, Row, Spin, Typography } from 'antd';
|
||||||
|
import type {
|
||||||
|
CatalogRefreshPolicy,
|
||||||
|
CatalogRefreshPolicyInput,
|
||||||
|
} from '@/services/platformCatalogAdmin';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
platformName: string;
|
||||||
|
policy: CatalogRefreshPolicy | null;
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (input: CatalogRefreshPolicyInput) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function dateTime(value: string | null | undefined) {
|
||||||
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '使用默认策略';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存策略仅在管理员主动打开时展示,避免长期占用目录管理主页面。 */
|
||||||
|
export default function CatalogRefreshPolicyModal({
|
||||||
|
platformName,
|
||||||
|
policy,
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [form] = Form.useForm<CatalogRefreshPolicyInput>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.resetFields();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (policy) form.setFieldsValue(policy);
|
||||||
|
}, [form, open, policy?.updated_at]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="system-catalog-policy-modal"
|
||||||
|
title={`${platformName} · 缓存新鲜度设置`}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void form.submit()}
|
||||||
|
okText="保存设置"
|
||||||
|
confirmLoading={saving}
|
||||||
|
okButtonProps={{ disabled: loading || !policy }}
|
||||||
|
destroyOnHidden
|
||||||
|
width={680}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="system-catalog-policy-info"
|
||||||
|
message="类目树固定每 7 天自动同步"
|
||||||
|
description="这里仅配置类型属性和属性字典的新鲜、过期天数。保存后不会立即创建批量同步任务。"
|
||||||
|
/>
|
||||||
|
{!policy && loading ? (
|
||||||
|
<div className="system-catalog-policy-loading">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Form<CatalogRefreshPolicyInput>
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
disabled={loading && !policy}
|
||||||
|
requiredMark={false}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
if (await onSave(values)) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="attribute_fresh_days"
|
||||||
|
label="类型属性新鲜天数(天)"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 365,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={365} precision={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="attribute_expire_days"
|
||||||
|
label="类型属性过期天数(天)"
|
||||||
|
dependencies={['attribute_fresh_days']}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 365,
|
||||||
|
},
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator: (_rule, value: number | undefined) =>
|
||||||
|
value !== undefined &&
|
||||||
|
value > Number(getFieldValue('attribute_fresh_days'))
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error('必须大于新鲜天数')),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={365} precision={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="dictionary_fresh_days"
|
||||||
|
label="属性字典新鲜天数(天)"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 365,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={365} precision={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="dictionary_expire_days"
|
||||||
|
label="属性字典过期天数(天)"
|
||||||
|
dependencies={['dictionary_fresh_days']}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 365,
|
||||||
|
},
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator: (_rule, value: number | undefined) =>
|
||||||
|
value !== undefined &&
|
||||||
|
value > Number(getFieldValue('dictionary_fresh_days'))
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error('必须大于新鲜天数')),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={365} precision={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
最近更新:{dateTime(policy?.updated_at)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Button, Card, Table, Tag } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { CatalogRun } from '@/services/platformCatalogAdmin';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
runs: CatalogRun[];
|
||||||
|
refreshing: boolean;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||||
|
queued: { label: '排队中', color: 'gold' },
|
||||||
|
running: { label: '同步中', color: 'blue' },
|
||||||
|
succeeded: { label: '成功', color: 'green' },
|
||||||
|
failed: { label: '失败', color: 'red' },
|
||||||
|
cancelled: { label: '已取消', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopeLabels = { tree: '类目树', attributes: '类型属性', dictionary: '字典候选值' };
|
||||||
|
const triggerLabels: Record<string, string> = {
|
||||||
|
manual: '手动',
|
||||||
|
scheduled: '旧定时任务',
|
||||||
|
weekly: '每周自动',
|
||||||
|
source_configured: '配置来源',
|
||||||
|
admin_batch: '管理员批次',
|
||||||
|
user_access: '用户访问',
|
||||||
|
publish: '发布校验',
|
||||||
|
};
|
||||||
|
|
||||||
|
function dateTime(value: string | null) {
|
||||||
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CatalogSyncRunsTable({ runs, refreshing, onRefresh }: Props) {
|
||||||
|
const columns: ColumnsType<CatalogRun> = [
|
||||||
|
{
|
||||||
|
title: '范围',
|
||||||
|
dataIndex: 'scope',
|
||||||
|
key: 'scope',
|
||||||
|
render: (scope: CatalogRun['scope']) => scopeLabels[scope],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '触发',
|
||||||
|
dataIndex: 'trigger',
|
||||||
|
key: 'trigger',
|
||||||
|
render: (value: string) => triggerLabels[value] ?? value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '优先级',
|
||||||
|
dataIndex: 'priority',
|
||||||
|
key: 'priority',
|
||||||
|
width: 90,
|
||||||
|
render: (value: CatalogRun['priority']) => (
|
||||||
|
<Tag color={value === 'high' ? 'red' : 'default'}>
|
||||||
|
{value === 'high' ? '高' : '低'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (value: string) => (
|
||||||
|
<Tag color={statusLabels[value]?.color}>{statusLabels[value]?.label ?? value}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: dateTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结果',
|
||||||
|
key: 'result',
|
||||||
|
render: (_value, run) =>
|
||||||
|
run.error_message ??
|
||||||
|
(run.status === 'succeeded' && run.result
|
||||||
|
? Object.entries(run.result)
|
||||||
|
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||||
|
.join(' · ')
|
||||||
|
: '—'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="最近同步任务"
|
||||||
|
extra={
|
||||||
|
<Button type="link" loading={refreshing} onClick={() => void onRefresh()}>
|
||||||
|
刷新任务
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<CatalogRun>
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={runs}
|
||||||
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 820 }}
|
||||||
|
locale={{ emptyText: '暂无同步任务' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Alert, Button, Drawer, Empty, Input, Pagination, Space, Spin, Table, Ty
|
|||||||
import type { CatalogAttribute, CatalogValue } from '@/services/platformCatalogAdmin';
|
import type { CatalogAttribute, CatalogValue } from '@/services/platformCatalogAdmin';
|
||||||
import { platformCatalogAdminApi } from '@/services/platformCatalogAdmin';
|
import { platformCatalogAdminApi } from '@/services/platformCatalogAdmin';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorMessage } from '@/services/api';
|
||||||
|
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
platformId: string;
|
platformId: string;
|
||||||
@@ -52,7 +53,7 @@ export default function DictionaryValuesDrawer({
|
|||||||
|
|
||||||
return <Drawer
|
return <Drawer
|
||||||
title={attribute ? `${attribute.name} · 候选值` : '候选值'}
|
title={attribute ? `${attribute.name} · 候选值` : '候选值'}
|
||||||
width={520}
|
size={520}
|
||||||
open={!!attribute}
|
open={!!attribute}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="system-catalog-values-drawer"
|
className="system-catalog-values-drawer"
|
||||||
@@ -64,14 +65,25 @@ export default function DictionaryValuesDrawer({
|
|||||||
}}
|
}}
|
||||||
>同步此字典</Button>}
|
>同步此字典</Button>}
|
||||||
>
|
>
|
||||||
{attribute && <Space direction="vertical" size="middle" className="system-catalog-values-content">
|
{attribute && <Space orientation="vertical" size="middle" className="system-catalog-values-content">
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
属性 ID {attribute.platform_attribute_id} · 字典 ID {attribute.dictionary_id}
|
属性 ID {attribute.platform_attribute_id} · 字典 ID {attribute.dictionary_id}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<CatalogCacheStatusTag
|
||||||
|
status={attribute.dictionary_cache_status}
|
||||||
|
error={attribute.dictionary_last_error_message}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
最近成功:{attribute.dictionary_last_success_at
|
||||||
|
? new Date(attribute.dictionary_last_success_at).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
: '尚未同步'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
{!isComplete && <Alert
|
{!isComplete && <Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="当前为部分候选值缓存"
|
title="当前为部分候选值缓存"
|
||||||
description="大字典可能还有其他值;列表搜索只查询 Atlas 已缓存的候选值。"
|
description="大字典可能还有其他值;列表搜索只查询 Atlas 已缓存的候选值。"
|
||||||
/>}
|
/>}
|
||||||
<Input.Search
|
<Input.Search
|
||||||
@@ -81,7 +93,7 @@ export default function DictionaryValuesDrawer({
|
|||||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
onChange={(event) => setDraftKeyword(event.target.value)}
|
||||||
onSearch={(value) => { setKeyword(value.trim()); setPage(1); }}
|
onSearch={(value) => { setKeyword(value.trim()); setPage(1); }}
|
||||||
/>
|
/>
|
||||||
{error && <Alert type="error" showIcon message={error} />}
|
{error && <Alert type="error" showIcon title={error} />}
|
||||||
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
|
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
|
||||||
: values.length ? <Table<CatalogValue>
|
: values.length ? <Table<CatalogValue>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button, Empty, Table, Tag, Tooltip, Typography } from 'antd';
|
import { Button, Empty, Table, Tag, Tooltip, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { CatalogAttribute } from '@/services/platformCatalogAdmin';
|
import type { CatalogAttribute } from '@/services/platformCatalogAdmin';
|
||||||
|
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
attributes: CatalogAttribute[];
|
attributes: CatalogAttribute[];
|
||||||
@@ -38,9 +39,15 @@ export default function PlatformAttributesTable({
|
|||||||
{
|
{
|
||||||
title: '候选值', key: 'values', width: 118,
|
title: '候选值', key: 'values', width: 118,
|
||||||
render: (_value, row) => row.dictionary_id
|
render: (_value, row) => row.dictionary_id
|
||||||
? <Button type="link" className="system-catalog-values-link" onClick={() => onValues(row)}>
|
? <div className="system-catalog-dictionary-cell">
|
||||||
|
<Button type="link" className="system-catalog-values-link" onClick={() => onValues(row)}>
|
||||||
查看 {row.dictionary_value_count}
|
查看 {row.dictionary_value_count}
|
||||||
</Button>
|
</Button>
|
||||||
|
<CatalogCacheStatusTag
|
||||||
|
status={row.dictionary_cache_status}
|
||||||
|
error={row.dictionary_last_error_message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
: <Typography.Text type="secondary">自由填报</Typography.Text>,
|
: <Typography.Text type="secondary">自由填报</Typography.Text>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, type Key } from 'react';
|
||||||
import { Empty, Input, Spin, Tag, Tree, Typography } from 'antd';
|
import { Empty, Input, Spin, Tag, Tree, Typography } from 'antd';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { DataNode } from 'antd/es/tree';
|
||||||
import type { CatalogCategory } from '@/services/platformCatalogAdmin';
|
import type { CatalogCategory } from '@/services/platformCatalogAdmin';
|
||||||
|
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
categories: CatalogCategory[];
|
categories: CatalogCategory[];
|
||||||
@@ -13,8 +14,9 @@ type Props = {
|
|||||||
/** 目录节点保留展开能力,只有活动商品类型叶子可选择。 */
|
/** 目录节点保留展开能力,只有活动商品类型叶子可选择。 */
|
||||||
export default function PlatformCategoryTree({ categories, loading, selectedId, onSelect }: Props) {
|
export default function PlatformCategoryTree({ categories, loading, selectedId, onSelect }: Props) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
|
||||||
const categoryById = useMemo(() => new Map(categories.map((item) => [item.id, item])), [categories]);
|
const categoryById = useMemo(() => new Map(categories.map((item) => [item.id, item])), [categories]);
|
||||||
const treeData = useMemo(() => {
|
const treeState = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
const visible = new Set<string>();
|
const visible = new Set<string>();
|
||||||
if (query) {
|
if (query) {
|
||||||
@@ -47,37 +49,54 @@ export default function PlatformCategoryTree({ categories, loading, selectedId,
|
|||||||
<span>{category.name}</span>
|
<span>{category.name}</span>
|
||||||
{category.external_type_id && category.external_type_id !== '0' &&
|
{category.external_type_id && category.external_type_id !== '0' &&
|
||||||
<Typography.Text type="secondary">#{category.external_type_id}</Typography.Text>}
|
<Typography.Text type="secondary">#{category.external_type_id}</Typography.Text>}
|
||||||
|
{category.external_type_id && category.external_type_id !== '0' &&
|
||||||
|
<CatalogCacheStatusTag
|
||||||
|
status={category.attribute_cache_status}
|
||||||
|
error={category.attribute_last_error}
|
||||||
|
/>}
|
||||||
{!category.is_active && category.external_type_id !== '0' &&
|
{!category.is_active && category.external_type_id !== '0' &&
|
||||||
<Tag color="default">不可建品</Tag>}
|
<Tag color="default">不可建品</Tag>}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: toNodes(category.id),
|
children: toNodes(category.id),
|
||||||
}));
|
}));
|
||||||
return toNodes(null);
|
return {
|
||||||
|
treeData: toNodes(null),
|
||||||
|
searchExpandedKeys: query
|
||||||
|
? [...childrenByParent.keys()].filter((key): key is string => key !== null)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
}, [categories, categoryById, search]);
|
}, [categories, categoryById, search]);
|
||||||
|
const isSearching = search.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="system-catalog-tree-panel">
|
<div className="system-catalog-tree-panel">
|
||||||
|
<div className="system-catalog-tree-search">
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="搜索类目、类型或 ID"
|
placeholder="搜索类目、类型或 ID"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="system-catalog-tree-scroll">
|
||||||
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
|
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
|
||||||
: !treeData.length ? <Empty description="暂无类目;请先同步类目树" />
|
: !treeState.treeData.length ? <Empty description="暂无类目;请先同步类目树" />
|
||||||
: <Tree
|
: <Tree
|
||||||
showLine={{ showLeafIcon: false }}
|
showLine={{ showLeafIcon: false }}
|
||||||
treeData={treeData}
|
treeData={treeState.treeData}
|
||||||
selectedKeys={selectedId ? [selectedId] : []}
|
selectedKeys={selectedId ? [selectedId] : []}
|
||||||
defaultExpandParent={false}
|
autoExpandParent={isSearching}
|
||||||
autoExpandParent={!!search}
|
expandedKeys={isSearching ? treeState.searchExpandedKeys : expandedKeys}
|
||||||
expandedKeys={search ? [...categoryById.keys()] : undefined}
|
onExpand={(keys) => {
|
||||||
|
if (!isSearching) setExpandedKeys(keys);
|
||||||
|
}}
|
||||||
onSelect={(keys) => {
|
onSelect={(keys) => {
|
||||||
const category = categoryById.get(String(keys[0] ?? ''));
|
const category = categoryById.get(String(keys[0] ?? ''));
|
||||||
if (category) onSelect(category);
|
if (category) onSelect(category);
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
export type CatalogCacheStatus = 'fresh' | 'stale' | 'expired' | 'missing';
|
||||||
|
|
||||||
export type AdminPlatform = {
|
export type AdminPlatform = {
|
||||||
id: string; code: string; name: string; status: string;
|
id: string; code: string; name: string; status: string;
|
||||||
source: { configured: boolean; client_id_hint: string | null; updated_at: string | null };
|
source: { configured: boolean; client_id_hint: string | null; updated_at: string | null };
|
||||||
summary: { category_count: number; last_tree_sync_at: string | null; last_error: string | null };
|
summary: {
|
||||||
|
category_count: number; last_tree_sync_at: string | null;
|
||||||
|
next_tree_sync_at: string | null; last_error: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CatalogCategory = {
|
export type CatalogCategory = {
|
||||||
id: string; parent_id: string | null; external_category_id: string;
|
id: string; parent_id: string | null; external_category_id: string;
|
||||||
external_type_id: string | null; name: string; path: string;
|
external_type_id: string | null; name: string; path: string;
|
||||||
is_active: boolean; synced_at: string | null;
|
is_active: boolean; synced_at: string | null;
|
||||||
|
attribute_last_success_at: string | null;
|
||||||
|
attribute_cache_status: CatalogCacheStatus | null;
|
||||||
|
attribute_last_error: string | null;
|
||||||
|
attribute_last_error_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CatalogAttribute = {
|
export type CatalogAttribute = {
|
||||||
@@ -19,6 +28,13 @@ export type CatalogAttribute = {
|
|||||||
is_aspect: boolean; is_collection: boolean; max_value_count: number;
|
is_aspect: boolean; is_collection: boolean; max_value_count: number;
|
||||||
dictionary_id: string | null; category_dependent: boolean;
|
dictionary_id: string | null; category_dependent: boolean;
|
||||||
is_active: boolean; synced_at: string | null; dictionary_value_count: number;
|
is_active: boolean; synced_at: string | null; dictionary_value_count: number;
|
||||||
|
last_success_at: string | null; cache_status: CatalogCacheStatus;
|
||||||
|
last_error_message: string | null; last_error_at: string | null;
|
||||||
|
dictionary_last_success_at: string | null;
|
||||||
|
dictionary_cache_status: CatalogCacheStatus | null;
|
||||||
|
dictionary_is_complete: boolean | null;
|
||||||
|
dictionary_last_error_message: string | null;
|
||||||
|
dictionary_last_error_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CatalogValue = {
|
export type CatalogValue = {
|
||||||
@@ -29,12 +45,35 @@ export type CatalogValue = {
|
|||||||
export type CatalogRun = {
|
export type CatalogRun = {
|
||||||
id: string; platform_id: string; scope: 'tree' | 'attributes' | 'dictionary';
|
id: string; platform_id: string; scope: 'tree' | 'attributes' | 'dictionary';
|
||||||
scope_key: string; category_id: string | null; attribute_id: string | null;
|
scope_key: string; category_id: string | null; attribute_id: string | null;
|
||||||
trigger: string; status: string; result: Record<string, unknown>;
|
trigger: string; priority: 'high' | 'low'; status: string;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
error_code: string | null; error_message: string | null;
|
error_code: string | null; error_message: string | null;
|
||||||
attempt_count: number; created_at: string;
|
attempt_count: number; created_at: string;
|
||||||
started_at: string | null; completed_at: string | null;
|
started_at: string | null; completed_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CatalogRefreshPolicy = {
|
||||||
|
attribute_fresh_days: number; attribute_expire_days: number;
|
||||||
|
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CatalogRefreshPolicyInput = Omit<CatalogRefreshPolicy, 'updated_at'>;
|
||||||
|
|
||||||
|
export type AttributeSyncSummary = {
|
||||||
|
total: number; fresh: number; stale: number; expired: number; missing: number;
|
||||||
|
active_batch_id: string | null; evaluated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttributeBatchMode = 'missing_expired' | 'missing_stale_expired' | 'all';
|
||||||
|
|
||||||
|
export type AttributeSyncBatch = {
|
||||||
|
id: string; platform_id: string; mode: AttributeBatchMode; status: string;
|
||||||
|
total_count: number; pending_count: number; running_count: number;
|
||||||
|
succeeded_count: number; failed_count: number; cancelled_count: number;
|
||||||
|
created_at: string; started_at: string | null; completed_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SyncRequest =
|
export type SyncRequest =
|
||||||
| { scope: 'tree' }
|
| { scope: 'tree' }
|
||||||
| { scope: 'attributes'; category_id: string }
|
| { scope: 'attributes'; category_id: string }
|
||||||
@@ -49,6 +88,14 @@ export const platformCatalogAdminApi = {
|
|||||||
),
|
),
|
||||||
categories: (platformId: string) =>
|
categories: (platformId: string) =>
|
||||||
api.get<CatalogCategory[]>(`/system/platform-catalog/platforms/${platformId}/categories`),
|
api.get<CatalogCategory[]>(`/system/platform-catalog/platforms/${platformId}/categories`),
|
||||||
|
refreshPolicy: (platformId: string) =>
|
||||||
|
api.get<CatalogRefreshPolicy>(
|
||||||
|
`/system/platform-catalog/platforms/${platformId}/refresh-policy`,
|
||||||
|
),
|
||||||
|
updateRefreshPolicy: (platformId: string, input: CatalogRefreshPolicyInput) =>
|
||||||
|
api.put<CatalogRefreshPolicy>(
|
||||||
|
`/system/platform-catalog/platforms/${platformId}/refresh-policy`, input,
|
||||||
|
),
|
||||||
attributes: (platformId: string, categoryId: string) =>
|
attributes: (platformId: string, categoryId: string) =>
|
||||||
api.get<CatalogAttribute[]>(
|
api.get<CatalogAttribute[]>(
|
||||||
`/system/platform-catalog/platforms/${platformId}/categories/${categoryId}/attributes`,
|
`/system/platform-catalog/platforms/${platformId}/categories/${categoryId}/attributes`,
|
||||||
@@ -65,4 +112,18 @@ export const platformCatalogAdminApi = {
|
|||||||
),
|
),
|
||||||
runs: (platformId: string) =>
|
runs: (platformId: string) =>
|
||||||
api.get<CatalogRun[]>(`/system/platform-catalog/platforms/${platformId}/syncs?limit=20`),
|
api.get<CatalogRun[]>(`/system/platform-catalog/platforms/${platformId}/syncs?limit=20`),
|
||||||
|
attributeSyncSummary: (platformId: string) =>
|
||||||
|
api.get<AttributeSyncSummary>(
|
||||||
|
`/system/platform-catalog/platforms/${platformId}/attribute-sync-summary`,
|
||||||
|
),
|
||||||
|
attributeSyncBatches: (platformId: string) =>
|
||||||
|
api.get<AttributeSyncBatch[]>(
|
||||||
|
`/system/platform-catalog/platforms/${platformId}/attribute-sync-batches?limit=10`,
|
||||||
|
),
|
||||||
|
createAttributeSyncBatch: (platformId: string, mode: AttributeBatchMode) =>
|
||||||
|
api.post<AttributeSyncBatch>(
|
||||||
|
`/system/platform-catalog/platforms/${platformId}/attribute-sync-batches`,
|
||||||
|
{ mode },
|
||||||
|
{ headers: { 'Idempotency-Key': crypto.randomUUID() } },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
ProductAttributeDictionaryValue,
|
ProductAttributeDictionaryResult,
|
||||||
ProductAttributeSchema,
|
ProductAttributeSchemaResult,
|
||||||
|
ProductCatalogRefreshRun,
|
||||||
ProductEditorCategoryOption,
|
ProductEditorCategoryOption,
|
||||||
ProductEditorContext,
|
ProductEditorContext,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
ProductEditorValidation,
|
ProductEditorValidation,
|
||||||
|
ProductPublishPreflight,
|
||||||
Operation,
|
Operation,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
SaveProductEditorDraftResponse,
|
SaveProductEditorDraftResponse,
|
||||||
@@ -40,34 +42,22 @@ export const productEditorApi = {
|
|||||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
categoryOptions: (
|
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
||||||
profileId: string,
|
|
||||||
platformId: string,
|
|
||||||
search?: string,
|
|
||||||
storeId?: string | null
|
|
||||||
) =>
|
|
||||||
api.get<ProductEditorCategoryOption[]>(
|
api.get<ProductEditorCategoryOption[]>(
|
||||||
`/product-platform-profiles/${profileId}/category-options`,
|
`/product-platform-profiles/${profileId}/category-options`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...(search ? { q: search } : {}),
|
...(search ? { q: search } : {}),
|
||||||
...(storeId ? { store_id: storeId } : {}),
|
|
||||||
},
|
},
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
attributeSchema: (
|
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null) =>
|
||||||
profileId: string,
|
api.get<ProductAttributeSchemaResult>(
|
||||||
platformId: string,
|
|
||||||
categoryId?: string | null,
|
|
||||||
storeId?: string | null
|
|
||||||
) =>
|
|
||||||
api.get<ProductAttributeSchema[]>(
|
|
||||||
`/product-platform-profiles/${profileId}/attribute-schema`,
|
`/product-platform-profiles/${profileId}/attribute-schema`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...(categoryId ? { category_id: categoryId } : {}),
|
...(categoryId ? { category_id: categoryId } : {}),
|
||||||
...(storeId ? { store_id: storeId } : {}),
|
|
||||||
},
|
},
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
}
|
}
|
||||||
@@ -76,8 +66,14 @@ export const productEditorApi = {
|
|||||||
api.get<ProductEditorValidation>(`/product-platform-profiles/${profileId}/validation`, {
|
api.get<ProductEditorValidation>(`/product-platform-profiles/${profileId}/validation`, {
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
}),
|
}),
|
||||||
|
publishPreflight: (profileId: string, platformId: string, variantIds: string[]) =>
|
||||||
|
api.post<ProductPublishPreflight>(
|
||||||
|
`/product-platform-profiles/${profileId}/publish-preflights`,
|
||||||
|
{ variant_ids: variantIds },
|
||||||
|
{ headers: { 'X-Atlas-Platform-Id': platformId } }
|
||||||
|
),
|
||||||
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||||
api.post<{ operation: Operation }>(
|
api.post<{ run: ProductCatalogRefreshRun }>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
@@ -91,18 +87,16 @@ export const productEditorApi = {
|
|||||||
attributeDictionary: (
|
attributeDictionary: (
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
dictionaryId: string,
|
attributeId: string,
|
||||||
search?: string,
|
search?: string,
|
||||||
categoryId?: string | null,
|
categoryId?: string | null,
|
||||||
storeId?: string | null,
|
|
||||||
cursor?: string | null
|
cursor?: string | null
|
||||||
) =>
|
) =>
|
||||||
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
api.get<ProductAttributeDictionaryResult>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(attributeId)}`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...(categoryId ? { category_id: categoryId } : {}),
|
...(categoryId ? { category_id: categoryId } : {}),
|
||||||
...(storeId ? { store_id: storeId } : {}),
|
|
||||||
...(search ? { q: search } : {}),
|
...(search ? { q: search } : {}),
|
||||||
...(cursor ? { cursor } : {}),
|
...(cursor ? { cursor } : {}),
|
||||||
},
|
},
|
||||||
@@ -112,12 +106,12 @@ export const productEditorApi = {
|
|||||||
syncAttributeDictionary: (
|
syncAttributeDictionary: (
|
||||||
profileId: string,
|
profileId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
dictionaryId: string,
|
attributeId: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
query: string
|
query: string
|
||||||
) =>
|
) =>
|
||||||
api.post<{ operation: Operation }>(
|
api.post<{ run: ProductCatalogRefreshRun }>(
|
||||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(attributeId)}/syncs`,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
params: { category_id: categoryId, q: query },
|
params: { category_id: categoryId, q: query },
|
||||||
|
|||||||
+15
-1
@@ -28,7 +28,7 @@ const connection = new Redis(workerConfig.redisUrl, {
|
|||||||
});
|
});
|
||||||
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||||
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
||||||
const platformQueue = new Queue<PlatformCatalogJob>('atlas-dispatch', { connection });
|
const platformQueue = new Queue<PlatformCatalogJob>('atlas-platform-catalog', { connection });
|
||||||
const dispatcher = new OutboxDispatcher(
|
const dispatcher = new OutboxDispatcher(
|
||||||
database,
|
database,
|
||||||
queue,
|
queue,
|
||||||
@@ -84,6 +84,13 @@ const worker = new Worker<OutboxJob | PlatformCatalogJob>(
|
|||||||
{ connection, concurrency: 5 },
|
{ connection, concurrency: 5 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 平台目录使用独立队列,交互型高优先级刷新不会与素材任务共用等待队列。
|
||||||
|
const platformWorker = new Worker<PlatformCatalogJob>(
|
||||||
|
'atlas-platform-catalog',
|
||||||
|
async (job) => syncPlatformCatalog(database, job.data),
|
||||||
|
{ connection, concurrency: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
async function cleanupCollection(database: Database, job: OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } }) {
|
async function cleanupCollection(database: Database, job: OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } }) {
|
||||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
await tx.query(`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`, [job.payload.operation_id]);
|
await tx.query(`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`, [job.payload.operation_id]);
|
||||||
@@ -106,6 +113,10 @@ worker.on('failed', (job, error) => {
|
|||||||
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
platformWorker.on('failed', (job) => {
|
||||||
|
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
|
||||||
|
void markPlatformCatalogUnavailable(database, job.data);
|
||||||
|
});
|
||||||
|
|
||||||
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
||||||
const source = await database.workspaceTransaction(
|
const source = await database.workspaceTransaction(
|
||||||
@@ -300,7 +311,9 @@ let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
|
|||||||
let platformCatalogTimer: ReturnType<typeof setInterval> | undefined;
|
let platformCatalogTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await queue.waitUntilReady();
|
await queue.waitUntilReady();
|
||||||
|
await platformQueue.waitUntilReady();
|
||||||
await worker.waitUntilReady();
|
await worker.waitUntilReady();
|
||||||
|
await platformWorker.waitUntilReady();
|
||||||
await dispatcher.dispatchBatch();
|
await dispatcher.dispatchBatch();
|
||||||
await platformDispatcher.dispatchBatch();
|
await platformDispatcher.dispatchBatch();
|
||||||
await platformScheduler.run();
|
await platformScheduler.run();
|
||||||
@@ -359,6 +372,7 @@ async function shutdown() {
|
|||||||
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
|
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
|
||||||
if (platformCatalogTimer) clearInterval(platformCatalogTimer);
|
if (platformCatalogTimer) clearInterval(platformCatalogTimer);
|
||||||
await worker.close();
|
await worker.close();
|
||||||
|
await platformWorker.close();
|
||||||
await queue.close();
|
await queue.close();
|
||||||
await platformQueue.close();
|
await platformQueue.close();
|
||||||
objectStorage.destroy();
|
objectStorage.destroy();
|
||||||
|
|||||||
@@ -15,20 +15,25 @@ export class PlatformCatalogDispatcher {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async dispatchBatch() {
|
public async dispatchBatch() {
|
||||||
|
await this.promoteDispatchedJobs();
|
||||||
const events = await this.database.transaction((tx) => tx.query<{
|
const events = await this.database.transaction((tx) => tx.query<{
|
||||||
id: string; run_id: string; platform_id: string;
|
id: string; run_id: string; platform_id: string; priority: 'high' | 'low';
|
||||||
}>(
|
}>(
|
||||||
`WITH candidates AS (
|
`WITH candidates AS (
|
||||||
SELECT id FROM atlas.platform_catalog_outbox_events
|
SELECT event.id, run.priority
|
||||||
WHERE (status = 'pending' AND next_attempt_at <= now())
|
FROM atlas.platform_catalog_outbox_events event
|
||||||
OR (status = 'dispatching' AND locked_until < now())
|
JOIN atlas.platform_catalog_sync_runs run ON run.id = event.run_id
|
||||||
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 20
|
WHERE (event.status = 'pending' AND event.next_attempt_at <= now())
|
||||||
|
OR (event.status = 'dispatching' AND event.locked_until < now())
|
||||||
|
ORDER BY CASE run.priority WHEN 'high' THEN 0 ELSE 1 END,
|
||||||
|
event.created_at
|
||||||
|
FOR UPDATE OF event SKIP LOCKED LIMIT 20
|
||||||
)
|
)
|
||||||
UPDATE atlas.platform_catalog_outbox_events event
|
UPDATE atlas.platform_catalog_outbox_events event
|
||||||
SET status = 'dispatching', locked_by = $1,
|
SET status = 'dispatching', locked_by = $1,
|
||||||
locked_until = now() + interval '2 minutes', updated_at = now()
|
locked_until = now() + interval '2 minutes', updated_at = now()
|
||||||
FROM candidates WHERE event.id = candidates.id
|
FROM candidates WHERE event.id = candidates.id
|
||||||
RETURNING event.id, event.run_id, event.platform_id`,
|
RETURNING event.id, event.run_id, event.platform_id, candidates.priority`,
|
||||||
[this.workerId],
|
[this.workerId],
|
||||||
));
|
));
|
||||||
for (const event of events.rows) {
|
for (const event of events.rows) {
|
||||||
@@ -39,6 +44,7 @@ export class PlatformCatalogDispatcher {
|
|||||||
platformId: event.platform_id,
|
platformId: event.platform_id,
|
||||||
}, {
|
}, {
|
||||||
jobId: event.id,
|
jobId: event.id,
|
||||||
|
priority: event.priority === 'high' ? 1 : 10,
|
||||||
attempts: 5,
|
attempts: 5,
|
||||||
backoff: { type: 'exponential', delay: 5_000 },
|
backoff: { type: 'exponential', delay: 5_000 },
|
||||||
removeOnComplete: 1000,
|
removeOnComplete: 1000,
|
||||||
@@ -65,4 +71,29 @@ export class PlatformCatalogDispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async promoteDispatchedJobs() {
|
||||||
|
const promoted = await this.database.query<{
|
||||||
|
id: string; promoted_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT event.id, run.promoted_at
|
||||||
|
FROM atlas.platform_catalog_outbox_events event
|
||||||
|
JOIN atlas.platform_catalog_sync_runs run ON run.id = event.run_id
|
||||||
|
WHERE event.status = 'dispatched' AND run.status = 'queued'
|
||||||
|
AND run.priority = 'high' AND run.promoted_at IS NOT NULL
|
||||||
|
AND event.updated_at < run.promoted_at
|
||||||
|
ORDER BY run.promoted_at LIMIT 20`,
|
||||||
|
);
|
||||||
|
for (const event of promoted.rows) {
|
||||||
|
const job = await this.queue.getJob(event.id);
|
||||||
|
// BullMQ 的有优先级任务位于 prioritized 集合,不能用 isWaiting 判断。
|
||||||
|
// changePriority 会在任务已开始时保持当前执行,不会创建重复任务。
|
||||||
|
if (job) await job.changePriority({ priority: 1 });
|
||||||
|
await this.database.query(
|
||||||
|
`UPDATE atlas.platform_catalog_outbox_events
|
||||||
|
SET updated_at = now() WHERE id = $1 AND updated_at < $2`,
|
||||||
|
[event.id, event.promoted_at],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
|
||||||
type Candidate = { scope: 'tree' | 'attributes' | 'dictionary'; scope_key: string;
|
type SourceRow = { revision: number };
|
||||||
category_id: string | null; attribute_id: string | null };
|
type BatchItem = {
|
||||||
|
batch_id: string;
|
||||||
|
category_id: string;
|
||||||
|
source_revision: number;
|
||||||
|
priority: 'high' | 'low';
|
||||||
|
};
|
||||||
|
|
||||||
/** 定时任务逐平台限量排队,避免启动时一次扫完全部类目和大字典。 */
|
const platformActiveRunLimit = 6;
|
||||||
|
|
||||||
|
/** 每周只自动刷新树;类型属性仅从管理员显式创建的批次中限量领取。 */
|
||||||
export class PlatformCatalogScheduler {
|
export class PlatformCatalogScheduler {
|
||||||
public constructor(private readonly database: Database) {}
|
public constructor(private readonly database: Database) {}
|
||||||
|
|
||||||
@@ -20,84 +27,201 @@ export class PlatformCatalogScheduler {
|
|||||||
|
|
||||||
private async schedulePlatform(platformId: string) {
|
private async schedulePlatform(platformId: string) {
|
||||||
await this.database.transaction(async (tx) => {
|
await this.database.transaction(async (tx) => {
|
||||||
const source = await tx.query<{ revision: number }>(
|
const source = await tx.query<SourceRow>(
|
||||||
`SELECT revision FROM atlas.platform_catalog_sources
|
`SELECT revision FROM atlas.platform_catalog_sources
|
||||||
WHERE platform_id = $1 FOR UPDATE`,
|
WHERE platform_id = $1 FOR UPDATE`,
|
||||||
[platformId],
|
[platformId],
|
||||||
);
|
);
|
||||||
if (!source.rows[0]) return;
|
if (!source.rows[0]) return;
|
||||||
|
await refreshBatchStatuses(tx, platformId, source.rows[0].revision);
|
||||||
|
|
||||||
const pending = await tx.query<{ count: string }>(
|
const pending = await tx.query<{ count: string }>(
|
||||||
`SELECT count(*)::text AS count FROM atlas.platform_catalog_sync_runs
|
`SELECT count(*)::text AS count FROM atlas.platform_catalog_sync_runs
|
||||||
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||||
[platformId],
|
[platformId],
|
||||||
);
|
);
|
||||||
if (Number(pending.rows[0]?.count ?? 0) >= 6) return;
|
let capacity = platformActiveRunLimit - Number(pending.rows[0]?.count ?? 0);
|
||||||
const candidates = await tx.query<Candidate>(
|
if (capacity <= 0) return;
|
||||||
`WITH possible AS (
|
|
||||||
SELECT 'tree'::text AS scope, 'tree'::text AS scope_key,
|
if (await treeNeedsRefresh(tx, platformId)) {
|
||||||
NULL::uuid AS category_id, NULL::uuid AS attribute_id,
|
await enqueueRun(tx, {
|
||||||
interval '24 hours' AS refresh_interval, 0 AS priority
|
platformId,
|
||||||
UNION ALL
|
scopeKey: 'tree',
|
||||||
SELECT 'attributes', 'attributes:' || category.id, category.id, NULL::uuid,
|
sourceRevision: source.rows[0].revision,
|
||||||
interval '24 hours', 1
|
trigger: 'weekly',
|
||||||
FROM atlas.platform_product_categories category
|
priority: 'low',
|
||||||
WHERE category.platform_id = $1 AND category.is_active
|
});
|
||||||
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
|
capacity -= 1;
|
||||||
UNION ALL
|
}
|
||||||
SELECT 'dictionary', 'dictionary:' || attribute.id,
|
if (capacity <= 0) return;
|
||||||
attribute.platform_product_category_id, attribute.id,
|
|
||||||
interval '7 days', 2
|
const items = await pendingBatchItems(tx, platformId, capacity);
|
||||||
FROM atlas.platform_product_category_attributes attribute
|
for (const item of items) {
|
||||||
JOIN atlas.platform_product_categories category
|
const runId = await enqueueRun(tx, {
|
||||||
ON category.id = attribute.platform_product_category_id
|
platformId,
|
||||||
WHERE attribute.platform_id = $1 AND attribute.is_active
|
scopeKey: `attributes:${item.category_id}`,
|
||||||
AND attribute.dictionary_id IS NOT NULL AND category.is_active
|
categoryId: item.category_id,
|
||||||
)
|
sourceRevision: item.source_revision,
|
||||||
SELECT possible.scope, possible.scope_key, possible.category_id, possible.attribute_id
|
trigger: 'admin_batch',
|
||||||
FROM possible
|
priority: item.priority,
|
||||||
WHERE NOT EXISTS (
|
});
|
||||||
SELECT 1 FROM atlas.platform_catalog_sync_runs run
|
await tx.query(
|
||||||
WHERE run.platform_id = $1 AND run.scope_key = possible.scope_key
|
`UPDATE atlas.platform_catalog_attribute_batch_items
|
||||||
AND run.status = 'succeeded'
|
SET run_id = $3, linked_at = now()
|
||||||
AND run.completed_at > now() - possible.refresh_interval
|
WHERE batch_id = $1 AND category_id = $2 AND run_id IS NULL`,
|
||||||
) AND NOT EXISTS (
|
[item.batch_id, item.category_id, runId],
|
||||||
SELECT 1 FROM atlas.platform_catalog_sync_runs run
|
);
|
||||||
WHERE run.platform_id = $1 AND run.scope_key = possible.scope_key
|
}
|
||||||
AND run.created_at > now() - interval '1 hour'
|
if (items.length) {
|
||||||
) AND NOT EXISTS (
|
await tx.query(
|
||||||
SELECT 1 FROM atlas.platform_catalog_sync_runs run
|
`UPDATE atlas.platform_catalog_attribute_batches
|
||||||
WHERE run.platform_id = $1 AND run.scope_key = possible.scope_key
|
SET status = 'running', started_at = COALESCE(started_at, now())
|
||||||
AND run.status IN ('queued', 'running')
|
WHERE id = $1 AND status = 'queued'`,
|
||||||
)
|
[items[0].batch_id],
|
||||||
ORDER BY possible.priority, possible.scope_key LIMIT 6`,
|
|
||||||
[platformId],
|
|
||||||
);
|
);
|
||||||
const capacity = 6 - Number(pending.rows[0]?.count ?? 0);
|
|
||||||
for (const candidate of candidates.rows.slice(0, capacity)) {
|
|
||||||
await enqueueScheduled(tx, platformId, source.rows[0].revision, candidate);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueScheduled(
|
async function treeNeedsRefresh(tx: DatabaseTransaction, platformId: string) {
|
||||||
|
const result = await tx.query<{ due: boolean }>(
|
||||||
|
`SELECT
|
||||||
|
(state.last_success_at IS NULL
|
||||||
|
OR state.last_success_at <= now() - interval '7 days')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM atlas.platform_catalog_sync_runs run
|
||||||
|
WHERE run.platform_id = $1 AND run.scope_key = 'tree'
|
||||||
|
AND run.status IN ('queued', 'running')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM atlas.platform_catalog_sync_runs run
|
||||||
|
WHERE run.platform_id = $1 AND run.scope_key = 'tree'
|
||||||
|
AND run.created_at > now() - interval '1 hour'
|
||||||
|
) AS due
|
||||||
|
FROM (SELECT 1) marker
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = $1 AND state.scope_key = 'tree'`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.due === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pendingBatchItems(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<BatchItem[]> {
|
||||||
|
const result = await tx.query<BatchItem>(
|
||||||
|
`SELECT item.batch_id, item.category_id, batch.source_revision,
|
||||||
|
CASE WHEN state.last_success_at IS NULL
|
||||||
|
OR state.last_success_at < now() -
|
||||||
|
make_interval(days => batch.attribute_expire_days)
|
||||||
|
THEN 'high' ELSE 'low' END AS priority
|
||||||
|
FROM atlas.platform_catalog_attribute_batches batch
|
||||||
|
JOIN atlas.platform_catalog_attribute_batch_items item
|
||||||
|
ON item.batch_id = batch.id AND item.platform_id = batch.platform_id
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = batch.platform_id
|
||||||
|
AND state.scope_key = 'attributes:' || item.category_id
|
||||||
|
WHERE batch.platform_id = $1 AND batch.status IN ('queued', 'running')
|
||||||
|
AND item.run_id IS NULL
|
||||||
|
ORDER BY CASE WHEN state.last_success_at IS NULL
|
||||||
|
OR state.last_success_at < now() -
|
||||||
|
make_interval(days => batch.attribute_expire_days)
|
||||||
|
THEN 0 ELSE 1 END,
|
||||||
|
item.created_at, item.category_id
|
||||||
|
LIMIT $2
|
||||||
|
FOR UPDATE OF item SKIP LOCKED`,
|
||||||
|
[platformId, limit],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBatchStatuses(
|
||||||
tx: DatabaseTransaction,
|
tx: DatabaseTransaction,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
sourceRevision: number,
|
sourceRevision: number,
|
||||||
candidate: Candidate,
|
|
||||||
) {
|
) {
|
||||||
const run = await tx.query<{ id: string }>(
|
await tx.query(
|
||||||
|
`UPDATE atlas.platform_catalog_attribute_batches
|
||||||
|
SET status = 'cancelled', completed_at = now()
|
||||||
|
WHERE platform_id = $1 AND status IN ('queued', 'running')
|
||||||
|
AND source_revision <> $2`,
|
||||||
|
[platformId, sourceRevision],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`WITH progress AS (
|
||||||
|
SELECT batch.id,
|
||||||
|
count(*) FILTER (WHERE item.run_id IS NULL) AS unlinked_count,
|
||||||
|
count(*) FILTER (WHERE run.status IN ('queued', 'running')) AS active_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'failed') AS failed_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'cancelled') AS cancelled_count
|
||||||
|
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
|
||||||
|
WHERE batch.platform_id = $1 AND batch.status IN ('queued', 'running')
|
||||||
|
GROUP BY batch.id
|
||||||
|
)
|
||||||
|
UPDATE atlas.platform_catalog_attribute_batches batch
|
||||||
|
SET status = CASE
|
||||||
|
WHEN progress.unlinked_count = 0 AND progress.active_count = 0
|
||||||
|
THEN CASE WHEN progress.failed_count > 0 THEN 'failed'::atlas.operation_status
|
||||||
|
WHEN progress.cancelled_count > 0 THEN 'cancelled'::atlas.operation_status
|
||||||
|
ELSE 'succeeded'::atlas.operation_status END
|
||||||
|
ELSE batch.status
|
||||||
|
END,
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN progress.unlinked_count = 0 AND progress.active_count = 0
|
||||||
|
THEN COALESCE(batch.completed_at, now())
|
||||||
|
ELSE batch.completed_at
|
||||||
|
END
|
||||||
|
FROM progress WHERE batch.id = progress.id`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueRun(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
input: {
|
||||||
|
platformId: string;
|
||||||
|
scopeKey: string;
|
||||||
|
categoryId?: string;
|
||||||
|
sourceRevision: number;
|
||||||
|
trigger: 'weekly' | 'admin_batch';
|
||||||
|
priority: 'high' | 'low';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const active = await tx.query<{ id: string; priority: 'high' | 'low' }>(
|
||||||
|
`SELECT id, priority 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') {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.platform_catalog_sync_runs
|
||||||
|
SET priority = 'high', promoted_at = now()
|
||||||
|
WHERE id = $1 AND status = 'queued'`,
|
||||||
|
[active.rows[0].id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return active.rows[0].id;
|
||||||
|
}
|
||||||
|
const inserted = await tx.query<{ id: string }>(
|
||||||
`INSERT INTO atlas.platform_catalog_sync_runs
|
`INSERT INTO atlas.platform_catalog_sync_runs
|
||||||
(platform_id, scope, scope_key, category_id, attribute_id,
|
(platform_id, scope, scope_key, category_id, trigger_kind,
|
||||||
trigger_kind, source_revision)
|
priority, source_revision)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'scheduled', $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[platformId, candidate.scope, candidate.scope_key,
|
[input.platformId, input.categoryId ? 'attributes' : 'tree', input.scopeKey,
|
||||||
candidate.category_id, candidate.attribute_id, sourceRevision],
|
input.categoryId ?? null, input.trigger, input.priority, input.sourceRevision],
|
||||||
);
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id)
|
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id)
|
||||||
VALUES ($1, $2)`,
|
VALUES ($1, $2)`,
|
||||||
[run.rows[0].id, platformId],
|
[inserted.rows[0].id, input.platformId],
|
||||||
);
|
);
|
||||||
|
return inserted.rows[0].id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import {
|
import {
|
||||||
fetchOzonCategoryAttributes, fetchOzonCategoryTree, fetchOzonDictionaryBatch,
|
fetchOzonCategoryAttributes, fetchOzonCategoryTree, fetchOzonDictionaryBatch,
|
||||||
|
fetchOzonDictionaryValues,
|
||||||
OzonCredentialError, OzonRequestError, type OzonCategoryNode,
|
OzonCredentialError, OzonRequestError, type OzonCategoryNode,
|
||||||
type OzonDictionaryValue,
|
type OzonDictionaryValue,
|
||||||
} from './ozon-client.js';
|
} from './ozon-client.js';
|
||||||
@@ -10,7 +11,10 @@ import { decryptStoreSecret } from './store-secret.crypto.js';
|
|||||||
import { workerConfig } from './config.js';
|
import { workerConfig } from './config.js';
|
||||||
|
|
||||||
type RunSource = {
|
type RunSource = {
|
||||||
id: string; platform_id: string; scope: 'tree' | 'attributes' | 'dictionary';
|
id: string; platform_id: string;
|
||||||
|
scope: 'tree' | 'attributes' | 'dictionary' | 'dictionary_search';
|
||||||
|
scope_key: string;
|
||||||
|
search_key: string | null;
|
||||||
category_id: string | null; attribute_id: string | null;
|
category_id: string | null; attribute_id: string | null;
|
||||||
source_revision: number; platform_code: string;
|
source_revision: number; platform_code: string;
|
||||||
client_id: string; secret_ciphertext: Buffer; secret_nonce: Buffer;
|
client_id: string; secret_ciphertext: Buffer; secret_nonce: Buffer;
|
||||||
@@ -22,7 +26,7 @@ type RunSource = {
|
|||||||
type SyncPayload =
|
type SyncPayload =
|
||||||
| { scope: 'tree'; nodes: OzonCategoryNode[] }
|
| { scope: 'tree'; nodes: OzonCategoryNode[] }
|
||||||
| { scope: 'attributes'; attributes: Awaited<ReturnType<typeof fetchOzonCategoryAttributes>> }
|
| { scope: 'attributes'; attributes: Awaited<ReturnType<typeof fetchOzonCategoryAttributes>> }
|
||||||
| { scope: 'dictionary'; batch: Awaited<ReturnType<typeof fetchOzonDictionaryBatch>> };
|
| { scope: 'dictionary' | 'dictionary_search'; batch: Awaited<ReturnType<typeof fetchOzonDictionaryBatch>> };
|
||||||
|
|
||||||
export async function syncPlatformCatalog(database: Database, job: PlatformCatalogJob) {
|
export async function syncPlatformCatalog(database: Database, job: PlatformCatalogJob) {
|
||||||
const source = await claimRun(database, job);
|
const source = await claimRun(database, job);
|
||||||
@@ -60,13 +64,30 @@ export async function syncPlatformCatalog(database: Database, job: PlatformCatal
|
|||||||
await finishRun(database, job.runId, 'failed', 'ATTRIBUTE_NOT_FOUND', '字典属性已失效');
|
await finishRun(database, job.runId, 'failed', 'ATTRIBUTE_NOT_FOUND', '字典属性已失效');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
payload = {
|
const dictionaryInput = {
|
||||||
scope: 'dictionary',
|
|
||||||
batch: await fetchOzonDictionaryBatch(source.client_id, apiKey, {
|
|
||||||
attributeId: source.platform_attribute_id,
|
attributeId: source.platform_attribute_id,
|
||||||
categoryId: source.external_category_id,
|
categoryId: source.external_category_id,
|
||||||
typeId: source.external_type_id,
|
typeId: source.external_type_id,
|
||||||
}),
|
};
|
||||||
|
payload = source.scope === 'dictionary_search'
|
||||||
|
? {
|
||||||
|
scope: 'dictionary_search',
|
||||||
|
batch: {
|
||||||
|
values: await fetchOzonDictionaryValues(
|
||||||
|
source.client_id,
|
||||||
|
apiKey,
|
||||||
|
{ ...dictionaryInput, query: source.search_key ?? '' },
|
||||||
|
),
|
||||||
|
isComplete: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
scope: 'dictionary',
|
||||||
|
batch: await fetchOzonDictionaryBatch(
|
||||||
|
source.client_id,
|
||||||
|
apiKey,
|
||||||
|
dictionaryInput,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +121,8 @@ export async function markPlatformCatalogUnavailable(database: Database, job: Pl
|
|||||||
async function claimRun(database: Database, job: PlatformCatalogJob): Promise<RunSource | null> {
|
async function claimRun(database: Database, job: PlatformCatalogJob): Promise<RunSource | null> {
|
||||||
return database.transaction(async (tx) => {
|
return database.transaction(async (tx) => {
|
||||||
const result = await tx.query<RunSource>(
|
const result = await tx.query<RunSource>(
|
||||||
`SELECT run.id, run.platform_id, run.scope, run.category_id, run.attribute_id,
|
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.search_key,
|
||||||
|
run.category_id, run.attribute_id,
|
||||||
run.source_revision, platform.code AS platform_code,
|
run.source_revision, platform.code AS platform_code,
|
||||||
source.client_id, source.secret_ciphertext, source.secret_nonce,
|
source.client_id, source.secret_ciphertext, source.secret_nonce,
|
||||||
source.revision AS current_revision,
|
source.revision AS current_revision,
|
||||||
@@ -159,6 +181,13 @@ async function persistAndComplete(database: Database, source: RunSource, payload
|
|||||||
source.attribute_id ?? '', payload.batch.values, payload.batch.isComplete,
|
source.attribute_id ?? '', payload.batch.values, payload.batch.isComplete,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await recordResourceSuccess(
|
||||||
|
tx,
|
||||||
|
source,
|
||||||
|
payload.scope === 'dictionary' || payload.scope === 'dictionary_search'
|
||||||
|
? payload.batch.isComplete
|
||||||
|
: true,
|
||||||
|
);
|
||||||
await updateRun(tx, source.id, 'succeeded', result);
|
await updateRun(tx, source.id, 'succeeded', result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -318,7 +347,62 @@ async function finishRun(
|
|||||||
database: Database, runId: string,
|
database: Database, runId: string,
|
||||||
status: 'failed' | 'cancelled', code: string, message: string,
|
status: 'failed' | 'cancelled', code: string, message: string,
|
||||||
) {
|
) {
|
||||||
await database.transaction((tx) => updateRun(tx, runId, status, null, code, message));
|
await database.transaction(async (tx) => {
|
||||||
|
if (status === 'failed') {
|
||||||
|
await recordResourceFailure(tx, runId, code, message);
|
||||||
|
}
|
||||||
|
await updateRun(tx, runId, status, null, code, message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordResourceSuccess(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
source: RunSource,
|
||||||
|
isComplete: boolean,
|
||||||
|
) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_resource_states
|
||||||
|
(platform_id, scope, scope_key, category_id, attribute_id, search_key,
|
||||||
|
last_success_at, last_success_run_id, is_complete)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, now(), $7, $8)
|
||||||
|
ON CONFLICT (platform_id, scope_key) DO UPDATE
|
||||||
|
SET scope = EXCLUDED.scope,
|
||||||
|
category_id = EXCLUDED.category_id,
|
||||||
|
attribute_id = EXCLUDED.attribute_id,
|
||||||
|
search_key = EXCLUDED.search_key,
|
||||||
|
last_success_at = EXCLUDED.last_success_at,
|
||||||
|
last_success_run_id = EXCLUDED.last_success_run_id,
|
||||||
|
is_complete = EXCLUDED.is_complete,
|
||||||
|
last_error_code = NULL,
|
||||||
|
last_error_message = NULL,
|
||||||
|
last_error_at = NULL,
|
||||||
|
updated_at = now()`,
|
||||||
|
[source.platform_id, source.scope, source.scope_key,
|
||||||
|
source.category_id, source.attribute_id, source.search_key,
|
||||||
|
source.id, isComplete],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordResourceFailure(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
runId: string,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_resource_states
|
||||||
|
(platform_id, scope, scope_key, category_id, attribute_id, search_key,
|
||||||
|
is_complete, last_error_code, last_error_message, last_error_at)
|
||||||
|
SELECT platform_id, scope, scope_key, category_id, attribute_id, search_key,
|
||||||
|
false, $2, $3, now()
|
||||||
|
FROM atlas.platform_catalog_sync_runs WHERE id = $1
|
||||||
|
ON CONFLICT (platform_id, scope_key) DO UPDATE
|
||||||
|
SET last_error_code = EXCLUDED.last_error_code,
|
||||||
|
last_error_message = EXCLUDED.last_error_message,
|
||||||
|
last_error_at = EXCLUDED.last_error_at,
|
||||||
|
updated_at = now()`,
|
||||||
|
[runId, code, message],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRun(
|
async function updateRun(
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { PlatformCatalogScheduler } from '../src/platform-catalog-scheduler.ts';
|
||||||
|
|
||||||
|
function schedulerDatabase(handler) {
|
||||||
|
const statements = [];
|
||||||
|
const query = async (sql, parameters = []) => {
|
||||||
|
statements.push({ sql, parameters });
|
||||||
|
return handler(sql, parameters);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
statements,
|
||||||
|
database: {
|
||||||
|
query: async (sql, parameters = []) => {
|
||||||
|
statements.push({ sql, parameters });
|
||||||
|
if (sql.includes('SELECT source.platform_id')) {
|
||||||
|
return { rows: [{ platform_id: 'platform-1' }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
return handler(sql, parameters);
|
||||||
|
},
|
||||||
|
transaction: async (work) => work({ query }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('没有管理员批次时只检查每周类目树,不自动创建属性或字典任务', async () => {
|
||||||
|
const { database, statements } = schedulerDatabase(async (sql) => {
|
||||||
|
if (sql.includes('SELECT revision FROM atlas.platform_catalog_sources')) {
|
||||||
|
return { rows: [{ revision: 1 }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes("count(*)::text AS count FROM atlas.platform_catalog_sync_runs")) {
|
||||||
|
return { rows: [{ count: '0' }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes('AS due')) return { rows: [{ due: false }], rowCount: 1 };
|
||||||
|
if (sql.includes('SELECT item.batch_id')) return { rows: [], rowCount: 0 };
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await new PlatformCatalogScheduler(database).run();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
statements.some(({ sql }) => sql.includes('INSERT INTO atlas.platform_catalog_sync_runs')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
statements.some(({ sql }) => sql.includes("scope = 'dictionary'")),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('到期类目树创建每周低优先级任务', async () => {
|
||||||
|
const { database, statements } = schedulerDatabase(async (sql) => {
|
||||||
|
if (sql.includes('SELECT revision FROM atlas.platform_catalog_sources')) {
|
||||||
|
return { rows: [{ revision: 3 }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes("count(*)::text AS count FROM atlas.platform_catalog_sync_runs")) {
|
||||||
|
return { rows: [{ count: '0' }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes('AS due')) return { rows: [{ due: true }], rowCount: 1 };
|
||||||
|
if (sql.includes('SELECT id, priority FROM atlas.platform_catalog_sync_runs')) {
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO atlas.platform_catalog_sync_runs')) {
|
||||||
|
return { rows: [{ id: 'run-tree' }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT item.batch_id')) return { rows: [], rowCount: 0 };
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await new PlatformCatalogScheduler(database).run();
|
||||||
|
|
||||||
|
const insert = statements.find(({ sql }) =>
|
||||||
|
sql.includes('INSERT INTO atlas.platform_catalog_sync_runs'));
|
||||||
|
assert.ok(insert);
|
||||||
|
assert.deepEqual(insert.parameters, [
|
||||||
|
'platform-1', 'tree', 'tree', null, 'weekly', 'low', 3,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员批次按资源年龄创建高优先级类型属性任务', async () => {
|
||||||
|
const { database, statements } = schedulerDatabase(async (sql) => {
|
||||||
|
if (sql.includes('SELECT revision FROM atlas.platform_catalog_sources')) {
|
||||||
|
return { rows: [{ revision: 4 }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes("count(*)::text AS count FROM atlas.platform_catalog_sync_runs")) {
|
||||||
|
return { rows: [{ count: '0' }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
if (sql.includes('AS due')) return { rows: [{ due: false }], rowCount: 1 };
|
||||||
|
if (sql.includes('SELECT item.batch_id')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
batch_id: 'batch-1',
|
||||||
|
category_id: 'category-1',
|
||||||
|
source_revision: 4,
|
||||||
|
priority: 'high',
|
||||||
|
}],
|
||||||
|
rowCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id, priority FROM atlas.platform_catalog_sync_runs')) {
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO atlas.platform_catalog_sync_runs')) {
|
||||||
|
return { rows: [{ id: 'run-attribute' }], rowCount: 1 };
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await new PlatformCatalogScheduler(database).run();
|
||||||
|
|
||||||
|
const insert = statements.find(({ sql }) =>
|
||||||
|
sql.includes('INSERT INTO atlas.platform_catalog_sync_runs'));
|
||||||
|
assert.ok(insert);
|
||||||
|
assert.deepEqual(insert.parameters, [
|
||||||
|
'platform-1', 'attributes', 'attributes:category-1', 'category-1',
|
||||||
|
'admin_batch', 'high', 4,
|
||||||
|
]);
|
||||||
|
assert.ok(statements.some(({ sql }) =>
|
||||||
|
sql.includes('UPDATE atlas.platform_catalog_attribute_batch_items')));
|
||||||
|
});
|
||||||
+2
-2
@@ -16,9 +16,9 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
|
|||||||
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 |
|
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 |
|
||||||
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
||||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||||
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.5 部分已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并,以及 Ozon 类目树、动态属性和关键词字典缓存同步;媒体、定价、AI 任务与首次上架仍待实现。 |
|
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 编辑器与发布预检已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并、平台级 Ozon 类目/属性/字典按需刷新及首次上架目录预检;定价、AI 任务与真正的 Ozon 发布 job 仍待实现。 |
|
||||||
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
|
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
|
||||||
| [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、手动同步和任务状态。 |
|
| [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、每周树同步、刷新策略、三种类型属性批次、优先级、任务状态及后台页面。 |
|
||||||
|
|
||||||
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 平台类目与属性管理 API
|
# 平台类目与属性管理 API
|
||||||
|
|
||||||
> 2026-09-18 新增契约。平台级目录供系统管理员维护;普通商品编辑器现有门店级 API 暂不改变。平台参考目录的归属见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md)。
|
> 2026-09-18 新增契约,2026-09-20 增加平台刷新策略和类型属性批次。平台级目录供系统管理员维护;普通商品编辑器现有门店级 API 暂不改变。平台参考目录的归属见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md),新鲜度与优先级规则见 [`platform-catalog-cache-refresh-plan.md`](../architecture/platform-catalog-cache-refresh-plan.md)。
|
||||||
|
|
||||||
## 权限与安全
|
## 权限与安全
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ ON CONFLICT (user_id) DO UPDATE SET revoked_at = NULL, granted_at = now();
|
|||||||
|
|
||||||
`GET /api/system/platform-catalog/platforms`
|
`GET /api/system/platform-catalog/platforms`
|
||||||
|
|
||||||
返回 `{ "data": [{ "id", "code", "name", "source": { "configured", "client_id_hint", "updated_at" }, "summary": { "category_count", "last_tree_sync_at", "last_error" } }] }`。`client_id_hint` 仅为脱敏末尾字符,不返回 API Key、密文或指纹。
|
返回 `{ "data": [{ "id", "code", "name", "source": { "configured", "client_id_hint", "updated_at" }, "summary": { "category_count", "last_tree_sync_at", "next_tree_sync_at", "last_error" } }] }`。`client_id_hint` 仅为脱敏末尾字符,不返回 API Key、密文或指纹。`next_tree_sync_at` 固定为最近成功时间后 7 天;从未成功时为 `null` 并由调度器尽快创建首次任务。
|
||||||
|
|
||||||
`PUT /api/system/platform-catalog/platforms/{platform_id}/source`
|
`PUT /api/system/platform-catalog/platforms/{platform_id}/source`
|
||||||
|
|
||||||
@@ -37,11 +37,33 @@ Body:`{ "client_id": "...", "api_key": "..." }`。仅 Ozon 适配器接受当
|
|||||||
|
|
||||||
`GET /api/system/platform-catalog/platforms/{platform_id}/categories`
|
`GET /api/system/platform-catalog/platforms/{platform_id}/categories`
|
||||||
|
|
||||||
返回当前平台的全部树节点 `{ id, parent_id, external_category_id, external_type_id, name, path, is_active, synced_at }`;按父子关系在客户端构建树,目录节点可展开,仅可发布类型叶子用于查看属性。
|
返回当前平台的全部树节点 `{ id, parent_id, external_category_id, external_type_id, name, path, is_active, synced_at, attribute_last_success_at, attribute_cache_status, attribute_last_error, attribute_last_error_at }`;按父子关系在客户端构建树,目录节点可展开,仅可发布类型叶子用于查看属性。纯目录节点的属性状态为 `null`;可发布类型状态为 `fresh | stale | expired | missing`。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/refresh-policy`
|
||||||
|
|
||||||
|
返回平台目录刷新策略:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"attribute_fresh_days": 14,
|
||||||
|
"attribute_expire_days": 21,
|
||||||
|
"dictionary_fresh_days": 14,
|
||||||
|
"dictionary_expire_days": 21,
|
||||||
|
"updated_at": "2026-09-20T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
尚未显式配置的平台返回相同默认值。类目树固定每 7 天同步,不属于可配置策略。
|
||||||
|
|
||||||
|
`PUT /api/system/platform-catalog/platforms/{platform_id}/refresh-policy`
|
||||||
|
|
||||||
|
Body 必须完整提交 `{ "attribute_fresh_days", "attribute_expire_days", "dictionary_fresh_days", "dictionary_expire_days" }`。四项均为 `1..365` 的整数,同类资源的 `expire_days` 必须严格大于 `fresh_days`。成功保存后立即影响后续状态判断,但不得因此主动扫描或创建属性/字典任务;服务端记录修改人和时间。
|
||||||
|
|
||||||
`GET /api/system/platform-catalog/platforms/{platform_id}/categories/{category_id}/attributes`
|
`GET /api/system/platform-catalog/platforms/{platform_id}/categories/{category_id}/attributes`
|
||||||
|
|
||||||
返回该类型的属性定义 `{ id, platform_attribute_id, complex_id, name, description, group_name, value_type, is_required, is_aspect, is_collection, max_value_count, dictionary_id, is_active, synced_at, dictionary_value_count }`。`category_id` 不属于所选平台时返回 404。
|
返回该类型的属性定义 `{ id, platform_attribute_id, complex_id, name, description, group_name, value_type, is_required, is_aspect, is_collection, max_value_count, dictionary_id, is_active, synced_at, last_success_at, cache_status, last_error_message, last_error_at, dictionary_last_success_at, dictionary_cache_status, dictionary_is_complete, dictionary_last_error_message, dictionary_last_error_at, dictionary_value_count }`。类型级 `last_success_at/cache_status` 会在该类型的每个属性行重复,便于管理页展示;没有任何属性行时应结合属性汇总或资源状态判断 missing。`category_id` 不属于所选平台时返回 404。
|
||||||
|
|
||||||
`GET /api/system/platform-catalog/platforms/{platform_id}/attributes/{attribute_id}/values?q={keyword}&page={number}`
|
`GET /api/system/platform-catalog/platforms/{platform_id}/attributes/{attribute_id}/values?q={keyword}&page={number}`
|
||||||
|
|
||||||
@@ -49,8 +71,52 @@ Body:`{ "client_id": "...", "api_key": "..." }`。仅 Ozon 适配器接受当
|
|||||||
|
|
||||||
`POST /api/system/platform-catalog/platforms/{platform_id}/syncs`
|
`POST /api/system/platform-catalog/platforms/{platform_id}/syncs`
|
||||||
|
|
||||||
Headers:`Idempotency-Key`(UUID)必填。Body:`{ "scope": "tree" | "attributes" | "dictionary", "category_id"?: "uuid", "attribute_id"?: "uuid" }`。树同步不带其他 ID;属性同步需要当前平台的可发布 `category_id`;字典同步需要该类型下带字典的 `attribute_id`。同作用域已有 queued/running 任务时返回已有任务,并将本次幂等键持久映射到该任务;同一用户重试不会在原任务完成后意外创建第二个任务。成功排队返回 `202` 与 `{ "data": { "id", "platform_id", "scope", "status", "trigger", "created_at" } }`。同步由 Worker 异步执行,失败保留上次成功缓存。
|
Headers:`Idempotency-Key`(UUID)必填。Body:`{ "scope": "tree" | "attributes" | "dictionary", "category_id"?: "uuid", "attribute_id"?: "uuid" }`。树同步不带其他 ID;兼容的单类型属性同步需要当前平台的可发布 `category_id`;字典同步需要该类型下带字典的 `attribute_id`。同作用域已有 queued/running 任务时返回已有任务,并将本次幂等键持久映射到该任务;同一用户重试不会在原任务完成后意外创建第二个任务。成功排队返回 `202` 与 `{ "data": { "id", "platform_id", "scope", "status", "trigger", "priority", "created_at" } }`。同步由 Worker 异步执行,失败保留上次成功缓存。树任务优先级独立决定;属性和字典根据资源年龄选择 `low` 或 `high`,missing/严格超过过期阈值为 `high`。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/attribute-sync-summary`
|
||||||
|
|
||||||
|
返回当前策略下活动可发布类型的聚合数量:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"total": 7365,
|
||||||
|
"fresh": 100,
|
||||||
|
"stale": 200,
|
||||||
|
"expired": 300,
|
||||||
|
"missing": 6765,
|
||||||
|
"active_batch_id": null,
|
||||||
|
"evaluated_at": "2026-09-20T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
状态边界严格按平台策略和最近成功时间计算:`age < fresh_days` 为 fresh,`fresh_days ≤ age ≤ expire_days` 为 stale,`age > expire_days` 为 expired,从未成功为 missing。
|
||||||
|
|
||||||
|
`POST /api/system/platform-catalog/platforms/{platform_id}/attribute-sync-batches`
|
||||||
|
|
||||||
|
Headers:`Idempotency-Key`(UUID)必填。Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "mode": "missing_expired" | "missing_stale_expired" | "all" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `missing_expired` 固定 missing、expired 类型快照;
|
||||||
|
- `missing_stale_expired` 固定 missing、stale、expired 类型快照;
|
||||||
|
- `all` 固定全部活动可发布类型快照,包含 fresh。
|
||||||
|
|
||||||
|
没有命中类型时返回 `422 CATALOG_ATTRIBUTE_BATCH_EMPTY`。同一平台已有 queued/running 批次时返回 `409 CATALOG_ATTRIBUTE_BATCH_ACTIVE` 和现有批次 ID,避免两个全量扫描互相竞争。成功返回 `202` 与批次 `{ id, platform_id, mode, status, total_count, pending_count, running_count, succeeded_count, failed_count, cancelled_count, created_at, started_at, completed_at }`。Worker 每分钟限量领取批次项;missing/expired 子任务为高优先级,fresh/stale 子任务为低优先级。批次创建后新增的类型不加入本批次。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/attribute-sync-batches?limit={number}`
|
||||||
|
|
||||||
|
返回最近批次及实时聚合进度,最多 50 条。批次允许部分失败:任一子任务失败时最终状态为 `failed`,但成功类型继续保留且不会回滚。
|
||||||
|
|
||||||
|
`GET /api/system/platform-catalog/platforms/{platform_id}/attribute-sync-batches/{batch_id}`
|
||||||
|
|
||||||
|
返回单个批次的相同进度字段;批次不属于当前平台时返回 404。
|
||||||
|
|
||||||
`GET /api/system/platform-catalog/platforms/{platform_id}/syncs?limit={number}`
|
`GET /api/system/platform-catalog/platforms/{platform_id}/syncs?limit={number}`
|
||||||
|
|
||||||
返回该平台最近的同步任务(最多 50 条),包括作用域、触发方式、状态、开始/完成时间、数量摘要及脱敏错误。系统管理员可据此查看手动和定时更新结果。
|
返回该平台最近的同步任务(最多 50 条),包括作用域、触发方式、状态、开始/完成时间、数量摘要及脱敏错误。系统管理员可据此查看手动和定时更新结果。
|
||||||
|
|
||||||
|
类目树调度固定为最近一次成功后 7 天。类型属性和属性字典没有周期性自动扫描;它们只由管理员操作、后续普通用户访问和发布前校验创建任务。
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# 商品编辑与首次上架 API
|
# 商品编辑与首次上架 API
|
||||||
|
|
||||||
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和字典按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
|
> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。素材选择已实现;定价确认、AI 任务、真正调用 Ozon 的首次发布 job 仍是冻结契约,尚未实现。
|
||||||
|
|
||||||
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||||
|
|
||||||
> 架构迁移说明:2026-09-18 已决定将类目/商品类型树、属性定义和字典候选值迁至[平台级参考目录](../architecture/platform-product-reference-catalog.md),同一平台所有门店共用,不维护门店差异层。本文件以下字段、UUID 和同步端点仍描述**当前已实现的门店级契约**;迁移前不得把它们解释为已经共享。未来调整须先修订本契约、共享类型和测试。
|
> 架构迁移说明:2026-09-18 已决定将类目/商品类型树、属性定义和字典候选值迁至[平台级参考目录](../architecture/platform-product-reference-catalog.md),同一平台所有门店共用,不维护门店差异层。迁移已完成到商品编辑和发布预检;旧门店级目录表仅保留兼容/回滚,不再作为读取、保存或发布校验事实。
|
||||||
|
|
||||||
## 1. 进入编辑与读取编辑器
|
## 1. 进入编辑与读取编辑器
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
|
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
|
||||||
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } },
|
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } },
|
||||||
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
|
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
|
||||||
"store_product_category_id": "uuid", "category_source": "manual" },
|
"platform_product_category_id": "uuid", "category_source": "manual" },
|
||||||
"variants": [{
|
"variants": [{
|
||||||
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
|
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
|
||||||
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
|
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
|
||||||
@@ -112,7 +112,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。
|
- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。
|
||||||
- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。
|
- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。
|
||||||
- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。
|
- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。
|
||||||
- `profile.store_product_category_id` 必须是当前目标门店的活动类目;服务端从门店类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。`attributes` 是完整属性草稿集合,属性 ID、复杂属性 ID、值类型、单/多值上限和字典值均由当前门店类目 schema 校验。
|
- `profile.platform_product_category_id` 必须是当前平台的活动类目;同平台门店共享类目、类型属性和字典缓存。服务端从平台类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。过渡期仍接受 `store_product_category_id`,但只用于按 `description_category_id + type_id` 映射平台类目,不参与当前读取和校验。
|
||||||
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
|
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
|
||||||
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
||||||
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
||||||
@@ -132,23 +132,25 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
|||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
|
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
|
||||||
|
|
||||||
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id`、`is_aspect` 与 `editor_field`。`editor_field` 只按 Ozon 属性 ID 计算,不允许按名称或翻译文本匹配:品牌 `85 → brand`、型号名称 `9048 → model`、类型 `8229 → category`、主题标签 `23171 → tags`、JSON 富内容 `11254 → rich_content`、简介/产品描述 `4191 → description`,其他 ID 返回 `null`。非空字段由编辑器已有控件承载,动态属性区不得重复展示或加载字典;服务端会兼容但忽略旧客户端重复提交的映射属性。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
|
当前 profile 必须已有平台类目。响应包含 `{ items, cache }`;字段 schema 额外返回 Atlas `attribute_id`,供字典接口精确限定属性作用域,其余字段包括 `platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id`、`is_aspect` 与 `editor_field`。`editor_field` 只按 Ozon 属性 ID 计算,不允许按名称或翻译文本匹配:品牌 `85 → brand`、型号名称 `9048 → model`、类型 `8229 → category`、主题标签 `23171 → tags`、JSON 富内容 `11254 → rich_content`、简介/产品描述 `4191 → description`,其他 ID 返回 `null`。可带 `?category_id={platform_category_uuid}` 预览尚未保存的新类目 schema。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
|
`cache.status` 为 `fresh | stale | expired | missing`,并返回 `last_success_at`、`fresh_until`、`expires_at`、`is_complete`、`refresh_run_id`、`refresh_status` 和 `retry_after_ms`。fresh 直接返回;stale 返回旧 schema 并创建或复用低优先级平台任务;expired/missing 创建或复用高优先级平台任务并最多等待 5 秒,仍未完成时以 HTTP `202` 返回空 `items` 和缓存状态。API 不直连 Ozon。
|
||||||
|
|
||||||
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`,每页最多 200 项;传入 `next_cursor` 可继续读取,`null` 表示本地缓存已读完。可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`;无效游标返回 `400 VALIDATION_FAILED`。接口只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
|
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{attribute_id}?q={keyword}&cursor={opaque}`
|
||||||
|
|
||||||
|
返回 `{ items: [{ value_id, value }], next_cursor, cache }`,每页最多 200 项。`attribute_id` 是 schema 返回的 Atlas 平台属性 UUID,不再只用可能跨类型重复的 Ozon `dictionary_id` 定位。完整字典使用字典整体状态;部分字典本地搜索未命中时,规范化关键词使用独立 `dictionary_search` 状态。fresh/stale/expired/missing 的返回、优先级和 5 秒等待规则与 schema 相同;expired/missing 的旧候选值不作为当前选项返回。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
||||||
|
|
||||||
返回当前目标门店的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,编辑器不提供同步按钮;当前 Worker 在门店凭据验证成功后用该门店凭据同步树。平台级共享参考源属于待迁移目标,不是本接口的现有行为。
|
返回当前平台共享的缓存类目树节点,包含平台类目 UUID、父节点 UUID、Ozon `description_category_id` / `type_id`、名称、路径和活动状态。接口不再接收或使用 `store_id`;切换同平台目标门店不会清空类目。树为空时客户端提示联系系统管理员同步。
|
||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
|
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
|
||||||
|
|
||||||
为已选门店活动类目创建 `store_category_attribute_sync` operation。同步以单类目事务替换该门店 schema;下一次编辑草稿保存会依最新 schema 校验并将不匹配旧值转为未映射草稿。
|
兼容入口:为平台类目创建或复用高优先级 `platform_catalog_sync_runs`。普通编辑器读取已自动按新鲜度触发刷新,不要求用户先点击本接口。
|
||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
|
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{attribute_id}/syncs?category_id={uuid}&q={keyword}`
|
||||||
|
|
||||||
不传 `q` 时创建 `store_attribute_dictionary_sync` operation,Worker 从该门店的 Ozon 字典值接口按游标读取首批最多 1000 项并 upsert 本地缓存;打开字典控件时可按需触发,当前类目下同一字典只自动触发一次,切换类目后重新评估,不在加载类目时同时同步全部字典。传入 `q` 时至少两个字符,Worker 使用 Ozon 字典搜索接口 upsert 命中的值;前端输入关键词后延迟触发,以便从超过首批范围的大字典中继续查找。所有字典属性均由 schema 的 `dictionary_id` 驱动,不根据中文属性名维护固定选项。Ozon 密钥始终留在 Worker。
|
兼容入口:不传 `q` 时刷新该平台属性的字典批次;传入至少两个字符的 `q` 时刷新独立关键词作用域。任务写入平台 Outbox,由 Worker 使用系统后台配置的平台参考源凭据调用 Ozon,响应不会携带密钥。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/validation`
|
`GET /api/product-platform-profiles/{profile_id}/validation`
|
||||||
|
|
||||||
@@ -191,6 +193,23 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
|||||||
|
|
||||||
`profile.issues` 包含商品级基础信息、类目 schema 和 `is_aspect=false` 的必填属性;每个 SKU 的 `issues` 包含货号、价格、有效包装规格、图片和 `is_aspect=true` 的必填属性。只有 `confirmation_status=confirmed` 且包含非空值的属性满足发布要求。`ready=true` 要求 profile 无问题、至少存在一个未上架 SKU,且全部未上架 SKU 均通过校验。
|
`profile.issues` 包含商品级基础信息、类目 schema 和 `is_aspect=false` 的必填属性;每个 SKU 的 `issues` 包含货号、价格、有效包装规格、图片和 `is_aspect=true` 的必填属性。只有 `confirmation_status=confirmed` 且包含非空值的属性满足发布要求。`ready=true` 要求 profile 无问题、至少存在一个未上架 SKU,且全部未上架 SKU 均通过校验。
|
||||||
|
|
||||||
|
`POST /api/product-platform-profiles/{profile_id}/publish-preflights`
|
||||||
|
|
||||||
|
Body:`{ "variant_ids": ["uuid", "uuid"] }`,最多 100 个且不能重复。该接口是创建真正发布 job 之前的可重算预检,不固化商品发布快照;平台目录任务按资源 `scope_key` 幂等合并,因此重复预检不会并发重复请求 Ozon。
|
||||||
|
|
||||||
|
预检只接受当前 profile 下 `not_listed` / `failed` 的未删除 SKU。`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不属于当前商品或已删除返回 `422 PUBLISH_VARIANTS_INVALID`。
|
||||||
|
|
||||||
|
目录处理顺序:
|
||||||
|
|
||||||
|
1. 只检查当前 `platform_product_category_id` 的 schema。fresh 直接使用;stale 使用旧 schema 并创建/复用 low、`trigger_kind=publish` 的平台任务;expired/missing 创建/复用 high 任务。
|
||||||
|
2. schema 可用后,从本次 SKU 集合的已确认草稿中提取实际使用的字典属性。未填写的可选字典、其他 SKU 和同类型下未使用字典都不刷新。
|
||||||
|
3. 字典沿用相同 14/21 天状态机。expired/missing 候选值不能作为发布依据;预检最多共用一个 5 秒等待窗口观察所有 high Worker 任务,不按字典数量逐项累加等待。
|
||||||
|
4. high schema 成功后,预检再根据新 schema 计算字典依赖;最后重新执行必填、scope、包装、价格、图片以及字典值有效性校验。
|
||||||
|
|
||||||
|
可立即完成时返回 HTTP `200`;仍在等待 high 任务时返回 HTTP `202`。响应包含 `status=ready|blocked|refreshing`、完整 `validation`、schema/已用字典的 `catalog` cache 元数据,以及本次创建或复用的 `refresh_runs`。
|
||||||
|
|
||||||
|
`refreshing` 时客户端使用相同 SKU 集合重新预检;不得退回过期缓存。`blocked` 表示目录资源可用但商品草稿仍有业务校验问题;`ready` 才能进入未来的发布快照创建事务。Ozon 最终仍可能拒绝 payload,该错误属于具体商品/门店的发布结果,不能据此创建门店级目录副本。
|
||||||
|
|
||||||
## 4. SKU 定价确认
|
## 4. SKU 定价确认
|
||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/pricing-confirms`
|
`POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/pricing-confirms`
|
||||||
@@ -244,6 +263,8 @@ Headers:`If-Match`、`Idempotency-Key` 必填。用户确认后才将候选写
|
|||||||
|
|
||||||
## 7. 部分 SKU 首次上架
|
## 7. 部分 SKU 首次上架
|
||||||
|
|
||||||
|
> 当前实现到上述 `publish-preflights`。以下 `publish-jobs`、Ozon `/v3/product/import` 提交和结果轮询仍是冻结契约,尚未开发;不得把预检 `ready` 当成已经提交或发布成功。
|
||||||
|
|
||||||
`POST /api/product-platform-profiles/{profile_id}/publish-jobs`
|
`POST /api/product-platform-profiles/{profile_id}/publish-jobs`
|
||||||
|
|
||||||
Headers:`Idempotency-Key` 必填。Body:
|
Headers:`Idempotency-Key` 必填。Body:
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# 平台目录缓存、新鲜度与按需刷新方案
|
||||||
|
|
||||||
|
> 方案日期:2026-09-20。状态:**第 1~4 步已实现**。
|
||||||
|
> 迁移 `025`~`027`、系统管理 API/页面、Worker 调度、商品编辑器平台级读取及首次发布预检均已落地。真正的 Ozon 首次发布 job 尚未开发;“第 4 步完成”仅指发布前目录刷新与最终本地校验已具备稳定复用入口。
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
本方案解决三个问题:
|
||||||
|
|
||||||
|
1. 类目树、类型属性和属性字典只保存一份平台级参考数据,同平台所有门店和用户共同使用。
|
||||||
|
2. 类目树通过低频自动同步维持基线;类型属性和属性字典不做周期性全量自动同步,改为管理员发起、用户访问触发和发布前校验。
|
||||||
|
3. 用户读取缓存时按新鲜度决定直接返回、后台刷新或等待高优先级刷新;Ozon 请求始终由 Worker 执行,不在 API 进程同步直连。
|
||||||
|
|
||||||
|
本阶段不实现门店差异层,不依赖 Ozon 聊天通知,也不假设 Ozon 提供类目变更 Feed。商品/SKU 已填值、门店凭据、发布结果和错误仍属于工作空间业务数据,不迁入平台参考目录。
|
||||||
|
|
||||||
|
## 2. 平台级唯一归属
|
||||||
|
|
||||||
|
| 参考数据 | 归属与读取键 | 禁止行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 类目/商品类型树 | `platform_id + external_category_id + external_type_id` | 不按 `store_id` 复制,不用名称匹配,不因切换同平台门店重新同步 |
|
||||||
|
| 类型属性定义 | `platform_id + platform_product_category_id + platform_attribute_id + complex_id` | 不保存门店级属性定义,不使用门店凭据刷新普通商品编辑缓存 |
|
||||||
|
| 属性字典候选值 | `platform_id + platform_product_category_id + attribute_id + external_value_id` | 不只按 `dictionary_id` 跨类型合并,不按门店保存同一候选值副本 |
|
||||||
|
|
||||||
|
所有普通读路径、发布校验和系统后台都读取平台级表。商品编辑器迁移时以 Ozon `description_category_id + type_id` 将现有门店类目引用映射到平台类目 UUID,再把属性和字典读取切换到平台级;迁移完成前保留旧表用于兼容和回滚,不能混用新旧 UUID。
|
||||||
|
|
||||||
|
平台目录只使用系统后台为该平台配置的专用参考源凭据。普通用户不会选择同步门店,API 响应和任务载荷不包含 `Api-Key`;只有 Worker 在调用平台时短暂解密凭据。
|
||||||
|
|
||||||
|
## 3. 同步触发规则
|
||||||
|
|
||||||
|
### 3.1 类目树
|
||||||
|
|
||||||
|
- 每个平台每 7 天自动同步一次完整类目/商品类型树。
|
||||||
|
- 周期从最近一次成功完成时间计算;失败不更新成功时间,按 Worker 重试策略处理。
|
||||||
|
- 系统后台保留“同步类目树”按钮。管理员手动同步绕过 7 天周期,但复用相同平台上已有的 queued/running 树任务,不重复调用 Ozon。
|
||||||
|
- 首次配置或更换平台参考源凭据时,自动创建一次树同步任务。
|
||||||
|
- 树同步完成后只更新节点、父子关系、活动状态和树级同步状态,不自动展开为全部类型属性或字典任务。
|
||||||
|
|
||||||
|
类目树自动同步是唯一固定周期同步。类型属性和属性字典均不再由定时器按 TTL 扫描并自动创建任务。
|
||||||
|
|
||||||
|
### 3.2 类型属性
|
||||||
|
|
||||||
|
类型属性有三种触发来源:
|
||||||
|
|
||||||
|
1. 普通用户读取商品编辑数据时发现缓存为 stale、expired 或 missing。
|
||||||
|
2. 发布任务在校验当前商品类型时发现缓存为 expired 或 missing。
|
||||||
|
3. 系统管理员在后台按所需范围启动一次类型属性同步批次。
|
||||||
|
|
||||||
|
系统管理员不需要逐个类型点击同步。后台提供三个批次入口:
|
||||||
|
|
||||||
|
| 按钮 | 批次快照包含范围 | 不包含范围 |
|
||||||
|
|---|---|---|
|
||||||
|
| 同步未缓存和过期类型属性 | `missing`、`expired`(`age > 过期阈值`) | `fresh`、`stale` |
|
||||||
|
| 同步未缓存和不新鲜类型属性 | `missing`、`stale`、`expired`(`age ≥ 新鲜阈值`,以及从未成功同步) | `fresh` |
|
||||||
|
| 同步全部类型属性 | 全部活动且可发布类型,不考虑缓存年龄 | 无 |
|
||||||
|
|
||||||
|
“同步全部类型属性”必须使用红色危险按钮;点击后弹出二次确认,明确展示将处理的类型数量、任务为低速后台批次且可能持续较长时间。用户确认前不得创建批次或子任务。前两个按钮使用普通按钮,但仍需在创建前展示本次命中的类型数量;命中数量为 0 时禁用操作并提示无需同步。
|
||||||
|
|
||||||
|
任一批次启动后:
|
||||||
|
|
||||||
|
- 固定一份当时活动且可发布的类型快照;后续树同步新增的类型不悄悄加入已经开始的批次。
|
||||||
|
- 创建父批次记录,记录总数、等待数、运行数、成功数、失败数和取消数。
|
||||||
|
- Worker/调度器按限流能力逐步生成或领取类型子任务,不把数千任务一次性全部压入 Redis。
|
||||||
|
- 已有相同 `scope_key` 的 queued/running 任务时直接复用,并计入批次结果。
|
||||||
|
- 批次只处理对应按钮命中的快照范围;“同步全部类型属性”会请求 fresh 类型。每个子任务的优先级仍按第 5 节的资源年龄计算,管理员批次本身不把 fresh/stale 任务提升为高优先级。
|
||||||
|
- 批次失败不回滚其他已成功类型,也不清空任何旧缓存。
|
||||||
|
|
||||||
|
### 3.3 属性字典
|
||||||
|
|
||||||
|
- 不做周期性全平台或全属性自动同步。
|
||||||
|
- 系统后台保留选择某个类型属性后“同步此字典”的单项操作。
|
||||||
|
- 普通用户打开字典控件或搜索候选值时,先读取 Atlas 平台缓存,再按第 4 节状态决定是否创建刷新任务。
|
||||||
|
- 类型属性刷新发现新增字典、`dictionary_id` 变化或属性作用域变化时,只把受影响字典标记为 missing/stale,不自动同步该类型的全部字典。
|
||||||
|
- 相同平台、类型、属性和规范化搜索词的并发补齐请求必须合并,不因用户或门店不同重复请求 Ozon。
|
||||||
|
|
||||||
|
字典的完整性与新鲜度是两个独立维度:
|
||||||
|
|
||||||
|
- `is_complete=true` 表示最近一次完整分页已经结束,可以据此处理 Ozon 已删除的旧值。
|
||||||
|
- `is_complete=false` 表示当前只有部分候选值;已有值仍可按新鲜度使用,但本地搜索未命中不能证明 Ozon 没有该值。
|
||||||
|
- 部分字典的“查询词补齐”作为独立缓存作用域记录成功时间;该查询词从未请求成功时视为 missing,按高优先级处理。搜索命中不会把整个字典改成完整。
|
||||||
|
|
||||||
|
## 4. 新鲜度状态机
|
||||||
|
|
||||||
|
类型属性和属性字典默认都使用:
|
||||||
|
|
||||||
|
- 新鲜时间:14 天;
|
||||||
|
- 过期时间:21 天。
|
||||||
|
|
||||||
|
资源年龄使用 `now - last_success_at` 计算,`last_success_at` 只在 Ozon 请求成功且新缓存事务提交后更新。排队、运行、失败和取消都不能延长新鲜期。全部比较使用 UTC 实时时长,不按自然日零点取整。
|
||||||
|
|
||||||
|
| 状态 | 精确条件 | Atlas 数据能否使用 | Ozon 任务 | 用户读取行为 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `fresh` | `age < 14 天` | 可以 | 不创建 | 立即返回 Atlas 缓存 |
|
||||||
|
| `stale` | `14 天 ≤ age ≤ 21 天` | 可以 | 创建/复用低优先级任务 | 立即返回旧缓存,并标记后台刷新中 |
|
||||||
|
| `expired` | `age > 21 天` | 不作为当前有效 schema/候选值使用 | 创建/复用高优先级任务 | 等待 Worker;成功后返回新缓存 |
|
||||||
|
| `missing` | 从未成功同步,或当前作用域无可用快照 | 不可以 | 创建/复用高优先级任务 | 与 expired 相同 |
|
||||||
|
|
||||||
|
边界规则:恰好 14 天进入 stale;恰好 21 天仍是 stale 和低优先级;超过 21 天才进入 expired 和高优先级。
|
||||||
|
|
||||||
|
失败处理:
|
||||||
|
|
||||||
|
- fresh/stale 的后台刷新失败时保留旧快照;在 21 天内仍可继续使用,并把失败状态提供给管理后台观察。
|
||||||
|
- expired/missing 刷新失败时不能把旧数据伪装为有效结果。API 返回可重试错误和任务状态;已有商品草稿值必须保留,但相关控件和发布校验等待有效 schema/字典后继续。
|
||||||
|
- 更新必须按资源作用域在单个事务中发布。读取方只能看到上一次完整成功快照或本次完整提交后的新快照,不能看到写入一半的数据。
|
||||||
|
|
||||||
|
## 5. Worker 优先级、等待和任务合并
|
||||||
|
|
||||||
|
Ozon 调用仍统一通过 Transactional Outbox 和 Worker:
|
||||||
|
|
||||||
|
| 资源状态 | Worker 优先级 | 是否允许返回旧缓存 | API 等待策略 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| fresh | 不创建任务 | 是 | 不等待 |
|
||||||
|
| stale(≤21 天) | 低 | 是 | 不等待,立即返回缓存 |
|
||||||
|
| expired(>21 天) | 高 | 否 | 最多等待短窗口,未完成则返回操作状态 |
|
||||||
|
| missing | 高 | 否 | 与 expired 相同 |
|
||||||
|
|
||||||
|
“等待 Ozon 数据”不代表 API 自己调用 Ozon。推荐交互:
|
||||||
|
|
||||||
|
1. API 在事务内创建或复用平台级同步 run 和 outbox。
|
||||||
|
2. 高优先级目录队列优先于管理员全量批次和 stale 刷新任务。
|
||||||
|
3. API/前端可等待最多 5 秒;Worker 在窗口内成功时立即重新读取并返回新缓存。
|
||||||
|
4. 5 秒内未完成时返回 `202`、`operation_id` 和 `cache_status=expired|missing`,前端保持加载状态并轮询或等待推送,不能退回门店缓存。
|
||||||
|
5. stale 请求立即返回 `200` 和旧缓存,可同时返回 `refresh_operation_id`,前端无需阻塞。
|
||||||
|
|
||||||
|
每个平台和资源 `scope_key` 同时最多有一个活动任务。低优先级任务已经 queued、随后资源超过 21 天时,将原任务提升为高优先级并复用同一个 run;若任务已经 running,则不启动重复任务,调用方等待同一个任务。高优先级任务只能插队尚未运行的低优先级任务,不能中断正在执行的 Ozon 请求。
|
||||||
|
|
||||||
|
目录交互任务应使用独立的 Worker 并发/限流通道,避免和素材处理等任务争用;高、低优先级共享同一平台级 Ozon 限流器、429 退避、最多尝试次数和审计记录。优先级不能绕过平台限流。
|
||||||
|
|
||||||
|
## 6. 普通用户读取流程
|
||||||
|
|
||||||
|
普通用户不直接操作系统管理 API。产品 API 根据当前平台和平台类目 UUID读取共享目录,并返回缓存元数据。
|
||||||
|
|
||||||
|
### 6.1 类型属性
|
||||||
|
|
||||||
|
```text
|
||||||
|
请求当前商品类型属性
|
||||||
|
↓
|
||||||
|
读取平台级属性快照与 last_success_at
|
||||||
|
↓
|
||||||
|
fresh ───────────────→ 200 最新 Atlas 缓存
|
||||||
|
stale ──→ 低优先级刷新 → 200 旧缓存 + refreshing
|
||||||
|
expired/missing ─────→ 高优先级刷新 → 短暂等待
|
||||||
|
├─ 成功:200 新缓存
|
||||||
|
└─ 未完成:202 operation
|
||||||
|
```
|
||||||
|
|
||||||
|
类型属性刷新只调用当前 `description_category_id + type_id` 的属性接口,不刷新类目树、其他类型或全部字典。刷新后比较属性 ID、复杂属性 ID、必填、SKU 维度、类型、数量、字典 ID 和依赖信息,并只失效受影响的字典缓存。
|
||||||
|
|
||||||
|
### 6.2 属性字典
|
||||||
|
|
||||||
|
1. 打开下拉框时先读取平台级字典缓存。
|
||||||
|
2. fresh 时立即返回;complete 字典的本地未命中直接视为无结果。
|
||||||
|
3. stale 时立即返回旧候选值并创建低优先级刷新任务。
|
||||||
|
4. expired/missing 时创建高优先级任务,更新 Atlas 后才能作为当前候选值返回。
|
||||||
|
5. partial 字典本地搜索未命中时,检查规范化查询词缓存;查询词 missing/expired 时创建高优先级搜索任务,成功结果先写入平台缓存再返回。
|
||||||
|
6. 前端可以先显示 stale/partial 的已有候选值,但不能把“部分缓存未命中”展示成 Ozon 确认不存在。
|
||||||
|
|
||||||
|
浏览器永远不接收 Ozon 原始凭据,也不使用 Ozon 响应作为未落库的临时事实。任何返回给用户的新候选值都必须已经成功写入 Atlas 平台缓存。
|
||||||
|
|
||||||
|
## 7. 发布前校验
|
||||||
|
|
||||||
|
- 发布任务只检查商品当前引用的类型属性,不扫描其他类型。
|
||||||
|
- fresh/stale 类型属性可以用于发布校验;stale 同时创建低优先级刷新,但不阻止本次发布进入后续校验。
|
||||||
|
- expired/missing 类型属性必须先通过高优先级 Worker 刷新,再使用新 schema 校验必填项、scope 和数量限制。
|
||||||
|
- 发布使用到的字典值必须存在于该平台、类型和属性作用域的有效缓存。相关字典 expired/missing 时先刷新对应字典或目标查询作用域,不刷新该类型下所有字典。
|
||||||
|
- Ozon 最终仍可能返回平台校验错误;这类错误按商品/门店记录,必要时将对应平台参考资源标记为需要重新检查,但不能自动生成门店差异层。
|
||||||
|
|
||||||
|
## 8. 系统后台调整
|
||||||
|
|
||||||
|
「平台类目与属性」页面保留现有平台选择、同步来源、类目树、属性和字典浏览能力,并调整为:
|
||||||
|
|
||||||
|
### 8.1 操作区
|
||||||
|
|
||||||
|
- “同步类目树”:立即创建/复用平台树任务。
|
||||||
|
- “同步未缓存和过期类型属性”:只为 missing、expired 类型创建批次。
|
||||||
|
- “同步未缓存和不新鲜类型属性”:只为 missing、stale、expired 类型创建批次。
|
||||||
|
- “同步全部类型属性”:红色危险按钮,为所有活动可发布类型创建批次;二次确认中显示命中数量和预计耗时,确认后才能创建任务。
|
||||||
|
- 类型详情可继续显示单类型最近成功时间和状态;本方案不要求管理员逐个点击才能完成全量属性同步。
|
||||||
|
- 字典 Drawer 保留“同步此字典”,不增加全平台字典批次。
|
||||||
|
|
||||||
|
### 8.2 新鲜度设置
|
||||||
|
|
||||||
|
按平台配置以下四个整数天数:
|
||||||
|
|
||||||
|
- 类型属性新鲜天数,默认 14;
|
||||||
|
- 类型属性过期天数,默认 21;
|
||||||
|
- 属性字典新鲜天数,默认 14;
|
||||||
|
- 属性字典过期天数,默认 21。
|
||||||
|
|
||||||
|
约束:取值建议为 `1..365`,且同一资源的过期天数必须大于新鲜天数。类目树的每周周期首期固定为 7 天,不放入该配置区。
|
||||||
|
|
||||||
|
设置保存后立即用于后续读取判断,但不主动扫描并创建数千任务。例如把过期天数从 30 改为 21 后,只有下次用户读取、发布校验或管理员批次触达该资源时才创建任务。修改必须记录操作者、修改时间和前后值。
|
||||||
|
|
||||||
|
### 8.3 状态展示
|
||||||
|
|
||||||
|
管理页至少展示:
|
||||||
|
|
||||||
|
- 类目树最近成功时间和下次计划时间;
|
||||||
|
- 类型属性批次的总数、等待、运行、成功、失败和完成百分比;
|
||||||
|
- 当前类型属性的 `fresh/stale/expired/missing`、最近成功时间和最近错误;
|
||||||
|
- 字典的相同新鲜度状态、完整/部分状态、候选值数量和最近错误;
|
||||||
|
- 高低优先级、触发来源(weekly/manual/admin_batch/user_access/publish)和任务复用关系。
|
||||||
|
|
||||||
|
## 9. 建议的数据与契约调整
|
||||||
|
|
||||||
|
确认后至少需要补充以下模型,具体 DDL 和字段名在实现阶段冻结:
|
||||||
|
|
||||||
|
1. 平台同步策略:保存属性、字典的新鲜和过期天数以及审计字段。
|
||||||
|
2. 资源同步状态:按 tree、类型属性、属性字典和字典搜索作用域保存 `last_success_at`、`is_complete`、最新任务及错误;不能仅依赖数据行的 `updated_at` 推断是否成功检查过。
|
||||||
|
3. 属性批次:保存管理员批次快照和聚合进度,子任务继续使用既有平台 run/outbox 事实。
|
||||||
|
4. 任务优先级与触发来源:区分 high/low 及 weekly、manual、admin_batch、user_access、publish。
|
||||||
|
5. 商品编辑引用:从门店类目 UUID 迁移为平台类目 UUID;属性和值仍按当前商品/SKU 草稿保存,但校验引用平台 schema。
|
||||||
|
|
||||||
|
普通产品读取响应建议统一带:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [],
|
||||||
|
"cache": {
|
||||||
|
"status": "fresh",
|
||||||
|
"last_success_at": "2026-09-20T00:00:00Z",
|
||||||
|
"fresh_until": "2026-10-04T00:00:00Z",
|
||||||
|
"expires_at": "2026-10-11T00:00:00Z",
|
||||||
|
"is_complete": true,
|
||||||
|
"refresh_run_id": null,
|
||||||
|
"refresh_status": null,
|
||||||
|
"retry_after_ms": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
管理 API 需要增加同步策略读取/修改和类型属性批次创建/读取;现有单项 `POST .../syncs` 保留给树和字典。最终端点、状态码和错误码必须在实现前更新 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md) 与 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)。
|
||||||
|
|
||||||
|
## 10. 实施顺序与验收
|
||||||
|
|
||||||
|
建议分四步实施,避免继续扩大门店级缓存:
|
||||||
|
|
||||||
|
1. **平台状态与调度**:增加策略、资源状态、批次和优先级;把树改为每周自动同步,关闭属性/字典周期自动扫描。
|
||||||
|
2. **后台能力**:加入新鲜度设置、三种范围的类型属性批次入口及统一批次进度,保留树及单字典手动同步。
|
||||||
|
3. **商品编辑迁移**:通过 Ozon 外部 ID 回填平台类目引用,切换属性和字典读取、按需刷新与任务等待;保留兼容回滚路径。
|
||||||
|
4. **发布迁移**:发布前使用平台 schema/字典状态执行定向高优先级刷新和最终校验,验证稳定后再讨论清理门店级目录表。已通过 `POST .../publish-preflights` 落地;未来 `publish-jobs` 必须先调用相同服务,不能自行绕过。
|
||||||
|
|
||||||
|
当前完成度:第 1~4 步已完成。第 3 步由 `027_product_editor_platform_catalog.sql` 回填平台类目引用并保留旧门店列;编辑器返回统一 cache 元数据,stale 创建低优先级任务并立即返回,expired/missing 创建高优先级任务、最多等待 5 秒后返回 `202`;部分字典的未命中关键词使用独立 `dictionary_search` 任务。第 4 步新增首次发布预检:只接受所选未导入 SKU,先刷新当前 schema,再从已确认草稿识别实际使用的字典;所有 high 任务共用 5 秒窗口,stale 继续 low 后台刷新,最终校验同时检查字典缓存有效期和候选值仍有效。
|
||||||
|
|
||||||
|
验收至少覆盖:
|
||||||
|
|
||||||
|
- 同平台不同门店读取相同类目、属性和字典记录,不产生门店副本。
|
||||||
|
- 13 天 23:59:59、恰好 14 天、恰好 21 天和超过 21 天四个边界行为正确。
|
||||||
|
- stale 立即返回旧缓存并只创建一个低优先级任务;expired/missing 复用一个高优先级任务并等待新缓存。
|
||||||
|
- queued 低优先级任务可提升,高并发用户不会重复调用 Ozon。
|
||||||
|
- 每周树任务、管理员树任务和来源变更任务正确去重。
|
||||||
|
- 三种类型属性批次分别准确覆盖 missing+expired、missing+stale+expired、全部活动类型;支持部分失败和准确进度,不自动创建字典任务。
|
||||||
|
- “同步全部类型属性”在二次确认前不产生批次、run 或 outbox,另外两个按钮在无命中类型时不能创建空批次。
|
||||||
|
- 字典完整/部分状态与查询词补齐不混淆;只有完整同步才能停用消失的旧值。
|
||||||
|
- 调整策略后不立即制造全量任务,下一次访问按新阈值生效。
|
||||||
|
- Ozon 失败、超时、429 和 Worker 重启均保留上次成功缓存、任务审计与可恢复状态。
|
||||||
|
|
||||||
|
## 11. 已确认的产品规则
|
||||||
|
|
||||||
|
1. 管理员属性批次有三种范围:missing+expired、missing+stale+expired、全部活动类型;全量批次会刷新 fresh 类型,并使用红色按钮和二次确认。
|
||||||
|
2. `age = 21 天` 仍属于 stale/低优先级;只有严格大于 21 天才属于 expired/高优先级。
|
||||||
|
3. missing 包括从未同步成功的整个资源,以及 partial 字典中从未成功补齐的具体查询作用域,均按高优先级处理。
|
||||||
|
4. expired 数据可以用于保留和展示已有草稿值,但不能作为当前可选 schema/候选值或发布校验事实。
|
||||||
|
5. 类目树周期首期固定 7 天;后台只配置类型属性和属性字典的两组新鲜/过期阈值。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 平台级商品参考目录的归属与维护决策
|
# 平台级商品参考目录的归属与维护决策
|
||||||
|
|
||||||
> 决策日期:2026-09-18。状态:平台参考目录、系统管理员维护 API/页面及定时同步链路已经实现;商品编辑器的门店级引用尚未迁移。当前商品编辑契约仍以 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 为准。本决策取代 2026-09-10「每个门店完整保存一份类目、属性和字典」的目标设计,不抹去其实现历史。
|
> 决策日期:2026-09-18。状态:平台参考目录、系统管理员维护 API/页面、同步链路、商品编辑器平台级读取及首次发布预检已实现;真正的 Ozon 发布 job 尚未开发。当前商品编辑契约以 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 为准。本决策取代 2026-09-10「每个门店完整保存一份类目、属性和字典」的目标设计,不抹去其实现历史。
|
||||||
|
|
||||||
## 1. 依据与边界
|
## 1. 依据与边界
|
||||||
|
|
||||||
@@ -19,14 +19,14 @@ Atlas 本阶段明确采用**平台级参考数据**:同一 `platform_id` 下
|
|||||||
|
|
||||||
平台参考表以 `platform_id` 隔离 Ozon、未来的 Wildberries 等平台,只保存对应平台可复用的参考元数据;不存客户凭据、商品、价格或店铺专有数据,也不使用 `store_id` 作为目录分区键。所有门店连接和商品引用仍须满足 [`development-collaboration-contract.md`](../project/development-collaboration-contract.md) 的工作空间外键与 RLS 约束。读 API 依页头平台上下文过滤,不能跨平台混用 ID,也不能据此枚举其他店铺信息。
|
平台参考表以 `platform_id` 隔离 Ozon、未来的 Wildberries 等平台,只保存对应平台可复用的参考元数据;不存客户凭据、商品、价格或店铺专有数据,也不使用 `store_id` 作为目录分区键。所有门店连接和商品引用仍须满足 [`development-collaboration-contract.md`](../project/development-collaboration-contract.md) 的工作空间外键与 RLS 约束。读 API 依页头平台上下文过滤,不能跨平台混用 ID,也不能据此枚举其他店铺信息。
|
||||||
|
|
||||||
目标交互上,切换到**同一平台**的另一个门店不应清空已选类目和属性;切换平台则必须重新选择该平台的类目并按新平台 schema 校验。新门店凭据验证只处理该门店连接,不为其复制或重新同步整套平台目录;若平台目录尚未准备好,由平台级任务补齐。当前门店级外键尚未迁移前仍维持现有切换行为。
|
切换到**同一平台**的另一个门店不会清空已选类目和属性;切换平台则必须重新选择该平台的类目并按新平台 schema 校验。新门店凭据验证只处理该门店连接,不为其复制或重新同步整套平台目录;若平台目录尚未准备好,由平台级任务补齐。
|
||||||
|
|
||||||
## 3. 同步方案:自动为主、人工为辅
|
## 3. 同步方案:当前实现与已确认目标
|
||||||
|
|
||||||
统一的后台同步服务按 `platform_id` 负责定时任务、首次缺失时的补齐和管理员手动请求。三种触发方式都创建有权限校验、幂等去重的 `platform_catalog_sync_runs + platform_catalog_outbox_events`;这是不带 `workspace_id` 的全局任务事实,不能借用租户 `operations/outbox_events`。Worker 通过平台适配器读取服务端安全存储的参考源凭据并调用对应平台。浏览器、日志、run 和 outbox 载荷不包含 API Key。管理员手动同步不得在 HTTP 请求内直接访问平台或绕过同一套限流、审计与事务发布流程。
|
统一的后台同步服务按 `platform_id` 负责定时任务、首次缺失时的补齐和管理员手动请求。三种触发方式都创建有权限校验、幂等去重的 `platform_catalog_sync_runs + platform_catalog_outbox_events`;这是不带 `workspace_id` 的全局任务事实,不能借用租户 `operations/outbox_events`。Worker 通过平台适配器读取服务端安全存储的参考源凭据并调用对应平台。浏览器、日志、run 和 outbox 载荷不包含 API Key。管理员手动同步不得在 HTTP 请求内直接访问平台或绕过同一套限流、审计与事务发布流程。
|
||||||
|
|
||||||
1. **同步来源**:每个平台分别配置 Atlas 专用的参考源凭据及适配器;Ozon 首期使用专用参考门店凭据作为技术采集身份,而非普通客户工作空间门店。设置最小授权、密钥轮换、失效告警与备用源,不能未经授权借用客户门店密钥。若暂无专用凭据,保持现有门店级链路,待来源配置完成后迁移。
|
1. **同步来源**:每个平台分别配置 Atlas 专用的参考源凭据及适配器;Ozon 首期使用专用参考门店凭据作为技术采集身份,而非普通客户工作空间门店。设置最小授权、密钥轮换、失效告警与备用源,不能未经授权借用客户门店密钥。若暂无专用凭据,保持现有门店级链路,待来源配置完成后迁移。
|
||||||
2. **基线调度**:当前实现为类目树和类型属性成功后 24 小时内不重复、字典成功后 7 天内不重复;调度器每 60 秒扫描,每个平台最多 6 个活动任务。BullMQ 最多尝试 5 次并从 5 秒开始指数退避。当前固定按树、属性、字典排序,在数千类型下会造成字典饥饿;后续必须改为属性/字典配额轮转,并增加近期访问优先级。具体间隔是 Atlas 运营参数,不是 Ozon 文档承诺的 TTL。
|
2. **基线调度(当前代码事实)**:类目树最近成功时间达到 7 天后由调度器创建低优先级周任务;管理员仍可手动创建高优先级树任务。类型属性和字典已经退出周期自动候选,类型属性只从管理员显式创建的三种批次中逐批领取,字典只保留单项手动入口。普通商品读路径已按 14/21 天执行 fresh/stale/expired/missing 状态机,并创建或复用 user_access 平台任务。
|
||||||
3. **管理员页面**:按平台展示参考源状态、树、属性、字典候选值、最后成功时间、失败详情、排队任务和「同步类目树 / 指定类目属性 / 指定字典」操作。手动动作必须受系统管理员权限控制、记录操作者,并返回异步任务;页面不展示密钥明文。当前实现位于 `/system/platform-catalog`,全局系统管理员授权独立于工作空间角色。
|
3. **管理员页面**:按平台展示参考源状态、树、属性、字典候选值、最后成功时间、失败详情、排队任务和「同步类目树 / 指定类目属性 / 指定字典」操作。手动动作必须受系统管理员权限控制、记录操作者,并返回异步任务;页面不展示密钥明文。当前实现位于 `/system/platform-catalog`,全局系统管理员授权独立于工作空间角色。
|
||||||
4. **读路径**:正常用户只读 Atlas 缓存。首次缺失时可排队补齐并显示准备中;已有数据过期时优先提供上次成功快照并后台更新。不能在每次打开编辑器或下拉框时同步请求 Ozon。大字典搜索按规范化关键词及上述字典作用域共享缓存,并去重并发补齐。
|
4. **读路径**:正常用户只读 Atlas 缓存。首次缺失时可排队补齐并显示准备中;已有数据过期时优先提供上次成功快照并后台更新。不能在每次打开编辑器或下拉框时同步请求 Ozon。大字典搜索按规范化关键词及上述字典作用域共享缓存,并去重并发补齐。
|
||||||
5. **原子性与监控**:P0 按平台及树、类型属性、字典作用域在单事务内 upsert,提交前读取方继续看到旧数据;失败保留上次成功缓存。仅在确认完整分页完成后才处理消失的字典值,部分同步记录 `is_complete=false`。当前尚未实现不可变版本快照、内容哈希、`next_refresh_at`、连续失败告警和异常删减保护,这些属于上线前可靠性加固,不能在文档中误记为已完成。
|
5. **原子性与监控**:P0 按平台及树、类型属性、字典作用域在单事务内 upsert,提交前读取方继续看到旧数据;失败保留上次成功缓存。仅在确认完整分页完成后才处理消失的字典值,部分同步记录 `is_complete=false`。当前尚未实现不可变版本快照、内容哈希、`next_refresh_at`、连续失败告警和异常删减保护,这些属于上线前可靠性加固,不能在文档中误记为已完成。
|
||||||
@@ -41,6 +41,6 @@ Ozon 字典分页每次最多 2000 个值;[搜索接口](https://docs.ozon.ru/
|
|||||||
|
|
||||||
## 5. 从现有实现迁移
|
## 5. 从现有实现迁移
|
||||||
|
|
||||||
当前 `022_store_catalog_cache.sql` 的 `store_product_categories`、属性和字典表,以及 profile 的 `store_product_category_id` 外键都是已上线的门店级事实,**本次文档决策不改变它们**。旧 `001_foundation.sql` / `021_product_category_attribute_schema.sql` 虽已有平台级表,但原字典唯一键不足以表达按类目/类型变化的字典;不能直接当成最终系统目录复用。
|
`022_store_catalog_cache.sql` 的门店级表和 profile 的 `store_product_category_id` 仍保留为兼容/回滚数据,但商品编辑器不再读取它们。当前事实来源是平台级类目、属性和 `platform_catalog_dictionary_values`。
|
||||||
|
|
||||||
迁移 `024_platform_catalog_management.sql` 已补齐以 `platform_id` 为作用域的参考来源、候选值和同步任务表,**没有建立门店差异表**;API、Worker 和管理页面已经按该作用域工作。下一步仍需以 Ozon 外部 ID 对照旧门店记录、回填平台目录。草稿/接口迁移应先提供兼容读写与可回滚映射,逐步改为平台目录引用,最后经校验和维护者批准再清理冗余门店缓存。现有商品编辑 HTTP 路径、UUID 含义和门店同步 operation 尚未改变;任何契约变更须先更新 `docs/api/`、共享类型和测试。Wildberries 等新平台应新增平台适配器及自己的外部 ID 映射,不复用 Ozon 的 `description_category_id + type_id` 语义。
|
迁移 `024`~`026` 建立平台参考来源、候选值、同步任务、策略和资源状态;`027_product_editor_platform_catalog.sql` 以 Ozon 外部类目/类型 ID 回填商品的 `platform_product_category_id`,并为部分字典增加关键词搜索任务作用域。旧门店列和表暂不删除,新保存会尽量反写可映射的门店类目 UUID,供回滚使用。清理旧表必须在发布链路迁移、稳定性验证和维护者批准后另行执行。Wildberries 等新平台应新增平台适配器及自己的外部 ID 映射,不复用 Ozon 的 `description_category_id + type_id` 语义。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 1. 目标与原则
|
## 1. 目标与原则
|
||||||
|
|
||||||
Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的类目 schema 决定;目标架构中,同一 Ozon 平台的所有门店共用该 schema。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。目标参考数据归属见[架构决策](../architecture/platform-product-reference-catalog.md);当前实现仍读门店级缓存。
|
Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的类目 schema 决定;同一 Ozon 平台的所有门店共用该 schema。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。当前编辑器已经读取平台级类目、属性和字典缓存;发布链路迁移仍待后续步骤。
|
||||||
|
|
||||||
页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
|
页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父
|
|||||||
3. 同步成功后重新构建动态属性表单。
|
3. 同步成功后重新构建动态属性表单。
|
||||||
4. 页面显示类目路径、商品类型和最后同步时间,并提供失败后的重试入口。
|
4. 页面显示类目路径、商品类型和最后同步时间,并提供失败后的重试入口。
|
||||||
|
|
||||||
以上描述当前门店级 schema 的获取流程。迁移到平台级参考目录后,直接读取当前平台共享 schema,不叠加门店差异;缺失或过期时由统一后台任务补齐,普通用户不直接触发 Ozon 请求,管理员重试与定时任务共用同一队列。接口字段及草稿外键在 `docs/api/` 更新前不改变。
|
以上描述当前门店级 schema 的获取流程。迁移到平台级参考目录后,直接读取当前平台共享 schema,不叠加门店差异;普通用户只请求 Atlas,Atlas 在缓存 stale、expired 或 missing 时创建/复用统一 Worker 任务,不由 API 进程直连 Ozon。14~21 天的旧缓存立即返回并低优先级刷新,严格超过 21 天或未缓存时等待高优先级任务;完整规则见[平台目录缓存刷新方案](../architecture/platform-catalog-cache-refresh-plan.md)。接口字段及草稿外键在 `docs/api/` 更新前不改变。
|
||||||
|
|
||||||
属性 schema 响应除现有字段外,计划补充:
|
属性 schema 响应除现有字段外,计划补充:
|
||||||
|
|
||||||
|
|||||||
+62
-6
@@ -293,7 +293,7 @@ M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目
|
|||||||
|
|
||||||
- 类目/商品类型树、类型属性定义和字典候选值归属 `platform_id`,同一平台的全部门店共用,不再按门店复制。Ozon 与未来 Wildberries 等平台分别使用自己的适配器和外部 ID 语义;本阶段**不创建、不读取门店差异层**。
|
- 类目/商品类型树、类型属性定义和字典候选值归属 `platform_id`,同一平台的全部门店共用,不再按门店复制。Ozon 与未来 Wildberries 等平台分别使用自己的适配器和外部 ID 语义;本阶段**不创建、不读取门店差异层**。
|
||||||
- 门店连接、卖家凭据、商品/SKU 已填属性、发布状态和错误仍是工作空间业务数据。平台参考目录不保存客户商品、价格、库存或发布事实。
|
- 门店连接、卖家凭据、商品/SKU 已填属性、发布状态和错误仍是工作空间业务数据。平台参考目录不保存客户商品、价格、库存或发布事实。
|
||||||
- 这项平台级能力已经独立落地,但商品编辑器仍使用 `022_store_catalog_cache.sql` 的门店级类目 UUID、属性和字典 API。不得直接删除旧表或把新旧 UUID 混用;编辑器迁移必须通过 Ozon 外部 ID 做双读/回填后再切换。
|
- 商品编辑器已由迁移 `027` 和新版 API 切到平台级类目 UUID、属性和字典缓存。`022_store_catalog_cache.sql` 的门店表与列暂时保留为兼容/回滚数据,不得在发布链路迁移完成前删除。
|
||||||
|
|
||||||
#### 1.33.2 类目树、属性和字典的标识规则
|
#### 1.33.2 类目树、属性和字典的标识规则
|
||||||
|
|
||||||
@@ -357,12 +357,68 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
|||||||
|
|
||||||
1. 调整定时调度公平性:树之后不能等待全部类型属性完成才开始字典;应在属性和字典之间设置配额/轮转,并优先近期使用或管理员选中的类型。
|
1. 调整定时调度公平性:树之后不能等待全部类型属性完成才开始字典;应在属性和字典之间设置配额/轮转,并优先近期使用或管理员选中的类型。
|
||||||
2. 为超过 10000 项的大字典实现可续传游标或分片同步周期。当前任务会正确标记 `is_complete=false`,但下一次仍从头读取,不能持续推进到第 10001 项以后。
|
2. 为超过 10000 项的大字典实现可续传游标或分片同步周期。当前任务会正确标记 `is_complete=false`,但下一次仍从头读取,不能持续推进到第 10001 项以后。
|
||||||
3. 将商品编辑器由门店级缓存迁移到平台级目录:按 `description_category_id + type_id` 和属性 ID 回填,先双读/兼容保存,再切换外键和 API,最后才讨论清理 `022` 表。
|
3. ~~将商品编辑器由门店级缓存迁移到平台级目录。~~ 已由第 3 步完成;后续只在发布迁移和稳定性验证后讨论清理 `022` 表。
|
||||||
4. 增加平台目录 PostgreSQL 集成测试,覆盖系统管理员隔离、凭据版本替换、手动幂等复用、定时去重、失败保留旧缓存、完整字典停用旧值及跨平台 ID 拒绝。
|
4. 增加平台目录 PostgreSQL 集成测试,覆盖系统管理员隔离、凭据版本替换、手动幂等复用、定时去重、失败保留旧缓存、完整字典停用旧值及跨平台 ID 拒绝。
|
||||||
5. 在浏览器完成管理员菜单、直达路由、凭据弹窗、任务轮询、类目/属性/字典展示的回归,并确认 API/Worker 进程都使用迁移后的当前代码。
|
5. 在浏览器完成管理员菜单、直达路由、凭据弹窗、任务轮询、类目/属性/字典展示的回归,并确认 API/Worker 进程都使用迁移后的当前代码。
|
||||||
|
|
||||||
完整架构边界见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md),HTTP 契约见 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md)。
|
完整架构边界见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md),HTTP 契约见 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md)。
|
||||||
|
|
||||||
|
### 1.34 开发进度与交接(2026-09-20):平台目录刷新方案第 1 步
|
||||||
|
|
||||||
|
第 1 步后端基础已完成,尚未开始系统后台页面、商品编辑器和发布链路迁移:
|
||||||
|
|
||||||
|
- 新增并实际应用 `025_platform_catalog_refresh_policy.sql`、`026_platform_catalog_refresh_policy_audit.sql`:按平台保存类型属性/字典的 14/21 天默认策略及不可变的阈值修改前后值;按 tree、attributes、dictionary、dictionary_search 保存独立资源状态;为同步 run 增加 high/low 优先级和提升时间;增加管理员属性批次及固定类型快照。没有创建门店级新表,也没有删除旧缓存。
|
||||||
|
- 迁移从历史成功 run 回填资源状态。应用后实际回读:Ozon 共有 7365 个活动可发布类型,其中 687 个类型属性 fresh、6678 个 missing,stale/expired 均为 0;另回填 1 个树状态和 1 个字典状态。默认策略已为所有现有平台写入 14/21 天。
|
||||||
|
- 平台目录调度器已改为只自动维护每周类目树。类型属性和字典不再进入周期自动候选;属性只从管理员批次中每个平台最多 6 个活动任务地逐步领取,字典保留单项任务。管理员批次支持 `missing_expired`、`missing_stale_expired`、`all` 三种冻结范围。
|
||||||
|
- 新增刷新策略、属性状态汇总、批次创建/列表/详情管理 API。相同平台一次只允许一个活动属性批次;空批次返回明确错误;批次允许部分失败。现有树/单属性/单字典接口保持兼容。
|
||||||
|
- 平台 run 和独立 BullMQ 队列 `atlas-platform-catalog` 已支持 high/low 优先级;missing/严格超过过期阈值为 high,恰好 21 天仍为 low。低优先级 queued 任务可提升并复用同一 run,不能打断已经 running 的请求;旧 `atlas-dispatch` 暂时保留消费历史平台任务的兼容分支。
|
||||||
|
- 平台同步成功会在同一发布事务更新 `last_success_at/is_complete` 并清除错误;失败只记录资源错误,不延长新鲜期或清空上次成功缓存。字典管理读取已改用资源状态判断完整性。
|
||||||
|
- 已通过 API/Worker/Database TypeScript 检查、5 项系统管理员参数单元测试和 9 项 Worker 测试;迁移已在真实 PostgreSQL 应用,并用回滚事务验证批次/平台外键。`git diff --check` 通过。
|
||||||
|
|
||||||
|
运行态提醒:维护者管理的 API/Worker 进程不会由 AI 重启。若当前 Worker 是修改前启动的旧进程,它仍会执行旧的 24 小时属性候选;需要维护者在检查代码后自行重启 API 和 Worker,新的每周树/无属性字典周期调度才会生效。
|
||||||
|
|
||||||
|
第 1 步已经用户确认,随后完成的第 2 步见下一节;此前已有的类目树固定搜索框和展开修复已保留。
|
||||||
|
|
||||||
|
### 1.35 开发进度与交接(2026-09-20):平台目录刷新方案第 2 步
|
||||||
|
|
||||||
|
第 2 步系统后台页面已完成,尚未开始商品编辑器和发布链路迁移:
|
||||||
|
|
||||||
|
- 「平台类目与属性」页已接入平台刷新策略,可配置类型属性和属性字典各自的新鲜/过期天数;类目树固定 7 天。表单执行 `1..365` 和“过期严格大于新鲜”校验,保存说明明确不会立即创建全量任务。
|
||||||
|
- 缓存新鲜度表单现已从主页面移入弹窗;操作区通过“缓存新鲜度设置”按钮打开。保存成功后关闭弹窗并刷新策略、类目及当前属性状态,保存失败时保留弹窗和用户输入。
|
||||||
|
- “最近同步任务”的手动刷新按钮现显示请求中的 loading 状态;刷新成功提示“同步任务刷新成功”,失败使用统一 API 错误消息。后台每 5 秒静默轮询仍保持无提示,避免周期性打扰管理员。
|
||||||
|
- 类型属性区展示 fresh/stale/expired/missing 汇总以及最近批次。三个操作分别覆盖 missing+expired、missing+stale+expired、全部类型;前两个显示命中数并在 0 时禁用,“同步全部类型属性”为红色危险按钮,必须在含类型数量和耗时提示的二次确认后才请求 API。
|
||||||
|
- 批次表展示完成比例、成功/运行/等待/失败数量与最终状态;同平台存在活动批次时禁用新建。页面每 5 秒静默刷新任务和批次,不让操作按钮周期性闪烁加载态。
|
||||||
|
- 类目树保留此前完成的固定搜索框和手动展开修复,并在可发布叶子展示类型属性缓存状态。选择类型后,属性卡标题重复显示类型状态;字典属性在表格和候选值 Drawer 中展示独立缓存状态、最近成功时间、完整/部分状态及候选值数量,仍保留单字典手动同步。
|
||||||
|
- 最近任务表新增 high/low 优先级以及 weekly、admin_batch、user_access、publish 等触发来源文案。页面主文件只协调状态和请求;刷新策略、属性批次、缓存标签、任务表均拆为独立组件。
|
||||||
|
- API 的类目和属性读模型补充最近资源错误及字典新鲜度字段,用于后台观测;没有改变普通商品编辑 API。
|
||||||
|
- 浏览器实测:页面无横向溢出;类目搜索可自动展开到匹配叶子;missing/fresh 类型状态正确;fresh 类型属性表和 missing 字典 Drawer 正常;全量同步仅打开危险确认层,验收过程没有创建批次或同步任务。
|
||||||
|
|
||||||
|
验证:Web TypeScript 与 production build、API TypeScript/build 和 5 项管理 API 单元测试通过,`git diff --check` 通过。页面在维护者现有 API 上完成浏览器回归;Vite 验收服务使用 5174 并在验收后关闭。API/Worker 未由 AI 启动、重启或停止。
|
||||||
|
|
||||||
|
第 2 步已验收,第 3 步完成情况见下一节;第 4 步也已在用户确认后完成,见 1.37。
|
||||||
|
|
||||||
|
### 1.36 开发进度与交接(2026-09-20):平台目录刷新方案第 3 步
|
||||||
|
|
||||||
|
- 新增并实际应用 `027_product_editor_platform_catalog.sql`。迁移按 `platform_id + description_category_id + type_id` 将既有 `store_product_category_id` 回填到 `platform_product_category_id`,同步回填未映射属性的原平台类目;旧门店列和三张门店缓存表继续保留,新保存会尽量反写对应门店类目 UUID,便于回滚。
|
||||||
|
- 商品编辑器类目树、类型属性和字典全部改读平台级表。切换同平台目标店铺不再清空类目;动态属性 schema 返回 `attribute_id`,字典 API 用该平台属性 UUID 限定候选值,不再只按可能重复的 `dictionary_id` 定位。
|
||||||
|
- 普通读取已实现 14/21 天状态机:fresh 直接返回;stale 返回旧缓存并创建/复用 low `user_access` run;expired/missing 创建/复用 high run,API 最多等待 5 秒,未完成则返回 HTTP `202`、空当前选项和 cache 元数据。前端最多轮询 20 次,过期旧值不进入当前控件。
|
||||||
|
- 平台级部分字典的本地未命中关键词现在使用独立 `dictionary_search:{attribute_id}:{hash}` 作用域。`platform_catalog_sync_runs` 保存规范化 `search_key`;Worker 调 Ozon 搜索接口,命中值先写入 Atlas,再更新该词的成功状态,不会把局部搜索误标为完整字典。
|
||||||
|
- 草稿保存和本地 validation 已改用平台 schema/字典。expired/missing schema 下,只允许原样保留已有属性并保存其他商品字段;类目变化或属性改写会返回刷新中错误。expired/missing 字典不能校验新候选值,旧草稿值仍可保留。
|
||||||
|
- 产品 API 契约更新为 `platform_product_category_id`,过渡期仍接受旧 `store_product_category_id` 并通过外部 ID 映射。属性/字典响应统一返回 `cache.status`、最近成功时间、fresh/expire 时间、完整性、刷新 run 和重试间隔。
|
||||||
|
- 验证已通过:contracts/API/Worker/Web TypeScript;`product-editor.e2e.test.ts` 与 `platform-catalog-admin.unit.test.ts` 共 13 项;迁移在本地 PostgreSQL 成功执行;`027` 约束和旧引用回填已实际验证。API/Worker 未由 AI 启动、重启或停止。
|
||||||
|
|
||||||
|
第 4 步首次发布预检已完成,见下一节。旧门店目录表暂不清理;需要等待真正的 Ozon 发布 job 复用预检服务并完成稳定性验证后另行决策。
|
||||||
|
|
||||||
|
### 1.37 开发进度与交接(2026-09-20):平台目录刷新方案第 4 步
|
||||||
|
|
||||||
|
- 新增 `POST /api/product-platform-profiles/{profile_id}/publish-preflights`。请求明确给出本次待首次上架的 `variant_ids`;只接受当前 profile 下 `not_listed` / `failed` 的未删除 SKU,拒绝 imported、submitting、跨商品和已删除 SKU。
|
||||||
|
- 发布预检先检查当前 `platform_product_category_id` 的 schema。fresh 直接使用;stale 使用缓存并创建/复用 low `publish` 任务;expired/missing 创建/复用 high `publish` 任务。低优先级 queued 任务在资源过期后复用原 run 并提升为 high。
|
||||||
|
- schema 可用后,只从本次 SKU 集合的已确认商品/SKU 属性草稿提取实际使用的字典。未填写的可选字典、其他 SKU 和同类型下未使用字典都不会创建任务;不扫描其他类型,也不使用旧门店目录。
|
||||||
|
- 多个 high schema/字典任务共用一个最多 5 秒等待窗口。schema 刷新成功后会基于新 schema 再计算字典依赖;窗口内未完成返回 HTTP `202` 和 `status=refreshing`,客户端可重试同一预检,不能退回过期缓存。
|
||||||
|
- 最终本地校验除标题、型号、品牌、必填属性、offer ID、价格、包装和图片外,新增“所用字典缓存未过期且候选 ID 仍为活动值”检查。stale 字典可以完成本次发布校验并在后台刷新;expired/missing 会返回刷新中问题。
|
||||||
|
- 预检响应包含 schema 与每个已用字典的统一 cache 元数据,以及本次创建/复用的目录 runs。真正的 `product_publish_job`、发布快照、Ozon `/v3/product/import` 调用和轮询仍未实现;预检 ready 不能解释为已提交或已发布。
|
||||||
|
- 代码按目录编排、最终校验、HTTP 入口拆分在独立组件中,没有把逻辑继续堆入 `product-editor.service.ts`。API 集成测试覆盖 stale 的 low publish 任务、expired 的 high 提升、只刷新已用字典和不触达未用字典。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
@@ -381,7 +437,7 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
|||||||
| 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 |
|
| 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 |
|
||||||
| 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 |
|
| 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 |
|
||||||
| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 |
|
| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 |
|
||||||
| 平台商品参考目录 | `platform_id` 下共享类目/类型树、属性定义和字典候选值;同平台门店共用,本阶段不维护门店差异层。定时同步、系统管理员页面和手动排队已实现;商品编辑器的门店级引用待迁移,见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md)。 |
|
| 平台商品参考目录 | `platform_id` 下共享类目/类型树、属性定义和字典候选值;同平台门店共用,本阶段不维护门店差异层。定时同步、系统管理员页面、手动排队、商品编辑器读取和发布预检已实现;真正的 Ozon 发布 job 待开发。 |
|
||||||
|
|
||||||
## 3. Ozon 已核对结论
|
## 3. Ozon 已核对结论
|
||||||
|
|
||||||
@@ -424,12 +480,12 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
下一步建议按以下顺序推进:
|
下一步建议按以下顺序推进:
|
||||||
|
|
||||||
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台和同步基础链路已经实现;现有门店级商品编辑契约在完成兼容迁移前继续生效。
|
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台、同步基础链路和商品编辑器迁移已经实现;旧门店目录只作为兼容/回滚数据保留。
|
||||||
|
|
||||||
当前执行顺序:
|
当前执行顺序:
|
||||||
|
|
||||||
1. 完成平台目录首次预热并修正属性/字典调度公平性和大字典续传;补 PostgreSQL 集成测试与系统管理员浏览器回归。
|
1. 将真正的 `publish-jobs` 创建事务接入已经完成的发布预检服务,固化发布快照后再实现 Ozon `/v3/product/import` 与结果轮询;不得重新实现另一套目录判断。
|
||||||
2. 按 Ozon 外部 ID 建立新旧目录映射和双读,迁移商品编辑器到平台级类目、属性和候选值;在验证前保留门店级缓存与外键。
|
2. 完成平台目录首次预热并修正大字典续传;补系统管理员浏览器回归。旧门店目录继续保留,暂不清理。
|
||||||
3. 在真实采集来源中继续回归候选媒体按需物化,然后完成定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
|
3. 在真实采集来源中继续回归候选媒体按需物化,然后完成定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
|
||||||
|
|
||||||
### 历史计划(仅供追溯)
|
### 历史计划(仅供追溯)
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export type ProductEditorCategoryOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ProductAttributeSchema = {
|
export type ProductAttributeSchema = {
|
||||||
|
/** Atlas 平台属性定义 UUID,用于精确限定同一类型下的字典作用域。 */
|
||||||
|
attribute_id: string;
|
||||||
platform_attribute_id: string;
|
platform_attribute_id: string;
|
||||||
complex_id: string;
|
complex_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,11 +77,46 @@ export type ProductAttributeSchema = {
|
|||||||
| null;
|
| null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductCatalogCacheStatus =
|
||||||
|
'fresh' | 'stale' | 'expired' | 'missing';
|
||||||
|
|
||||||
|
export type ProductCatalogCacheMetadata = {
|
||||||
|
status: ProductCatalogCacheStatus;
|
||||||
|
last_success_at: string | null;
|
||||||
|
fresh_until: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
is_complete: boolean;
|
||||||
|
refresh_run_id: string | null;
|
||||||
|
refresh_status: string | null;
|
||||||
|
retry_after_ms: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductCatalogRefreshRun = {
|
||||||
|
id: string;
|
||||||
|
platform_id: string;
|
||||||
|
scope: string;
|
||||||
|
status: string;
|
||||||
|
trigger_kind: string;
|
||||||
|
priority: 'high' | 'low';
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAttributeSchemaResult = {
|
||||||
|
items: ProductAttributeSchema[];
|
||||||
|
cache: ProductCatalogCacheMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductAttributeDictionaryValue = {
|
export type ProductAttributeDictionaryValue = {
|
||||||
value_id: string;
|
value_id: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductAttributeDictionaryResult = {
|
||||||
|
items: ProductAttributeDictionaryValue[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
cache: ProductCatalogCacheMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductEditorValidationIssue = {
|
export type ProductEditorValidationIssue = {
|
||||||
code: string;
|
code: string;
|
||||||
field: string;
|
field: string;
|
||||||
@@ -102,10 +139,22 @@ export type ProductEditorValidation = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type ProductPublishPreflight = {
|
||||||
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
status: 'ready' | 'blocked' | 'refreshing';
|
||||||
* publishing DTOs intentionally stay out of this first editor slice.
|
validation: ProductEditorValidation;
|
||||||
*/
|
catalog: {
|
||||||
|
schema: ProductCatalogCacheMetadata;
|
||||||
|
dictionaries: Array<{
|
||||||
|
attribute_id: string;
|
||||||
|
platform_attribute_id: string;
|
||||||
|
name: string;
|
||||||
|
cache: ProductCatalogCacheMetadata;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
refresh_runs: ProductCatalogRefreshRun[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 商品编辑入口与完整读模型;真正的发布 job DTO 仍在后续纵向任务中定义。 */
|
||||||
export type ProductEditorContext = {
|
export type ProductEditorContext = {
|
||||||
collection_item_id: string;
|
collection_item_id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
@@ -369,6 +418,20 @@ export const productAiSourceMaterialOverridesSchema =
|
|||||||
.partial()
|
.partial()
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
export const productPublishPreflightRequestSchema = z
|
||||||
|
.object({
|
||||||
|
variant_ids: z
|
||||||
|
.array(uuid)
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.refine((ids) => new Set(ids).size === ids.length, 'SKU 不能重复'),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ProductPublishPreflightRequest = z.infer<
|
||||||
|
typeof productPublishPreflightRequestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
||||||
export const saveProductEditorDraftRequestSchema = z
|
export const saveProductEditorDraftRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -399,6 +462,8 @@ export const saveProductEditorDraftRequestSchema = z
|
|||||||
title: nullableText(1_000),
|
title: nullableText(1_000),
|
||||||
description: nullableText(100_000),
|
description: nullableText(100_000),
|
||||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||||
|
platform_product_category_id: uuid.nullable().optional(),
|
||||||
|
/** 旧客户端过渡字段;服务端会按外部 ID 映射到平台类目。 */
|
||||||
store_product_category_id: uuid.nullable().optional(),
|
store_product_category_id: uuid.nullable().optional(),
|
||||||
category_source: z
|
category_source: z
|
||||||
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- 平台目录刷新阈值独立于门店和工作空间;修改策略不主动创建同步任务。
|
||||||
|
CREATE TABLE platform_catalog_refresh_policies (
|
||||||
|
platform_id UUID PRIMARY KEY REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
attribute_fresh_days INTEGER NOT NULL DEFAULT 14 CHECK (attribute_fresh_days BETWEEN 1 AND 365),
|
||||||
|
attribute_expire_days INTEGER NOT NULL DEFAULT 21 CHECK (attribute_expire_days BETWEEN 1 AND 365),
|
||||||
|
dictionary_fresh_days INTEGER NOT NULL DEFAULT 14 CHECK (dictionary_fresh_days BETWEEN 1 AND 365),
|
||||||
|
dictionary_expire_days INTEGER NOT NULL DEFAULT 21 CHECK (dictionary_expire_days BETWEEN 1 AND 365),
|
||||||
|
updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (attribute_expire_days > attribute_fresh_days),
|
||||||
|
CHECK (dictionary_expire_days > dictionary_fresh_days)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO platform_catalog_refresh_policies (platform_id)
|
||||||
|
SELECT id FROM platforms
|
||||||
|
ON CONFLICT (platform_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 成功检查时间属于资源状态,不能从数据行 updated_at 推断。
|
||||||
|
CREATE TABLE platform_catalog_resource_states (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL CHECK (scope IN ('tree', 'attributes', 'dictionary', 'dictionary_search')),
|
||||||
|
scope_key TEXT NOT NULL,
|
||||||
|
category_id UUID,
|
||||||
|
attribute_id UUID,
|
||||||
|
search_key TEXT,
|
||||||
|
last_success_at TIMESTAMPTZ,
|
||||||
|
last_success_run_id UUID REFERENCES platform_catalog_sync_runs(id) ON DELETE SET NULL,
|
||||||
|
is_complete BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_error_code TEXT,
|
||||||
|
last_error_message TEXT,
|
||||||
|
last_error_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (platform_id, scope_key),
|
||||||
|
FOREIGN KEY (platform_id, category_id)
|
||||||
|
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (attribute_id, platform_id, category_id)
|
||||||
|
REFERENCES platform_product_category_attributes(id, platform_id, platform_product_category_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
(scope = 'tree' AND category_id IS NULL AND attribute_id IS NULL AND search_key IS NULL)
|
||||||
|
OR (scope = 'attributes' AND category_id IS NOT NULL AND attribute_id IS NULL AND search_key IS NULL)
|
||||||
|
OR (scope = 'dictionary' AND category_id IS NOT NULL AND attribute_id IS NOT NULL AND search_key IS NULL)
|
||||||
|
OR (scope = 'dictionary_search' AND category_id IS NOT NULL AND attribute_id IS NOT NULL AND search_key IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_catalog_resource_states_status
|
||||||
|
ON platform_catalog_resource_states (platform_id, scope, last_success_at);
|
||||||
|
|
||||||
|
-- 保留旧 scheduled 触发值用于历史任务;新任务细分每周、批次和交互来源。
|
||||||
|
ALTER TABLE platform_catalog_sync_runs
|
||||||
|
DROP CONSTRAINT platform_catalog_sync_runs_trigger_kind_check;
|
||||||
|
ALTER TABLE platform_catalog_sync_runs
|
||||||
|
ADD CONSTRAINT platform_catalog_sync_runs_trigger_kind_check
|
||||||
|
CHECK (trigger_kind IN (
|
||||||
|
'manual', 'scheduled', 'weekly', 'source_configured',
|
||||||
|
'admin_batch', 'user_access', 'publish'
|
||||||
|
));
|
||||||
|
ALTER TABLE platform_catalog_sync_runs
|
||||||
|
ADD COLUMN priority TEXT NOT NULL DEFAULT 'low'
|
||||||
|
CHECK (priority IN ('high', 'low')),
|
||||||
|
ADD COLUMN promoted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- 管理员批次固定启动瞬间的类型集合,后续树变化不改变既有批次范围。
|
||||||
|
CREATE TABLE platform_catalog_attribute_batches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
mode TEXT NOT NULL CHECK (mode IN ('missing_expired', 'missing_stale_expired', 'all')),
|
||||||
|
status operation_status NOT NULL DEFAULT 'queued',
|
||||||
|
source_revision INTEGER NOT NULL CHECK (source_revision > 0),
|
||||||
|
attribute_fresh_days INTEGER NOT NULL CHECK (attribute_fresh_days BETWEEN 1 AND 365),
|
||||||
|
attribute_expire_days INTEGER NOT NULL CHECK (attribute_expire_days BETWEEN 1 AND 365),
|
||||||
|
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
idempotency_key UUID NOT NULL,
|
||||||
|
total_count INTEGER NOT NULL CHECK (total_count > 0),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (platform_id, id),
|
||||||
|
UNIQUE (requested_by_user_id, idempotency_key),
|
||||||
|
CHECK (attribute_expire_days > attribute_fresh_days)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX uq_platform_catalog_active_attribute_batch
|
||||||
|
ON platform_catalog_attribute_batches (platform_id)
|
||||||
|
WHERE status IN ('queued', 'running');
|
||||||
|
CREATE INDEX ix_platform_catalog_attribute_batches_recent
|
||||||
|
ON platform_catalog_attribute_batches (platform_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE platform_catalog_attribute_batch_items (
|
||||||
|
batch_id UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
category_id UUID NOT NULL,
|
||||||
|
run_id UUID REFERENCES platform_catalog_sync_runs(id) ON DELETE RESTRICT,
|
||||||
|
linked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (batch_id, category_id),
|
||||||
|
FOREIGN KEY (platform_id, batch_id)
|
||||||
|
REFERENCES platform_catalog_attribute_batches(platform_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (platform_id, category_id)
|
||||||
|
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
|
||||||
|
CHECK ((run_id IS NULL) = (linked_at IS NULL))
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_catalog_attribute_batch_items_pending
|
||||||
|
ON platform_catalog_attribute_batch_items (batch_id, created_at, category_id)
|
||||||
|
WHERE run_id IS NULL;
|
||||||
|
CREATE INDEX ix_platform_catalog_attribute_batch_items_run
|
||||||
|
ON platform_catalog_attribute_batch_items (run_id)
|
||||||
|
WHERE run_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 现有成功任务建立初始状态,避免部署后把已缓存资源误判为 missing。
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (run.platform_id, run.scope_key)
|
||||||
|
run.platform_id, run.scope, run.scope_key, run.category_id, run.attribute_id,
|
||||||
|
run.id AS run_id, run.completed_at, run.result
|
||||||
|
FROM platform_catalog_sync_runs run
|
||||||
|
WHERE run.status = 'succeeded' AND run.completed_at IS NOT NULL
|
||||||
|
ORDER BY run.platform_id, run.scope_key, run.completed_at DESC, run.id DESC
|
||||||
|
)
|
||||||
|
INSERT INTO platform_catalog_resource_states
|
||||||
|
(platform_id, scope, scope_key, category_id, attribute_id,
|
||||||
|
last_success_at, last_success_run_id, is_complete)
|
||||||
|
SELECT platform_id, scope, scope_key, category_id, attribute_id,
|
||||||
|
completed_at, run_id,
|
||||||
|
CASE WHEN scope IN ('tree', 'attributes') THEN true
|
||||||
|
ELSE COALESCE((result->>'is_complete')::boolean, false) END
|
||||||
|
FROM latest
|
||||||
|
ON CONFLICT (platform_id, scope_key) DO UPDATE
|
||||||
|
SET last_success_at = EXCLUDED.last_success_at,
|
||||||
|
last_success_run_id = EXCLUDED.last_success_run_id,
|
||||||
|
is_complete = EXCLUDED.is_complete,
|
||||||
|
updated_at = now();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- 阈值调整会改变缓存可用性判断,保留不可变的前后值用于系统级审计。
|
||||||
|
CREATE TABLE platform_catalog_refresh_policy_revisions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
changed_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
previous_policy JSONB NOT NULL,
|
||||||
|
next_policy JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_platform_catalog_refresh_policy_revisions_recent
|
||||||
|
ON platform_catalog_refresh_policy_revisions (platform_id, created_at DESC);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- 商品编辑器恢复使用平台级类目引用;门店级列暂时保留用于旧客户端兼容和回滚。
|
||||||
|
UPDATE product_platform_profiles profile
|
||||||
|
SET platform_product_category_id = platform_category.id,
|
||||||
|
platform_type_id = platform_category.external_type_id,
|
||||||
|
updated_at = now()
|
||||||
|
FROM store_product_categories store_category
|
||||||
|
JOIN 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 profile.store_product_category_id = store_category.id
|
||||||
|
AND profile.platform_id = platform_category.platform_id
|
||||||
|
AND profile.platform_product_category_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE product_unmapped_attributes unmapped
|
||||||
|
SET original_platform_category_id = platform_category.id
|
||||||
|
FROM store_product_categories store_category
|
||||||
|
JOIN 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 unmapped.original_store_product_category_id = store_category.id
|
||||||
|
AND unmapped.platform_id = platform_category.platform_id
|
||||||
|
AND unmapped.original_platform_category_id IS NULL;
|
||||||
|
|
||||||
|
-- 部分大字典的关键词补齐是独立缓存作用域,不能刷新整个字典的新鲜时间。
|
||||||
|
ALTER TABLE platform_catalog_sync_runs
|
||||||
|
ADD COLUMN search_key TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE platform_catalog_sync_runs
|
||||||
|
DROP CONSTRAINT platform_catalog_sync_runs_scope_check,
|
||||||
|
DROP CONSTRAINT platform_catalog_sync_runs_check;
|
||||||
|
|
||||||
|
ALTER TABLE platform_catalog_sync_runs
|
||||||
|
ADD CONSTRAINT platform_catalog_sync_runs_scope_check
|
||||||
|
CHECK (scope IN ('tree', 'attributes', 'dictionary', 'dictionary_search')),
|
||||||
|
ADD CONSTRAINT platform_catalog_sync_runs_target_check CHECK (
|
||||||
|
(scope = 'tree' AND category_id IS NULL AND attribute_id IS NULL AND search_key IS NULL)
|
||||||
|
OR (scope = 'attributes' AND category_id IS NOT NULL AND attribute_id IS NULL AND search_key IS NULL)
|
||||||
|
OR (scope = 'dictionary' AND category_id IS NOT NULL AND attribute_id IS NOT NULL AND search_key IS NULL)
|
||||||
|
OR (scope = 'dictionary_search' AND category_id IS NOT NULL AND attribute_id IS NOT NULL
|
||||||
|
AND length(trim(search_key)) >= 2)
|
||||||
|
);
|
||||||
|
|
||||||
Reference in New Issue
Block a user