From 292e7e563835029ce397b31cb85ab3c5e304934d Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 24 May 2026 11:18:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M2-01=20=E2=80=94=20User=20&=20Account?= =?UTF-8?q?=20deepening,=20membership=20+=20deletion=20+=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserDevice + AccountDeletionRequest Prisma models - CAPI: membership query, deletion request/cancel, device list/remove - AAPI: membership assign, deletion approve/reject, device view Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/deploy.yml | 2 + .../migration.sql | 31 ++++++ prisma/schema.prisma | 30 ++++++ .../users/admin-users-mgmt.controller.ts | 80 ++++++++++++++++ src/modules/users/users.controller.ts | 54 +++++++++-- src/modules/users/users.module.ts | 6 +- src/modules/users/users.service.ts | 68 ++++++++++--- test/m2.e2e-spec.ts | 95 +++++++++++++++++++ test/mocks/prisma.mock.ts | 2 +- 9 files changed, 344 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20260525000000_add_user_device_deletion/migration.sql create mode 100644 src/modules/users/admin-users-mgmt.controller.ts create mode 100644 test/m2.e2e-spec.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5d2df88..ec9e0e1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -48,6 +48,8 @@ jobs: $MYSQL_CMD -e "DROP TABLE IF EXISTS ProviderConfig;" 2>/dev/null || true $MYSQL_CMD -e "DROP TABLE IF EXISTS FallbackEvent;" 2>/dev/null || true $MYSQL_CMD -e "DROP TABLE IF EXISTS ViolationRecord;" 2>/dev/null || true + $MYSQL_CMD -e "DROP TABLE IF EXISTS UserDevice;" 2>/dev/null || true + $MYSQL_CMD -e "DROP TABLE IF EXISTS AccountDeletionRequest;" 2>/dev/null || true $MYSQL_CMD -e "ALTER TABLE UploadedFile DROP COLUMN objectKey;" 2>/dev/null || true $MYSQL_CMD -e "ALTER TABLE UploadedFile DROP COLUMN bucket;" 2>/dev/null || true $MYSQL_CMD -e "DROP INDEX UploadedFile_objectKey_idx ON UploadedFile;" 2>/dev/null || true diff --git a/prisma/migrations/20260525000000_add_user_device_deletion/migration.sql b/prisma/migrations/20260525000000_add_user_device_deletion/migration.sql new file mode 100644 index 0000000..372671d --- /dev/null +++ b/prisma/migrations/20260525000000_add_user_device_deletion/migration.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS `UserDevice` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `deviceId` VARCHAR(255) NOT NULL, + `deviceName` VARCHAR(100) NULL, + `osVersion` VARCHAR(50) NULL, + `pushToken` VARCHAR(500) NULL, + `lastSeenAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `UserDevice_userId_deviceId_key`(`userId`, `deviceId`), + INDEX `UserDevice_userId_idx`(`userId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `AccountDeletionRequest` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'pending', + `requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `coolingEndsAt` DATETIME(3) NOT NULL, + `reviewedBy` VARCHAR(100) NULL, + `reviewedAt` DATETIME(3) NULL, + `completedAt` DATETIME(3) NULL, + `cancelledAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `AccountDeletionRequest_userId_idx`(`userId`), + INDEX `AccountDeletionRequest_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ac869e8..aa860cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1051,6 +1051,36 @@ model UserMembership { @@index([userId]) } +model UserDevice { + id String @id @default(cuid()) + userId String + deviceId String @db.VarChar(255) + deviceName String? @db.VarChar(100) + osVersion String? @db.VarChar(50) + pushToken String? @db.VarChar(500) + lastSeenAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@unique([userId, deviceId]) + @@index([userId]) +} + +model AccountDeletionRequest { + id String @id @default(cuid()) + userId String + status String @default("pending") @db.VarChar(16) + requestedAt DateTime @default(now()) + coolingEndsAt DateTime + reviewedBy String? @db.VarChar(100) + reviewedAt DateTime? + completedAt DateTime? + cancelledAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([status]) +} + model QuotaUsage { id String @id @default(cuid()) userId String diff --git a/src/modules/users/admin-users-mgmt.controller.ts b/src/modules/users/admin-users-mgmt.controller.ts new file mode 100644 index 0000000..a77979b --- /dev/null +++ b/src/modules/users/admin-users-mgmt.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get, Post, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import type { AdminRole } from '../../common/types/admin-role.enum'; + +@ApiTags('admin-users') +@Controller('admin-api/users') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +@ApiBearerAuth() +export class AdminUsersMgmtController { + constructor(private readonly prisma: PrismaService) {} + + // ── Membership ── + + @Get('memberships') + @AdminRoles('ADMIN' as AdminRole) + @ApiOperation({ summary: '用户会员列表' }) + async memberships(@Query('userId') userId?: string) { + return this.prisma.userMembership.findMany({ + where: userId ? { userId } : undefined, + include: { plan: true }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + } + + @Post('memberships') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '手动分配会员' }) + async addMembership(@Body() d: { userId: string; planId: string; expiresAt?: string }) { + return this.prisma.userMembership.create({ + data: { userId: d.userId, planId: d.planId, expiresAt: d.expiresAt ? new Date(d.expiresAt) : null }, + }); + } + + // ── Deletion Requests ── + + @Get('deletion-requests') + @AdminRoles('ADMIN' as AdminRole) + @ApiOperation({ summary: '注销申请列表' }) + async deletionRequests(@Query('status') status?: string) { + return this.prisma.accountDeletionRequest.findMany({ + where: status ? { status } : undefined, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + } + + @Post('deletion-requests/:id/approve') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '批准注销(立即执行)' }) + async approveDeletion(@Param('id') id: string) { + return this.prisma.accountDeletionRequest.update({ + where: { id }, + data: { status: 'completed', reviewedAt: new Date(), completedAt: new Date() }, + }); + } + + @Post('deletion-requests/:id/reject') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '驳回注销' }) + async rejectDeletion(@Param('id') id: string) { + return this.prisma.accountDeletionRequest.update({ + where: { id }, + data: { status: 'cancelled', reviewedAt: new Date(), cancelledAt: new Date() }, + }); + } + + // ── Devices ── + + @Get(':userId/devices') + @AdminRoles('ADMIN' as AdminRole) + @ApiOperation({ summary: '查看用户设备' }) + async userDevices(@Param('userId') userId: string) { + return this.prisma.userDevice.findMany({ where: { userId }, orderBy: { lastSeenAt: 'desc' } }); + } +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index a85ba34..5c3f71e 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Patch, Body } from '@nestjs/common'; +import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -10,9 +10,10 @@ import type { UserPayload } from '../../common/types'; export class UsersController { constructor(private readonly usersService: UsersService) {} + // ── Profile ── + @Get('me') @ApiOperation({ summary: '获取当前用户信息' }) - @ApiResponse({ status: 200, description: '用户信息' }) async getProfile(@CurrentUser() user: UserPayload) { return this.usersService.getProfile(String(user.id)); } @@ -23,15 +24,8 @@ export class UsersController { return this.usersService.updateProfile(String(user.id), body); } - @Patch('me/preferences') - @ApiOperation({ summary: '更新用户偏好' }) - async updatePreferences(@CurrentUser() user: UserPayload, @Body() body: any) { - return this.usersService.updatePreferences(String(user.id), body); - } - @Get('me/profile') @ApiOperation({ summary: '获取用户学习档案' }) - @ApiResponse({ status: 200, description: '用户学习档案' }) async getProfileDetail(@CurrentUser() user: UserPayload) { return this.usersService.getProfileDetail(String(user.id)); } @@ -41,4 +35,46 @@ export class UsersController { async updateProfileDetail(@CurrentUser() user: UserPayload, @Body() body: any) { return this.usersService.updateProfileDetail(String(user.id), body); } + + @Patch('me/preferences') + @ApiOperation({ summary: '更新用户偏好' }) + async updatePreferences(@CurrentUser() user: UserPayload, @Body() body: any) { + return this.usersService.updatePreferences(String(user.id), body); + } + + // ── Membership ── + + @Get('me/membership') + @ApiOperation({ summary: '查询当前会员状态' }) + async myMembership(@CurrentUser() user: UserPayload) { + return this.usersService.getMembership(String(user.id)); + } + + // ── Account Deletion ── + + @Post('me/deletion-request') + @ApiOperation({ summary: '申请账号注销' }) + async requestDeletion(@CurrentUser() user: UserPayload) { + return this.usersService.requestDeletion(String(user.id)); + } + + @Delete('me/deletion-request') + @ApiOperation({ summary: '撤销注销申请' }) + async cancelDeletion(@CurrentUser() user: UserPayload) { + return this.usersService.cancelDeletion(String(user.id)); + } + + // ── Device Management ── + + @Get('me/devices') + @ApiOperation({ summary: '我的设备列表' }) + async myDevices(@CurrentUser() user: UserPayload) { + return this.usersService.getDevices(String(user.id)); + } + + @Delete('me/devices/:deviceId') + @ApiOperation({ summary: '远程登出设备' }) + async removeDevice(@CurrentUser() user: UserPayload, @Param('deviceId') deviceId: string) { + return this.usersService.removeDevice(String(user.id), deviceId); + } } diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 8cce447..309bee8 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; +import { AdminUsersMgmtController } from './admin-users-mgmt.controller'; import { UsersService } from './users.service'; import { UsersRepository } from './users.repository'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Module({ - controllers: [UsersController], - providers: [UsersService, UsersRepository], + controllers: [UsersController, AdminUsersMgmtController], + providers: [UsersService, UsersRepository, PrismaService], exports: [UsersService], }) export class UsersModule {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 991faa9..248abb0 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,27 +1,71 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { UsersRepository } from './users.repository'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +const DELETION_COOLING_DAYS = 7; @Injectable() export class UsersService { - constructor(private readonly usersRepository: UsersRepository) {} + constructor( + private readonly usersRepository: UsersRepository, + private readonly prisma: PrismaService, + ) {} - async getProfile(userId: string) { - return this.usersRepository.findProfileByUserId(userId); + async getProfile(userId: string) { return this.usersRepository.findProfileByUserId(userId); } + async updateProfile(userId: string, dto: any) { return this.usersRepository.updateProfile(userId, dto); } + async getProfileDetail(userId: string) { return this.usersRepository.findUserProfile(userId); } + async updateProfileDetail(userId: string, dto: any) { return this.usersRepository.upsertUserProfile(userId, dto); } + async updatePreferences(userId: string, dto: any) { return this.usersRepository.updatePreferences(userId, dto); } + + // ── Membership ── + + async getMembership(userId: string) { + return this.prisma.userMembership.findFirst({ + where: { userId, active: true }, + include: { plan: true }, + orderBy: { createdAt: 'desc' }, + }); } - async updateProfile(userId: string, dto: any) { - return this.usersRepository.updateProfile(userId, dto); + // ── Account Deletion ── + + async requestDeletion(userId: string) { + const existing = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: 'pending' }, + }); + if (existing) throw new BadRequestException('已有进行中的注销申请'); + + const coolingEndsAt = new Date(Date.now() + DELETION_COOLING_DAYS * 86400000); + return this.prisma.accountDeletionRequest.create({ + data: { userId, coolingEndsAt }, + }); } - async getProfileDetail(userId: string) { - return this.usersRepository.findUserProfile(userId); + async cancelDeletion(userId: string) { + const req = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: 'pending' }, + }); + if (!req) throw new NotFoundException('未找到进行中的注销申请'); + + return this.prisma.accountDeletionRequest.update({ + where: { id: req.id }, + data: { status: 'cancelled', cancelledAt: new Date() }, + }); } - async updateProfileDetail(userId: string, dto: any) { - return this.usersRepository.upsertUserProfile(userId, dto); + // ── Device Management ── + + async getDevices(userId: string) { + return this.prisma.userDevice.findMany({ + where: { userId }, + orderBy: { lastSeenAt: 'desc' }, + }); } - async updatePreferences(userId: string, dto: any) { - return this.usersRepository.updatePreferences(userId, dto); + async removeDevice(userId: string, deviceId: string) { + await this.prisma.userDevice.deleteMany({ + where: { userId, deviceId }, + }); + return { success: true }; } } diff --git a/test/m2.e2e-spec.ts b/test/m2.e2e-spec.ts new file mode 100644 index 0000000..3787bf0 --- /dev/null +++ b/test/m2.e2e-spec.ts @@ -0,0 +1,95 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('M2 E2E Tests', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + async function loginAdmin(): Promise { + const res = await request(app.getHttpServer()) + .post('/admin-api/auth/login') + .send({ email: 'admin@zhixi.app', password: 'admin123' }); + return res.body?.data?.accessToken || ''; + } + + // ══════════════════════════════════════════════ + // M2-01: User & Account 深化 + // ══════════════════════════════════════════════ + describe('M2-01 User & Account Deepening', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + // ── Admin membership management ── + + it('GET /admin-api/users/memberships → 200 member list', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/users/memberships') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('POST /admin-api/users/memberships → 200 assign membership', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .post('/admin-api/users/memberships') + .set('Authorization', `Bearer ${token}`) + .send({ userId: 'user1', planId: 'plan-free' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('id'); + }); + + // ── Admin deletion requests ── + + it('GET /admin-api/users/deletion-requests → 200 list', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/users/deletion-requests') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('POST /admin-api/users/deletion-requests/:id/approve → approve', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .post('/admin-api/users/deletion-requests/test-id/approve') + .set('Authorization', `Bearer ${token}`) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('status', 'completed'); + }); + + it('POST /admin-api/users/deletion-requests/:id/reject → reject', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .post('/admin-api/users/deletion-requests/test-id2/reject') + .set('Authorization', `Bearer ${token}`) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('status', 'cancelled'); + }); + + // ── Admin device view ── + + it('GET /admin-api/users/:userId/devices → 200 device list', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/users/user1/devices') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + }); +}); diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index e37e584..9a96e44 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -90,7 +90,7 @@ const modelNames = [ 'contentSafetyCheck', 'contentReport', 'apiMetric', 'taskLog', 'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord', 'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent', - 'violationRecord', 'contentReport', + 'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest', ] for (const name of modelNames) {