feat: M2-01 — User & Account deepening, membership + deletion + devices
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 33s

- 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 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 11:18:56 +08:00
parent 14eaad53c3
commit 292e7e5638
9 changed files with 344 additions and 24 deletions

View File

@ -48,6 +48,8 @@ jobs:
$MYSQL_CMD -e "DROP TABLE IF EXISTS ProviderConfig;" 2>/dev/null || true $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 FallbackEvent;" 2>/dev/null || true
$MYSQL_CMD -e "DROP TABLE IF EXISTS ViolationRecord;" 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 objectKey;" 2>/dev/null || true
$MYSQL_CMD -e "ALTER TABLE UploadedFile DROP COLUMN bucket;" 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 $MYSQL_CMD -e "DROP INDEX UploadedFile_objectKey_idx ON UploadedFile;" 2>/dev/null || true

View File

@ -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;

View File

@ -1051,6 +1051,36 @@ model UserMembership {
@@index([userId]) @@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 { model QuotaUsage {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String

View File

@ -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' } });
}
}

View File

@ -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 { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ -10,9 +10,10 @@ import type { UserPayload } from '../../common/types';
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
// ── Profile ──
@Get('me') @Get('me')
@ApiOperation({ summary: '获取当前用户信息' }) @ApiOperation({ summary: '获取当前用户信息' })
@ApiResponse({ status: 200, description: '用户信息' })
async getProfile(@CurrentUser() user: UserPayload) { async getProfile(@CurrentUser() user: UserPayload) {
return this.usersService.getProfile(String(user.id)); return this.usersService.getProfile(String(user.id));
} }
@ -23,15 +24,8 @@ export class UsersController {
return this.usersService.updateProfile(String(user.id), body); 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') @Get('me/profile')
@ApiOperation({ summary: '获取用户学习档案' }) @ApiOperation({ summary: '获取用户学习档案' })
@ApiResponse({ status: 200, description: '用户学习档案' })
async getProfileDetail(@CurrentUser() user: UserPayload) { async getProfileDetail(@CurrentUser() user: UserPayload) {
return this.usersService.getProfileDetail(String(user.id)); return this.usersService.getProfileDetail(String(user.id));
} }
@ -41,4 +35,46 @@ export class UsersController {
async updateProfileDetail(@CurrentUser() user: UserPayload, @Body() body: any) { async updateProfileDetail(@CurrentUser() user: UserPayload, @Body() body: any) {
return this.usersService.updateProfileDetail(String(user.id), body); 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);
}
} }

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { AdminUsersMgmtController } from './admin-users-mgmt.controller';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UsersRepository } from './users.repository'; import { UsersRepository } from './users.repository';
import { PrismaService } from '../../infrastructure/database/prisma.service';
@Module({ @Module({
controllers: [UsersController], controllers: [UsersController, AdminUsersMgmtController],
providers: [UsersService, UsersRepository], providers: [UsersService, UsersRepository, PrismaService],
exports: [UsersService], exports: [UsersService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@ -1,27 +1,71 @@
import { Injectable } from '@nestjs/common'; import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { UsersRepository } from './users.repository'; import { UsersRepository } from './users.repository';
import { PrismaService } from '../../infrastructure/database/prisma.service';
const DELETION_COOLING_DAYS = 7;
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private readonly usersRepository: UsersRepository) {} constructor(
private readonly usersRepository: UsersRepository,
private readonly prisma: PrismaService,
) {}
async getProfile(userId: string) { async getProfile(userId: string) { return this.usersRepository.findProfileByUserId(userId); }
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) { // ── Account Deletion ──
return this.usersRepository.updateProfile(userId, dto);
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) { async cancelDeletion(userId: string) {
return this.usersRepository.findUserProfile(userId); 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) { // ── Device Management ──
return this.usersRepository.upsertUserProfile(userId, dto);
async getDevices(userId: string) {
return this.prisma.userDevice.findMany({
where: { userId },
orderBy: { lastSeenAt: 'desc' },
});
} }
async updatePreferences(userId: string, dto: any) { async removeDevice(userId: string, deviceId: string) {
return this.usersRepository.updatePreferences(userId, dto); await this.prisma.userDevice.deleteMany({
where: { userId, deviceId },
});
return { success: true };
} }
} }

95
test/m2.e2e-spec.ts Normal file
View File

@ -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<string> {
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);
});
});
});

View File

@ -90,7 +90,7 @@ const modelNames = [
'contentSafetyCheck', 'contentReport', 'apiMetric', 'taskLog', 'contentSafetyCheck', 'contentReport', 'apiMetric', 'taskLog',
'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord', 'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord',
'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent', 'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent',
'violationRecord', 'contentReport', 'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest',
] ]
for (const name of modelNames) { for (const name of modelNames) {