feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
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 {
|
||||
public constructor(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(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(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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user