Files
atlas-seller-kit/apps/api/src/stores/stores.controller.ts
T
2026-09-11 17:28:58 +08:00

379 lines
12 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
createStoreRequestSchema,
listStoresQuerySchema,
updateStoreRequestSchema,
} from '@atlas/contracts';
import { z } from 'zod';
import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { Workspace } from '@atlas/contracts';
import { PlatformsService } from './platforms.service.js';
import { StoresService } from './stores.service.js';
type HttpResponse = { status: (status: number) => HttpResponse };
@ApiTags('Platforms')
@ApiBearerAuth('access-token')
@Controller()
export class PlatformsController {
// tsx/esbuild 不生成 design:paramtypes 元数据,控制器依赖必须显式 @Inject(与 auth.controller 一致)
public constructor(
@Inject(PlatformsService)
private readonly platformsService: PlatformsService,
) {}
@Get('platforms')
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
@ApiOkResponse({ description: '平台目录' })
public async list() {
return { data: await this.platformsService.list() };
}
@Get('platform-context')
@ApiOperation({ summary: '获取页头所选平台的默认门店上下文' })
@ApiOkResponse({ description: '平台、默认门店和门店总数' })
public async context(
@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.platformsService.context(
workspace.id,
requireUuid('X-Atlas-Platform-Id', platformId),
),
};
}
}
@ApiTags('Stores')
@ApiBearerAuth('access-token')
@Controller('stores')
export class StoresController {
public constructor(
@Inject(StoresService) private readonly storesService: StoresService,
) {}
@Get()
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
@ApiOkResponse({ description: '门店列表与不透明游标' })
public async list(
@Query() query: unknown,
@Headers('x-atlas-platform-id') platformHeader: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
if (platformHeader) {
throw new ApiError(
400,
'PLATFORM_CONTEXT_CONFLICT',
'门店管理列表不能同时使用平台 header 和 platform_id 查询参数',
);
}
return this.storesService.list(
workspace.id,
parse(listStoresQuerySchema, query),
);
}
@Get(':storeId')
@ApiOperation({ summary: '读取单个门店' })
public async get(
@Param('storeId') storeId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.storesService.get(
workspace.id,
requireUuid('store_id', storeId),
),
};
}
@Post()
@ApiOperation({ summary: '创建门店连接,并请求异步凭据验证' })
@ApiAcceptedResponse({ description: '已加密保存凭据,并创建验证操作' })
public async create(
@Body() body: unknown,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.create(
workspace.id,
request.auth.userId,
requireIdempotencyKey(idempotencyKey),
parse(createStoreRequestSchema, body),
),
);
}
@Patch(':storeId')
@ApiOperation({ summary: '按版本编辑门店;更新密钥会重新请求验证' })
@ApiOkResponse({ description: '普通字段已更新' })
@ApiAcceptedResponse({ description: '新密钥已保存,并创建验证操作' })
public async update(
@Param('storeId') storeId: string,
@Body() body: unknown,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
return withStatus(
response,
await this.storesService.update(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
parseRevision(ifMatch),
parse(updateStoreRequestSchema, body),
),
);
}
@Post(':storeId/set-default')
@ApiOperation({ summary: '设为当前平台默认门店' })
public async setDefault(
@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.setDefault(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
@Post(':storeId/credential-verifications')
@ApiOperation({ summary: '手动请求重新验证门店凭据' })
@ApiAcceptedResponse({ description: '凭据验证操作已入队' })
public async credentialVerification(
@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.requestCredentialVerification(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
@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: '仓库同步操作已入队' })
public async warehouseSync(
@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.requestWarehouseSync(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
@Get(':storeId/warehouses')
@ApiOperation({ summary: '读取最近成功同步的仓库摘要' })
public async warehouses(
@Param('storeId') storeId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return this.storesService.warehouses(
workspace.id,
requireUuid('store_id', storeId),
);
}
@Delete(':storeId')
@HttpCode(204)
@ApiOperation({ summary: '软删除门店连接并撤销当前密钥' })
@ApiNoContentResponse({ description: '门店已删除' })
public async remove(
@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.remove(
workspace.id,
request.auth.userId,
requireUuid('store_id', storeId),
requireIdempotencyKey(idempotencyKey),
),
);
}
}
@ApiTags('Operations')
@ApiBearerAuth('access-token')
@Controller('operations')
export class OperationsController {
public constructor(
@Inject(StoresService) private readonly storesService: StoresService,
) {}
@Get(':operationId')
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
public async get(
@Param('operationId') operationId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.storesService.operation(
workspace.id,
requireUuid('operation_id', operationId),
),
};
}
}
function withStatus(
response: HttpResponse,
result: { status: number; body: unknown },
) {
response.status(result.status);
return result.body;
}
function requireIdempotencyKey(value: string | undefined): string {
if (!value) {
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'请求缺少 Idempotency-Key',
);
}
return requireUuid('Idempotency-Key', value);
}
function requireUuid(field: string, value: string): string {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
function parseRevision(value: string | undefined): number {
const match = value?.match(/^"([1-9]\d*)"$/);
if (!match) {
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求必须携带形如 "1" 的 If-Match',
);
}
return Number(match[1]);
}
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (result.success) return result.data;
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
result.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}