8655b7805f
tsx/esbuild 不生成 design:paramtypes 装饰器元数据,裸类型构造参数在 dev 运行时注入 undefined,GET /api/platforms 返回 500。改为与 auth.controller.ts 一致的显式 @Inject(...)。 跨领域改动说明:前端联调平台上下文时发现该阻断性 bug,修复方式 沿用仓库既有模式,不影响服务层(原本即显式 token 注入)。
341 lines
11 KiB
TypeScript
341 lines
11 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/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,
|
|
})),
|
|
);
|
|
}
|