Merge branch 'feat/r2-address-design' into main

R2 地址与设计清单完整 CRUD(事务/归属校验/状态机/2MB 限制)、
PATCH /api/users/me 补齐、api-contract §5 措辞修正与实现注记

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-12 20:41:19 +08:00
co-authored by Claude
13 changed files with 442 additions and 32 deletions
+15 -2
View File
@@ -176,9 +176,22 @@
> **designData 结构遵循 `wechat_wc/docs/design-data-contract-v1.md`R2/R4 冻结契约)。** > **designData 结构遵循 `wechat_wc/docs/design-data-contract-v1.md`R2/R4 冻结契约)。**
> 该契约保证 R4 下单后能据此构造 `.wcd` 投递到词云平台。要点: > 该契约保证 R4 下单后能据此构造 `.wcd` 投递到词云平台。要点:
> 贴纸图 `src` 必须为 COS 持久 URL(禁止 `wxfile://`/`tmp`)、保留 `wordcloud` 分组、 > 贴纸图 `src` 在**派单前**必须为 COS 持久 URLR2 保存/更新清单时允许暂存
> 保留 `category.mask`;后端该 JSON 白名单须放行 `version/background/wordcloud/rotation/zIndex`。 > `wxfile://`/`tmp` 本地路径,由 R4 在下单/派单前上传 COS 并回写
> design-data-contract-v1.md 约束#1、决策#42026-08-12 冻结);
> 保留 `wordcloud` 分组、保留 `category.mask`;后端该 JSON 白名单须放行
> `version/background/wordcloud/rotation/zIndex`。
>`items` 为服务端 JSON,需做结构白名单与大小校验(单条 ≤ 1MB)。 >`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 ### PATCH /api/design-list/:id
+28 -3
View File
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { AddressesService } from './addresses.service'; import { AddressesService } from './addresses.service';
import { CreateAddressDto } from './dto/create-address.dto'; import { CreateAddressDto } from './dto/create-address.dto';
import { UpdateAddressDto } from './dto/update-address.dto';
@ApiTags('收货地址') @ApiTags('收货地址')
@ApiBearerAuth() @ApiBearerAuth()
@@ -11,13 +20,13 @@ export class AddressesController {
constructor(private readonly addressesService: AddressesService) {} constructor(private readonly addressesService: AddressesService) {}
@Get() @Get()
@ApiOperation({ summary: '我的收货地址列表' }) @ApiOperation({ summary: '我的收货地址列表(默认地址在前)' })
list(@CurrentUser() user: JwtPayload) { list(@CurrentUser() user: JwtPayload) {
return this.addressesService.listByUser(user.sub); return this.addressesService.listByUser(user.sub);
} }
@Post() @Post()
@ApiOperation({ summary: '新增收货地址' }) @ApiOperation({ summary: '新增收货地址(首个地址自动设为默认)' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateAddressDto) { create(@CurrentUser() user: JwtPayload, @Body() dto: CreateAddressDto) {
return this.addressesService.create(user.sub, dto); return this.addressesService.create(user.sub, dto);
} }
@@ -27,4 +36,20 @@ export class AddressesController {
setDefault(@CurrentUser() user: JwtPayload, @Param('id') id: string) { setDefault(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.addressesService.setDefault(user.sub, id); 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);
}
} }
+71 -5
View File
@@ -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 { PrismaService } from '../prisma/prisma.service';
import { CreateAddressDto } from './dto/create-address.dto'; import { CreateAddressDto } from './dto/create-address.dto';
import { UpdateAddressDto } from './dto/update-address.dto';
@Injectable() @Injectable()
export class AddressesService { export class AddressesService {
@@ -14,12 +16,76 @@ export class AddressesService {
} }
async create(userId: string, dto: CreateAddressDto) { async create(userId: string, dto: CreateAddressDto) {
// TODO: 若 isDefault,需先把该用户其它地址置为非默认(事务) return this.prisma.$transaction(async (tx) => {
return this.prisma.address.create({ data: { ...dto, userId } }); // 首个地址强制为默认(列表非空时默认地址始终存在)
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) { async setDefault(userId: string, id: string) {
// TODO: 事务内先清旧默认再设新默认 const existing = await this.getOwnedAddress(userId, id);
return this.prisma.address.update({ where: { id }, data: { isDefault: true } }); 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,存在但非本人 403api-contract-v1 §4 */
private async getOwnedAddress(userId: string, id: string): Promise<Address> {
const address = await this.prisma.address.findUnique({ where: { id } });
if (!address) throw new NotFoundException('地址不存在');
if (address.userId !== userId) throw new ForbiddenException('无权操作该地址');
return address;
} }
} }
+46
View File
@@ -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;
}
@@ -34,6 +34,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
? res ? res
: ((res as Record<string, unknown>).message as string | string[] | undefined)?.toString() ?? : ((res as Record<string, unknown>).message as string | string[] | undefined)?.toString() ??
exception.message; 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) { } else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
// 常见 Prisma 错误码映射 // 常见 Prisma 错误码映射
switch (exception.code) { switch (exception.code) {
+32 -7
View File
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { DesignListService } from './design-list.service'; import { DesignListService } from './design-list.service';
import { BatchDeleteDesignListDto } from './dto/batch-delete-design-list.dto';
import { CreateDesignListDto } from './dto/create-design-list.dto'; import { CreateDesignListDto } from './dto/create-design-list.dto';
import { UpdateDesignListDto } from './dto/update-design-list.dto';
@ApiTags('设计清单') @ApiTags('设计清单')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('design-lists') @Controller('design-list')
export class DesignListController { export class DesignListController {
constructor(private readonly designListService: DesignListService) {} constructor(private readonly designListService: DesignListService) {}
@Get() @Get()
@ApiOperation({ summary: '我的设计清单列表' }) @ApiOperation({ summary: '我的设计清单createdAt 倒序,不分页)' })
list(@CurrentUser() user: JwtPayload) { list(@CurrentUser() user: JwtPayload) {
return this.designListService.listByUser(user.sub); return this.designListService.listByUser(user.sub);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: '设计清单详情' }) @ApiOperation({ summary: '查询单条本人清单' })
findOne(@Param('id') id: string) { findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.designListService.findOne(id); return this.designListService.findOne(user.sub, id);
} }
@Post() @Post()
@ApiOperation({ summary: '创建设计清单' }) @ApiOperation({ summary: '创建清单(一条设计一条清单,items 固定 1 个元素)' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateDesignListDto) { create(@CurrentUser() user: JwtPayload, @Body() dto: CreateDesignListDto) {
return this.designListService.create(user.sub, dto); 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);
}
} }
+102 -7
View File
@@ -1,7 +1,32 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { DesignList, DesignListStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { BatchDeleteDesignListDto } from './dto/batch-delete-design-list.dto';
import { CreateDesignListDto } from './dto/create-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 §51MB */
const DESIGN_DATA_MAX_BYTES = 1024 * 1024;
/** 状态机:单向推进(阶段0 决策#6),前端只允许提交 DRAFT→SUBMITTED */
const STATUS_TRANSITIONS: Record<DesignListStatus, DesignListStatus[]> = {
DRAFT: [DesignListStatus.SUBMITTED],
SUBMITTED: [DesignListStatus.PROCESSING],
PROCESSING: [DesignListStatus.DONE],
DONE: [],
};
@Injectable() @Injectable()
export class DesignListService { export class DesignListService {
@@ -14,19 +39,89 @@ export class DesignListService {
}); });
} }
async findOne(id: string) { async findOne(userId: string, id: string) {
// TODO: 权限校验(仅本人可查) const list = await this.getOwnedList(userId, id);
return this.prisma.designList.findUnique({ where: { id } }); return list;
} }
async create(userId: string, dto: CreateDesignListDto) { async create(userId: string, dto: CreateDesignListDto) {
// TODO: 校验 items 结构、关联商品 SKU const items = dto.items.map((it) => this.validateDesignData(it));
return this.prisma.designList.create({ return this.prisma.designList.create({
data: { data: {
title: dto.title, title: dto.title,
items: (dto.items ?? []) as Prisma.InputJsonValue, items: this.toJson(items),
userId, userId,
}, },
}); });
} }
async update(userId: string, id: string, dto: UpdateDesignListDto) {
const existing = await this.getOwnedList(userId, id);
// 部分更新语义:只覆盖显式传入的字段,不传 items 不清 designDataR4 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<DesignList> {
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<T extends { designData?: Record<string, unknown> }>(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}`);
}
}
} }
@@ -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[];
}
+56 -6
View File
@@ -1,14 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 = 一条后端 DesignListitems 固定 1 个元素;
* 决策#3productIcon 不入库,由客户端按 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<string, unknown>;
}
export class CreateDesignListDto { export class CreateDesignListDto {
@ApiProperty({ description: '清单标题' }) @ApiProperty({ description: '清单标题;前端传 productName(阶段0 决策#4' })
@IsString() @IsString()
@IsNotEmpty()
title!: string; title!: string;
@ApiPropertyOptional({ description: '定制项(结构化 JSON', example: [{ sku: 'tshirt', color: 'black' }] }) @ApiProperty({ type: [DesignListEntryDto] })
@IsOptional()
@IsArray() @IsArray()
@IsObject({ each: true }) @ArrayMinSize(1)
items?: Record<string, unknown>[]; @ArrayMaxSize(1)
@ValidateNested({ each: true })
@Type(() => DesignListEntryDto)
items!: DesignListEntryDto[];
} }
@@ -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;
}
+6 -1
View File
@@ -2,12 +2,17 @@ import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule); // 设计清单 designData 契约上限 1MBapi-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); const config = app.get(ConfigService);
app.setGlobalPrefix('api', { exclude: ['health'] }); app.setGlobalPrefix('api', { exclude: ['health'] });
+17
View File
@@ -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;
}
+9 -1
View File
@@ -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 { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UpdateProfileDto } from './dto/update-profile.dto';
@ApiTags('用户') @ApiTags('用户')
@ApiBearerAuth() @ApiBearerAuth()
@@ -15,4 +16,11 @@ export class UsersController {
async getMe(@CurrentUser() user: JwtPayload) { async getMe(@CurrentUser() user: JwtPayload) {
return this.usersService.findById(user.sub); 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);
}
} }