feat(edit): 编辑页开发
This commit is contained in:
@@ -207,6 +207,44 @@ export class StoresController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':storeId/category-tree-syncs')
|
||||
@ApiOperation({ summary: '同步刷新门店类目树' })
|
||||
@ApiOkResponse({ description: 'Ozon 类目树已成功写入门店缓存' })
|
||||
public async categoryTreeSync(
|
||||
@Param('storeId') storeId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.requestCategoryTreeSync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('default/category-tree-debug')
|
||||
@ApiOperation({ summary: '读取当前平台默认门店的 Ozon 原始类目树(调试)' })
|
||||
public async defaultCategoryTreeDebug(
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (!platformId) {
|
||||
throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
||||
}
|
||||
return {
|
||||
data: await this.storesService.fetchDefaultCategoryTreeDebug(
|
||||
workspace.id,
|
||||
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':storeId/warehouse-syncs')
|
||||
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
||||
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { encryptStoreSecret } from './store-secret.crypto.js';
|
||||
import { decryptStoreSecret, encryptStoreSecret } from './store-secret.crypto.js';
|
||||
|
||||
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
||||
credential_checked_at: Date | string | null;
|
||||
@@ -46,6 +46,23 @@ type WarehouseRow = {
|
||||
status: string;
|
||||
synced_at: Date | string;
|
||||
};
|
||||
type OzonCategoryNode = {
|
||||
description_category_id?: number | string;
|
||||
category_name?: string | null;
|
||||
type_id?: number | string | null;
|
||||
type_name?: string | null;
|
||||
disabled?: boolean;
|
||||
children?: OzonCategoryNode[];
|
||||
};
|
||||
type FlatCategory = {
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
parent_key: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
source_payload: OzonCategoryNode;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StoresService {
|
||||
@@ -335,6 +352,170 @@ export class StoresService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 手动刷新是同步请求:Ozon 完整响应成功落库后才向浏览器返回成功。 */
|
||||
public async requestCategoryTreeSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/stores/${storeId}/category-tree-syncs`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const store = await this.requireStore(transaction, storeId);
|
||||
if (store.status === 'disabled') {
|
||||
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
|
||||
}
|
||||
if (store.credential_status !== 'valid') {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'门店凭据尚未验证通过,无法刷新类目',
|
||||
);
|
||||
}
|
||||
|
||||
// 保持门店行锁直到完成写入,防止同一门店的两次刷新交错覆盖缓存。
|
||||
await transaction.query(
|
||||
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||
[storeId],
|
||||
);
|
||||
const source = await transaction.query<{
|
||||
platform_id: string;
|
||||
platform_code: string;
|
||||
platform_client_id: string;
|
||||
secret_ciphertext: Buffer;
|
||||
secret_nonce: Buffer;
|
||||
}>(
|
||||
`SELECT store.platform_id, platform.code AS platform_code, store.platform_client_id,
|
||||
secret.secret_ciphertext, secret.secret_nonce
|
||||
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.deleted_at IS NULL
|
||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||
AND secret.status = 'valid'
|
||||
LIMIT 1`,
|
||||
[storeId],
|
||||
);
|
||||
const current = source.rows[0];
|
||||
if (!current) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'门店凭据尚未验证通过,无法刷新类目',
|
||||
);
|
||||
}
|
||||
if (current.platform_code.toUpperCase() !== 'OZON') {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_CATALOG_UNSUPPORTED',
|
||||
'当前平台暂不支持类目缓存同步',
|
||||
);
|
||||
}
|
||||
const apiKey = decryptStoreSecret(
|
||||
current.secret_ciphertext,
|
||||
current.secret_nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||
);
|
||||
const tree = await requestOzonCategoryTree(
|
||||
current.platform_client_id,
|
||||
apiKey,
|
||||
);
|
||||
const result = await persistCategoryTreeInTransaction(
|
||||
transaction,
|
||||
storeId,
|
||||
current.platform_id,
|
||||
tree,
|
||||
);
|
||||
return { status: 200, body: { data: result } };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-only read-through for the current platform's default store. The
|
||||
* credential is decrypted only for this outbound request and is never
|
||||
* placed in the browser response, logs, operations, or outbox payloads.
|
||||
*/
|
||||
public async fetchDefaultCategoryTreeDebug(
|
||||
workspaceId: string,
|
||||
platformId: string,
|
||||
): Promise<unknown> {
|
||||
const source = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const rows = await transaction.query<{
|
||||
platform_client_id: string;
|
||||
secret_ciphertext: Buffer;
|
||||
secret_nonce: Buffer;
|
||||
}>(
|
||||
`SELECT store.platform_client_id, secret.secret_ciphertext, secret.secret_nonce
|
||||
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.platform_id = $1 AND store.is_default
|
||||
AND store.deleted_at IS NULL AND store.status <> 'disabled'
|
||||
AND store.credential_status = 'valid'
|
||||
AND platform.code = 'OZON'
|
||||
AND secret.kind = 'seller_api_key' AND secret.status = 'valid'
|
||||
AND secret.revoked_at IS NULL
|
||||
LIMIT 1`,
|
||||
[platformId],
|
||||
);
|
||||
return rows.rows[0] ?? null;
|
||||
},
|
||||
);
|
||||
if (!source) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'当前平台没有凭据已验证的默认 Ozon 门店',
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = decryptStoreSecret(
|
||||
source.secret_ciphertext,
|
||||
source.secret_nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api-seller.ozon.ru/v1/description-category/tree',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': source.platform_client_id,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
const body = (await response.json()) as unknown;
|
||||
if (response.ok) return body;
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_DEBUG_FAILED',
|
||||
`Ozon 类目树请求失败(HTTP ${response.status})`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_DEBUG_UNAVAILABLE',
|
||||
'Ozon 类目树请求暂时不可用,请稍后重试',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async requestWarehouseSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -661,6 +842,163 @@ export class StoresService {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOzonCategoryTree(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
): Promise<OzonCategoryNode[]> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': clientId,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_UNAVAILABLE',
|
||||
'Ozon 类目请求暂时不可用,请稍后重试',
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_REFRESH_FAILED',
|
||||
`Ozon 类目请求失败(HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
let body: { result?: unknown };
|
||||
try {
|
||||
body = (await response.json()) as { result?: unknown };
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_INVALID',
|
||||
'Ozon 返回的类目树格式无效',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(body?.result)) {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_INVALID',
|
||||
'Ozon 返回的类目树格式无效',
|
||||
);
|
||||
}
|
||||
return body.result as OzonCategoryNode[];
|
||||
}
|
||||
|
||||
async function persistCategoryTreeInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
roots: OzonCategoryNode[],
|
||||
) {
|
||||
const records = [
|
||||
...new Map(
|
||||
flattenCategoryTree(roots).map((record) => [
|
||||
`${record.external_category_id}:${record.external_type_id}`,
|
||||
record,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const syncTime = new Date();
|
||||
await transaction.query(
|
||||
`WITH incoming AS (
|
||||
SELECT * FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||
external_category_id text, external_type_id text, parent_key text,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
)
|
||||
INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, synced_at)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id,
|
||||
name, path, is_active, source_payload, $4::timestamptz
|
||||
FROM incoming
|
||||
ON CONFLICT (store_id, external_category_id, external_type_id) DO UPDATE
|
||||
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
||||
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||||
updated_at = now()`,
|
||||
[storeId, platformId, JSON.stringify(records), syncTime],
|
||||
);
|
||||
await transaction.query(
|
||||
`WITH incoming AS (
|
||||
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||||
external_category_id text, external_type_id text, parent_key text,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
), resolved AS (
|
||||
SELECT child.id AS child_id, parent.id AS parent_id
|
||||
FROM incoming
|
||||
JOIN atlas.store_product_categories child
|
||||
ON child.store_id = $1
|
||||
AND child.external_category_id = incoming.external_category_id
|
||||
AND child.external_type_id = incoming.external_type_id
|
||||
LEFT JOIN atlas.store_product_categories parent
|
||||
ON parent.store_id = $1
|
||||
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||||
)
|
||||
UPDATE atlas.store_product_categories category
|
||||
SET parent_id = resolved.parent_id, updated_at = now()
|
||||
FROM resolved WHERE category.id = resolved.child_id`,
|
||||
[storeId, JSON.stringify(records)],
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_product_categories SET is_active = false, updated_at = now()
|
||||
WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||
[storeId, syncTime],
|
||||
);
|
||||
return {
|
||||
category_count: records.length,
|
||||
active_category_count: records.filter((record) => record.is_active).length,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenCategoryTree(
|
||||
nodes: OzonCategoryNode[],
|
||||
path: string[] = [],
|
||||
parentKey: string | null = null,
|
||||
inheritedCategory?: { id: string; name: string },
|
||||
): FlatCategory[] {
|
||||
const records: FlatCategory[] = [];
|
||||
for (const node of nodes) {
|
||||
const explicitCategoryId = String(node.description_category_id ?? '').trim();
|
||||
const explicitCategoryName = node.category_name?.trim() ?? '';
|
||||
const categoryId = explicitCategoryId || inheritedCategory?.id || '';
|
||||
const categoryName = explicitCategoryName || inheritedCategory?.name || '';
|
||||
const typeId = String(node.type_id ?? 0);
|
||||
const name = explicitCategoryName || node.type_name?.trim() || categoryName;
|
||||
if (!categoryId || !name) continue;
|
||||
const key = `${categoryId}:${typeId}`;
|
||||
const nextPath = [...path, name];
|
||||
records.push({
|
||||
external_category_id: categoryId,
|
||||
external_type_id: typeId,
|
||||
parent_key: parentKey,
|
||||
name,
|
||||
path: nextPath.join(' › '),
|
||||
is_active: node.disabled !== true && typeId !== '0',
|
||||
source_payload: node,
|
||||
});
|
||||
records.push(
|
||||
...flattenCategoryTree(
|
||||
node.children ?? [],
|
||||
nextPath,
|
||||
key,
|
||||
explicitCategoryId && explicitCategoryName
|
||||
? { id: explicitCategoryId, name: explicitCategoryName }
|
||||
: inheritedCategory,
|
||||
),
|
||||
);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
const storeSelect = `
|
||||
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
||||
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
||||
|
||||
Reference in New Issue
Block a user