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(schema: z.ZodType, 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, })), ); }