diff --git a/docs/api-contract-v1.md b/docs/api-contract-v1.md index 0da33ca..1b997ff 100644 --- a/docs/api-contract-v1.md +++ b/docs/api-contract-v1.md @@ -176,9 +176,22 @@ > **designData 结构遵循 `wechat_wc/docs/design-data-contract-v1.md`(R2/R4 冻结契约)。** > 该契约保证 R4 下单后能据此构造 `.wcd` 投递到词云平台。要点: -> 贴纸图 `src` 必须为 COS 持久 URL(禁止 `wxfile://`/`tmp`)、保留 `wordcloud` 分组、 -> 保留 `category.mask`;后端该 JSON 白名单须放行 `version/background/wordcloud/rotation/zIndex`。 +> 贴纸图 `src` 在**派单前**必须为 COS 持久 URL;R2 保存/更新清单时允许暂存 +> `wxfile://`/`tmp` 本地路径,由 R4 在下单/派单前上传 COS 并回写 +> (design-data-contract-v1.md 约束#1、决策#4,2026-08-12 冻结); +> 保留 `wordcloud` 分组、保留 `category.mask`;后端该 JSON 白名单须放行 +> `version/background/wordcloud/rotation/zIndex`。 >`items` 为服务端 JSON,需做结构白名单与大小校验(单条 ≤ 1MB)。 +> +> **实现补充(R2,非契约变更)**:服务端 JSON body 传输上限为 **2MB**(`main.ts`,Nest 默认 100KB +> 会使 1MB 业务限制不可达)。三层边界:≤1MB 正常受理;1MB~2MB 由 design-list service 返回 +> 400「单条设计数据超过 1MB 上限」;>2MB 返回 413「请求体过大」。文件上传(R4 multipart) +> 不走此限制,沿用各模块独立校验(如底图 ≤10MB)。 +> +> **实现补充(DIY 修正,非契约变更)**:`stickers[].width/height` 语义为「画布显示像素」 +> (画布坐标空间 = `category.mask` 尺寸,`x/y` 允许超出画布边界,渲染端裁切、WCD 按原值还原), +> 渲染、碰撞检测、WCD 打包三方按同一语义消费; +> 历史数据中的原始像素值由读取端按 mask 归一化兼容。详见 design-data-contract-v1.md 注记。 ### PATCH /api/design-list/:id diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts index 0210422..a076cc4 100644 --- a/src/addresses/addresses.controller.ts +++ b/src/addresses/addresses.controller.ts @@ -1,8 +1,17 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { AddressesService } from './addresses.service'; import { CreateAddressDto } from './dto/create-address.dto'; +import { UpdateAddressDto } from './dto/update-address.dto'; @ApiTags('收货地址') @ApiBearerAuth() @@ -11,13 +20,13 @@ export class AddressesController { constructor(private readonly addressesService: AddressesService) {} @Get() - @ApiOperation({ summary: '我的收货地址列表' }) + @ApiOperation({ summary: '我的收货地址列表(默认地址在前)' }) list(@CurrentUser() user: JwtPayload) { return this.addressesService.listByUser(user.sub); } @Post() - @ApiOperation({ summary: '新增收货地址' }) + @ApiOperation({ summary: '新增收货地址(首个地址自动设为默认)' }) create(@CurrentUser() user: JwtPayload, @Body() dto: CreateAddressDto) { return this.addressesService.create(user.sub, dto); } @@ -27,4 +36,20 @@ export class AddressesController { setDefault(@CurrentUser() user: JwtPayload, @Param('id') id: string) { return this.addressesService.setDefault(user.sub, id); } + + @Patch(':id') + @ApiOperation({ summary: '更新本人地址(isDefault 走默认地址事务)' }) + update( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateAddressDto, + ) { + return this.addressesService.update(user.sub, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: '删除本人地址(删默认地址时自动补偿新默认)' }) + remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.addressesService.remove(user.sub, id); + } } diff --git a/src/addresses/addresses.service.ts b/src/addresses/addresses.service.ts index 6220c07..78e57cf 100644 --- a/src/addresses/addresses.service.ts +++ b/src/addresses/addresses.service.ts @@ -1,6 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Address, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateAddressDto } from './dto/create-address.dto'; +import { UpdateAddressDto } from './dto/update-address.dto'; @Injectable() export class AddressesService { @@ -14,12 +16,76 @@ export class AddressesService { } async create(userId: string, dto: CreateAddressDto) { - // TODO: 若 isDefault,需先把该用户其它地址置为非默认(事务) - return this.prisma.address.create({ data: { ...dto, userId } }); + return this.prisma.$transaction(async (tx) => { + // 首个地址强制为默认(列表非空时默认地址始终存在) + const count = await tx.address.count({ where: { userId } }); + const isDefault = count === 0 ? true : (dto.isDefault ?? false); + if (isDefault) { + // 事务内先清旧默认再写入,保证默认地址唯一 + await tx.address.updateMany({ + where: { userId, isDefault: true }, + data: { isDefault: false }, + }); + } + return tx.address.create({ + data: { ...dto, isDefault, userId }, + }); + }); + } + + async update(userId: string, id: string, dto: UpdateAddressDto) { + const existing = await this.getOwnedAddress(userId, id); + return this.prisma.$transaction(async (tx) => { + if (dto.isDefault === true) { + await tx.address.updateMany({ + where: { userId, isDefault: true }, + data: { isDefault: false }, + }); + } + return tx.address.update({ where: { id: existing.id }, data: dto }); + }); } async setDefault(userId: string, id: string) { - // TODO: 事务内先清旧默认再设新默认 - return this.prisma.address.update({ where: { id }, data: { isDefault: true } }); + const existing = await this.getOwnedAddress(userId, id); + return this.prisma.$transaction(async (tx) => { + await tx.address.updateMany({ + where: { userId, isDefault: true }, + data: { isDefault: false }, + }); + return tx.address.update({ + where: { id: existing.id }, + data: { isDefault: true }, + }); + }); + } + + async remove(userId: string, id: string) { + const existing = await this.getOwnedAddress(userId, id); + await this.prisma.$transaction(async (tx) => { + await tx.address.delete({ where: { id: existing.id } }); + // 删除的是默认地址:补偿把最新一条设为默认,避免列表无默认 + if (existing.isDefault) { + const next = await tx.address.findFirst({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + if (next) { + await tx.address.update({ + where: { id: next.id }, + data: { isDefault: true }, + }); + } + } + }); + return null; + } + + /** 归属校验:不存在 404,存在但非本人 403(api-contract-v1 §4) */ + private async getOwnedAddress(userId: string, id: string): Promise
{ + const address = await this.prisma.address.findUnique({ where: { id } }); + if (!address) throw new NotFoundException('地址不存在'); + if (address.userId !== userId) throw new ForbiddenException('无权操作该地址'); + return address; } } diff --git a/src/addresses/dto/update-address.dto.ts b/src/addresses/dto/update-address.dto.ts new file mode 100644 index 0000000..f5f068d --- /dev/null +++ b/src/addresses/dto/update-address.dto.ts @@ -0,0 +1,46 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +/** PATCH /api/addresses/:id:全字段可选,至少传一个字段才有意义 */ +export class UpdateAddressDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + phone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + province?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + city?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + district?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + detail?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index 0dfa1bf..517416e 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -34,6 +34,14 @@ export class AllExceptionsFilter implements ExceptionFilter { ? res : ((res as Record).message as string | string[] | undefined)?.toString() ?? exception.message; + } else if ( + exception instanceof Error && + (exception as Error & { type?: string }).type === 'entity.too.large' + ) { + // body-parser 请求体超限(main.ts 限制 2MB) + status = HttpStatus.PAYLOAD_TOO_LARGE; + code = 413; + message = '请求体过大'; } else if (exception instanceof Prisma.PrismaClientKnownRequestError) { // 常见 Prisma 错误码映射 switch (exception.code) { diff --git a/src/design-list/design-list.controller.ts b/src/design-list/design-list.controller.ts index b1a5c39..01ea520 100644 --- a/src/design-list/design-list.controller.ts +++ b/src/design-list/design-list.controller.ts @@ -1,30 +1,55 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { DesignListService } from './design-list.service'; +import { BatchDeleteDesignListDto } from './dto/batch-delete-design-list.dto'; import { CreateDesignListDto } from './dto/create-design-list.dto'; +import { UpdateDesignListDto } from './dto/update-design-list.dto'; @ApiTags('设计清单') @ApiBearerAuth() -@Controller('design-lists') +@Controller('design-list') export class DesignListController { constructor(private readonly designListService: DesignListService) {} @Get() - @ApiOperation({ summary: '我的设计清单列表' }) + @ApiOperation({ summary: '我的设计清单(createdAt 倒序,不分页)' }) list(@CurrentUser() user: JwtPayload) { return this.designListService.listByUser(user.sub); } @Get(':id') - @ApiOperation({ summary: '设计清单详情' }) - findOne(@Param('id') id: string) { - return this.designListService.findOne(id); + @ApiOperation({ summary: '查询单条本人清单' }) + findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.designListService.findOne(user.sub, id); } @Post() - @ApiOperation({ summary: '创建设计清单' }) + @ApiOperation({ summary: '创建清单(一条设计一条清单,items 固定 1 个元素)' }) create(@CurrentUser() user: JwtPayload, @Body() dto: CreateDesignListDto) { return this.designListService.create(user.sub, dto); } + + @Patch(':id') + @ApiOperation({ summary: '更新本人清单(title/items/状态迁移,单向状态机)' }) + update( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateDesignListDto, + ) { + return this.designListService.update(user.sub, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: '删除本人清单' }) + remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.designListService.remove(user.sub, id); + } + + @Post('batch-delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '批量删除本人清单,返回实际删除数' }) + batchDelete(@CurrentUser() user: JwtPayload, @Body() dto: BatchDeleteDesignListDto) { + return this.designListService.batchDelete(user.sub, dto); + } } diff --git a/src/design-list/design-list.service.ts b/src/design-list/design-list.service.ts index 3574b72..b214de5 100644 --- a/src/design-list/design-list.service.ts +++ b/src/design-list/design-list.service.ts @@ -1,7 +1,32 @@ -import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { DesignList, DesignListStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { BatchDeleteDesignListDto } from './dto/batch-delete-design-list.dto'; import { CreateDesignListDto } from './dto/create-design-list.dto'; +import { UpdateDesignListDto } from './dto/update-design-list.dto'; + +/** designData 结构白名单(wechat_wc/docs/design-data-contract-v1.md §2 冻结字段) */ +const DESIGN_DATA_ALLOWED_KEYS = new Set([ + 'version', + 'category', + 'background', + 'wordcloud', + 'stickers', + // 兼容旧版字段(契约明确保留) + 'imageSrc', + 'imagePos', +]); + +/** 单条设计数据上限(api-contract-v1 §5:1MB) */ +const DESIGN_DATA_MAX_BYTES = 1024 * 1024; + +/** 状态机:单向推进(阶段0 决策#6),前端只允许提交 DRAFT→SUBMITTED */ +const STATUS_TRANSITIONS: Record = { + DRAFT: [DesignListStatus.SUBMITTED], + SUBMITTED: [DesignListStatus.PROCESSING], + PROCESSING: [DesignListStatus.DONE], + DONE: [], +}; @Injectable() export class DesignListService { @@ -14,19 +39,89 @@ export class DesignListService { }); } - async findOne(id: string) { - // TODO: 权限校验(仅本人可查) - return this.prisma.designList.findUnique({ where: { id } }); + async findOne(userId: string, id: string) { + const list = await this.getOwnedList(userId, id); + return list; } async create(userId: string, dto: CreateDesignListDto) { - // TODO: 校验 items 结构、关联商品 SKU + const items = dto.items.map((it) => this.validateDesignData(it)); return this.prisma.designList.create({ data: { title: dto.title, - items: (dto.items ?? []) as Prisma.InputJsonValue, + items: this.toJson(items), userId, }, }); } + + async update(userId: string, id: string, dto: UpdateDesignListDto) { + const existing = await this.getOwnedList(userId, id); + + // 部分更新语义:只覆盖显式传入的字段,不传 items 不清 designData(R4 wordcloud 分组保护) + const data: Prisma.DesignListUpdateInput = {}; + if (dto.title !== undefined) data.title = dto.title; + if (dto.items !== undefined) { + data.items = this.toJson(dto.items.map((it) => this.validateDesignData(it))); + } + if (dto.status !== undefined && dto.status !== existing.status) { + this.assertTransition(existing.status, dto.status); + data.status = dto.status; + } + + return this.prisma.designList.update({ where: { id: existing.id }, data }); + } + + async remove(userId: string, id: string) { + const existing = await this.getOwnedList(userId, id); + await this.prisma.designList.delete({ where: { id: existing.id } }); + return null; + } + + /** 批量删除:一次事务只删本人清单,返回实际删除数(部分 id 无效不报错) */ + async batchDelete(userId: string, dto: BatchDeleteDesignListDto) { + const result = await this.prisma.designList.deleteMany({ + where: { id: { in: dto.ids }, userId }, + }); + return { deleted: result.count }; + } + + /** DTO 实例 → 纯 JSON(剥离 class 元数据与 undefined 字段,满足 Prisma InputJsonValue) */ + private toJson(items: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(items)) as Prisma.InputJsonValue; + } + + /** 归属校验:不存在 404,存在但非本人 403(与 addresses 模块一致) */ + private async getOwnedList(userId: string, id: string): Promise { + const list = await this.prisma.designList.findUnique({ where: { id } }); + if (!list) throw new NotFoundException('设计清单不存在'); + if (list.userId !== userId) throw new ForbiddenException('无权操作该设计清单'); + return list; + } + + /** 白名单 + 大小校验;只校验不修改业务 JSON(契约:服务端不改内容) */ + private validateDesignData }>(item: T): T { + if (item.designData === undefined) return item; + + const unknownKeys = Object.keys(item.designData).filter( + (k) => !DESIGN_DATA_ALLOWED_KEYS.has(k), + ); + if (unknownKeys.length > 0) { + throw new BadRequestException( + `designData 包含不支持的字段: ${unknownKeys.join(', ')}`, + ); + } + + const size = Buffer.byteLength(JSON.stringify(item.designData), 'utf8'); + if (size > DESIGN_DATA_MAX_BYTES) { + throw new BadRequestException('单条设计数据超过 1MB 上限'); + } + return item; + } + + private assertTransition(from: DesignListStatus, to: DesignListStatus) { + if (!STATUS_TRANSITIONS[from].includes(to)) { + throw new BadRequestException(`状态不允许从 ${from} 迁移到 ${to}`); + } + } } diff --git a/src/design-list/dto/batch-delete-design-list.dto.ts b/src/design-list/dto/batch-delete-design-list.dto.ts new file mode 100644 index 0000000..d78ee41 --- /dev/null +++ b/src/design-list/dto/batch-delete-design-list.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString, IsNotEmpty } from 'class-validator'; + +/** POST /api/design-list/batch-delete 请求体(api-contract-v1 §5) */ +export class BatchDeleteDesignListDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(50) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + ids!: string[]; +} diff --git a/src/design-list/dto/create-design-list.dto.ts b/src/design-list/dto/create-design-list.dto.ts index 5bf80f8..10dde61 100644 --- a/src/design-list/dto/create-design-list.dto.ts +++ b/src/design-list/dto/create-design-list.dto.ts @@ -1,14 +1,64 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +/** + * 清单条目(api-contract-v1 §5)。 + * 阶段0 决策#1:一条前端 DesignItem = 一条后端 DesignList,items 固定 1 个元素; + * 决策#3:productIcon 不入库,由客户端按 productId 推导。 + */ +export class DesignListEntryDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + productId!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + productName!: string; + + @ApiProperty() + @IsNumber() + @Min(0) + unitPrice!: number; + + @ApiProperty() + @IsNumber() + @Min(1) + @Max(999) + count!: number; + + /** 结构白名单与大小校验在 service 层做(设计数据契约 v1) */ + @ApiPropertyOptional() + @IsOptional() + @IsObject() + designData?: Record; +} export class CreateDesignListDto { - @ApiProperty({ description: '清单标题' }) + @ApiProperty({ description: '清单标题;前端传 productName(阶段0 决策#4)' }) @IsString() + @IsNotEmpty() title!: string; - @ApiPropertyOptional({ description: '定制项(结构化 JSON)', example: [{ sku: 'tshirt', color: 'black' }] }) - @IsOptional() + @ApiProperty({ type: [DesignListEntryDto] }) @IsArray() - @IsObject({ each: true }) - items?: Record[]; + @ArrayMinSize(1) + @ArrayMaxSize(1) + @ValidateNested({ each: true }) + @Type(() => DesignListEntryDto) + items!: DesignListEntryDto[]; } diff --git a/src/design-list/dto/update-design-list.dto.ts b/src/design-list/dto/update-design-list.dto.ts new file mode 100644 index 0000000..75b6b95 --- /dev/null +++ b/src/design-list/dto/update-design-list.dto.ts @@ -0,0 +1,39 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { DesignListStatus } from '@prisma/client'; +import { DesignListEntryDto } from './create-design-list.dto'; + +export class UpdateDesignListDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + title?: string; + + @ApiPropertyOptional({ type: [DesignListEntryDto] }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1) + @ValidateNested({ each: true }) + @Type(() => DesignListEntryDto) + items?: DesignListEntryDto[]; + + /** 状态迁移由 service 层做单向状态机校验(DRAFT→SUBMITTED→PROCESSING→DONE) */ + @ApiPropertyOptional({ enum: DesignListStatus }) + @IsOptional() + @IsEnum(DesignListStatus) + @MaxLength(20) + status?: DesignListStatus; +} diff --git a/src/main.ts b/src/main.ts index df1d9d9..f3a393f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,12 +2,17 @@ import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import * as express from 'express'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; async function bootstrap(): Promise { - const app = await NestFactory.create(AppModule); + // 设计清单 designData 契约上限 1MB(api-contract-v1 §5), + // body 限制放宽到 2MB,超 1MB 的业务校验在 design-list service 返回 400 + const app = await NestFactory.create(AppModule, { bodyParser: false }); + app.use(express.json({ limit: '2mb' })); + app.use(express.urlencoded({ extended: true, limit: '2mb' })); const config = app.get(ConfigService); app.setGlobalPrefix('api', { exclude: ['health'] }); diff --git a/src/users/dto/update-profile.dto.ts b/src/users/dto/update-profile.dto.ts new file mode 100644 index 0000000..0207a80 --- /dev/null +++ b/src/users/dto/update-profile.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** PATCH /api/users/me:昵称/头像可选更新(契约 §2) */ +export class UpdateProfileDto { + @ApiPropertyOptional({ description: '昵称' }) + @IsOptional() + @IsString() + @MaxLength(50) + nickname?: string; + + @ApiPropertyOptional({ description: '头像 URL' }) + @IsOptional() + @IsString() + @MaxLength(500) + avatar?: string; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index ec7793c..60d307a 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { UsersService } from './users.service'; +import { UpdateProfileDto } from './dto/update-profile.dto'; @ApiTags('用户') @ApiBearerAuth() @@ -15,4 +16,11 @@ export class UsersController { async getMe(@CurrentUser() user: JwtPayload) { return this.usersService.findById(user.sub); } + + @Patch('me') + @ApiOperation({ summary: '更新当前用户资料(昵称/头像)' }) + @ApiOkResponse({ description: '更新后的用户' }) + async updateMe(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { + return this.usersService.updateProfile(user.sub, dto); + } }