feat: M2-01 — User & Account deepening, membership + deletion + devices
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 33s
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:
parent
14eaad53c3
commit
292e7e5638
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
80
src/modules/users/admin-users-mgmt.controller.ts
Normal file
80
src/modules/users/admin-users-mgmt.controller.ts
Normal 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' } });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
95
test/m2.e2e-spec.ts
Normal file
95
test/m2.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user