feat: complete login and user management flow

This commit is contained in:
hectorzhao
2026-07-02 16:28:48 +08:00
parent 9c639d54b9
commit e26d8324c3
22 changed files with 1320 additions and 300 deletions
@@ -0,0 +1,10 @@
ALTER TABLE "User"
ADD COLUMN "email" TEXT,
ADD COLUMN "phone" TEXT,
ADD COLUMN "failedLoginCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "lockedUntil" TIMESTAMP(3),
ADD COLUMN "lastLoginAt" TIMESTAMP(3),
ADD COLUMN "deletedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
+6
View File
@@ -65,9 +65,15 @@ model User {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
username String @unique username String @unique
email String? @unique
phone String? @unique
displayName String displayName String
passwordHash String passwordHash String
status String @default("active") status String @default("active")
failedLoginCount Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+20 -5
View File
@@ -1,14 +1,29 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthService, LoginDto } from './auth.service'; import { AuthService, LoginDto } from './auth.service';
@ApiTags('auth') @ApiTags('auth')
@Controller('client/auth') @Controller()
export class AuthController { export class AuthController {
constructor(private readonly auth: AuthService) {} constructor(private readonly auth: AuthService) {}
@Post('login') @Get('admin/auth/captcha')
login(@Body() body: LoginDto) { adminCaptcha() {
return this.auth.login(body); return this.auth.createCaptcha();
}
@Post('admin/auth/login')
adminLogin(@Body() body: LoginDto) {
return this.auth.login(body, 'admin');
}
@Get('client/auth/captcha')
clientCaptcha() {
return this.auth.createCaptcha();
}
@Post('client/auth/login')
clientLogin(@Body() body: LoginDto) {
return this.auth.login(body, 'client');
} }
} }
+62
View File
@@ -0,0 +1,62 @@
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { hashPassword } from '../users/users.service';
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
const user = {
id: 'user-1',
tenantId: roleCode === 'enterprise_admin' ? 'tenant-1' : null,
username: 'user',
email: 'user@example.com',
phone: '13800000000',
displayName: '用户',
passwordHash: hashPassword('secret1'),
status: 'active',
deletedAt: null,
lockedUntil: null,
tenant: { id: 'tenant-1', name: '企业A' },
roles: [{ role: { code: roleCode } }],
...overrides,
};
return {
findByLogin: jest.fn().mockResolvedValue(user),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
};
}
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
const captcha = service.createCaptcha();
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
return service.login({
login: 'user@example.com',
password,
captchaId: captcha.captchaId,
captchaText: String(answer),
}, portal);
}
describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' }));
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
});
it('rejects enterprise admins on admin portal', async () => {
const users = createUsersMock('enterprise_admin');
const service = new AuthService(users as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
});
it('locks user after five failed password attempts', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never);
for (let index = 0; index < 5; index += 1) {
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
}
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
});
});
+98 -6
View File
@@ -1,30 +1,122 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service'; import { hashPassword, UsersService } from '../users/users.service';
export interface LoginDto { export interface LoginDto {
username: string; login: string;
password: string; password: string;
captchaId: string;
captchaText: string;
} }
type LoginPortal = 'admin' | 'client';
type CaptchaRecord = {
answer: string;
expiresAt: number;
};
const captchaStore = new Map<string, CaptchaRecord>();
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor(private readonly users: UsersService) {} constructor(private readonly users: UsersService) {}
async login(data: LoginDto) { createCaptcha() {
const user = await this.users.findByUsername(data.username); const left = Math.floor(10 + Math.random() * 40);
if (!user || user.passwordHash !== hashPassword(data.password) || user.status !== 'active') { const right = Math.floor(1 + Math.random() * 9);
throw new UnauthorizedException('Invalid username or password'); const captchaId = randomUUID();
captchaStore.set(captchaId, {
answer: String(left + right),
expiresAt: Date.now() + 5 * 60 * 1000,
});
return {
captchaId,
challenge: `${left} + ${right} = ?`,
expiresInSeconds: 300,
};
}
async login(data: LoginDto, portal: LoginPortal) {
const login = data.login?.trim();
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
} }
this.verifyCaptcha(data.captchaId, data.captchaText);
this.assertAnonymousNotLocked(login);
const user = await this.users.findByLogin(login);
if (!user) {
this.recordAnonymousFailure(login);
throw new UnauthorizedException('Invalid login or password');
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
if (user.status !== 'active' || user.deletedAt) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted');
}
if (user.passwordHash !== hashPassword(data.password)) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password');
}
const roleCodes = user.roles.map((item) => item.role.code);
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only platform admins can login to admin portal');
}
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
}
await this.users.recordLoginSuccess(user.id);
anonymousFailures.delete(login);
return { return {
accessToken: `dev-token-${user.id}`, accessToken: `dev-token-${user.id}`,
tokenType: 'Bearer', tokenType: 'Bearer',
portal,
user: { user: {
id: user.id, id: user.id,
tenantId: user.tenantId, tenantId: user.tenantId,
tenantName: user.tenant?.name,
username: user.username, username: user.username,
email: user.email,
phone: user.phone,
displayName: user.displayName, displayName: user.displayName,
roles: roleCodes,
}, },
}; };
} }
private verifyCaptcha(captchaId?: string, captchaText?: string) {
const record = captchaId ? captchaStore.get(captchaId) : undefined;
captchaStore.delete(captchaId ?? '');
if (!record || record.expiresAt < Date.now()) {
throw new BadRequestException('Captcha expired, refresh and try again');
}
if (record.answer !== captchaText?.trim()) {
throw new BadRequestException('Captcha is incorrect');
}
}
private assertAnonymousNotLocked(login: string) {
const current = anonymousFailures.get(login);
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
}
private recordAnonymousFailure(login: string) {
const current = anonymousFailures.get(login) ?? { count: 0 };
const count = current.count + 1;
anonymousFailures.set(login, {
count,
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
});
}
} }
+70 -12
View File
@@ -1,56 +1,114 @@
import { Body, Controller, Get, Post } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator'; import { TenantId } from '../common/tenant-id.decorator';
import { import {
AssignPermissionDto, AssignPermissionDto,
AssignRoleDto, AssignRoleDto,
ChangePasswordDto,
ChangeUserStatusDto,
CreatePermissionDto, CreatePermissionDto,
CreateRoleDto, CreateRoleDto,
CreateUserDto, CreateUserDto,
UpdateUserDto,
UsersService, UsersService,
} from './users.service'; } from './users.service';
@ApiTags('users') @ApiTags('users')
@Controller('admin/users') @Controller()
export class UsersController { export class UsersController {
constructor(private readonly users: UsersService) {} constructor(private readonly users: UsersService) {}
@Get() @Get('admin/users')
list(@TenantId() tenantId?: string) { list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) {
return this.users.list(tenantId); return this.users.list(tenantId, roleCode);
} }
@Post() @Post('admin/users')
create(@Body() body: CreateUserDto) { create(@Body() body: CreateUserDto) {
return this.users.create(body); return this.users.create(body);
} }
@Get('roles') @Put('admin/users/:id')
update(@Param('id') id: string, @Body() body: UpdateUserDto) {
return this.users.update(id, body);
}
@Patch('admin/users/:id')
patch(@Param('id') id: string, @Body() body: UpdateUserDto) {
return this.users.update(id, body);
}
@Post('admin/users/:id/status')
changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto) {
return this.users.changeStatus(id, body);
}
@Post('admin/users/:id/password')
changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto) {
return this.users.changePassword(id, body);
}
@Delete('admin/users/:id')
remove(@Param('id') id: string, @Body('operatorId') operatorId?: string) {
return this.users.remove(id, operatorId);
}
@Get('client/users')
listClient(@TenantId() tenantId?: string) {
return this.users.listClientUsers(tenantId);
}
@Post('client/users')
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto) {
return this.users.create({ ...body, roleCode: 'enterprise_admin' }, tenantId);
}
@Put('client/users/:id')
updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin' }, tenantId);
}
@Post('client/users/:id/status')
changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto) {
return this.users.changeStatus(id, body, tenantId);
}
@Post('client/users/:id/password')
changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto) {
return this.users.changePassword(id, body, tenantId);
}
@Delete('client/users/:id')
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body('operatorId') operatorId?: string) {
return this.users.remove(id, operatorId, tenantId);
}
@Get('admin/users/roles')
listRoles() { listRoles() {
return this.users.listRoles(); return this.users.listRoles();
} }
@Post('roles') @Post('admin/users/roles')
createRole(@Body() body: CreateRoleDto) { createRole(@Body() body: CreateRoleDto) {
return this.users.createRole(body); return this.users.createRole(body);
} }
@Get('permissions') @Get('admin/users/permissions')
listPermissions() { listPermissions() {
return this.users.listPermissions(); return this.users.listPermissions();
} }
@Post('permissions') @Post('admin/users/permissions')
createPermission(@Body() body: CreatePermissionDto) { createPermission(@Body() body: CreatePermissionDto) {
return this.users.createPermission(body); return this.users.createPermission(body);
} }
@Post('roles/assign') @Post('admin/users/roles/assign')
assignRole(@Body() body: AssignRoleDto) { assignRole(@Body() body: AssignRoleDto) {
return this.users.assignRole(body); return this.users.assignRole(body);
} }
@Post('permissions/assign') @Post('admin/users/permissions/assign')
assignPermission(@Body() body: AssignPermissionDto) { assignPermission(@Body() body: AssignPermissionDto) {
return this.users.assignPermission(body); return this.users.assignPermission(body);
} }
+87
View File
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import { UsersService } from './users.service';
function createPrismaMock() {
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
return {
user: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'user-1', ...data, tenant: null, roles: [{ role: roles.get('platform_admin') ?? roles.get('enterprise_admin') }] })),
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
role: {
upsert: jest.fn().mockImplementation(({ where, create, update }) => {
const role = { id: `role-${where.code}`, ...create, ...update };
roles.set(where.code, role);
return Promise.resolve(role);
}),
findMany: jest.fn(),
create: jest.fn(),
},
userRole: {
upsert: jest.fn(),
deleteMany: jest.fn(),
create: jest.fn(),
},
permission: { findMany: jest.fn(), create: jest.fn() },
rolePermission: { upsert: jest.fn() },
operationLog: { create: jest.fn() },
$transaction: jest.fn((callback) => callback({
user: {
update: jest.fn().mockResolvedValue({ id: 'user-1', username: 'u', roles: [] }),
},
userRole: {
deleteMany: jest.fn(),
create: jest.fn(),
},
})),
};
}
describe('UsersService', () => {
it('requires enterprise admins to be linked to a tenant', async () => {
const service = new UsersService(createPrismaMock() as never);
await expect(service.create({
displayName: '企业管理员',
email: 'tenant@example.com',
password: 'secret1',
roleCode: 'enterprise_admin',
})).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects platform admins linked to tenants', async () => {
const service = new UsersService(createPrismaMock() as never);
await expect(service.create({
tenantId: 'tenant-1',
displayName: '平台管理员',
email: 'admin@example.com',
password: 'secret1',
roleCode: 'platform_admin',
})).rejects.toThrow('platform_admin must not be linked to a tenant');
});
it('creates platform admins and writes operation logs', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await service.create({
displayName: '平台管理员',
email: 'admin@example.com',
phone: '13800000000',
password: 'secret1',
roleCode: 'platform_admin',
operatorId: 'operator-1',
});
expect(prisma.user.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
email: 'admin@example.com',
phone: '13800000000',
status: 'active',
}),
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ action: 'user.created', resource: 'user' }),
}));
});
});
+227 -12
View File
@@ -1,13 +1,41 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
export interface CreateUserDto { export interface CreateUserDto {
tenantId?: string; tenantId?: string;
username: string; username?: string;
email?: string;
phone?: string;
displayName: string; displayName: string;
password: string; password: string;
status?: string; status?: string;
roleCode: UserRoleCode;
operatorId?: string;
}
export interface UpdateUserDto {
tenantId?: string | null;
username?: string;
email?: string | null;
phone?: string | null;
displayName?: string;
status?: string;
roleCode?: UserRoleCode;
operatorId?: string;
}
export interface ChangeUserStatusDto {
status: 'active' | 'disabled';
operatorId?: string;
}
export interface ChangePasswordDto {
password: string;
operatorId?: string;
} }
export interface CreateRoleDto { export interface CreateRoleDto {
@@ -33,31 +61,159 @@ export interface AssignPermissionDto {
permissionId: string; permissionId: string;
} }
const roleNames: Record<UserRoleCode, { name: string; scope: string }> = {
platform_admin: { name: '平台管理员', scope: 'platform' },
enterprise_admin: { name: '企业管理员', scope: 'tenant' },
};
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) { list(tenantId?: string, roleCode?: string) {
return this.prisma.user.findMany({ return this.prisma.user.findMany({
where: tenantId ? { tenantId } : undefined, where: {
include: { roles: { include: { role: true } } }, deletedAt: null,
...(tenantId ? { tenantId } : {}),
...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}),
},
include: { tenant: true, roles: { include: { role: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 100, take: 200,
}); });
} }
findByUsername(username: string) { listClientUsers(tenantId?: string) {
return this.prisma.user.findUnique({ where: { username } }); if (!tenantId) {
throw new BadRequestException('tenantId is required for client user management');
}
return this.list(tenantId);
} }
create(data: CreateUserDto) { findByUsername(username: string) {
return this.prisma.user.create({ return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } });
}
findByLogin(login: string) {
return this.prisma.user.findFirst({
where: {
deletedAt: null,
OR: [{ username: login }, { email: login }, { phone: login }],
},
include: { tenant: true, roles: { include: { role: true } } },
});
}
async create(data: CreateUserDto, scopeTenantId?: string) {
const roleCode = data.roleCode;
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
const tenantId = scopeTenantId ?? data.tenantId;
const role = await this.ensureRole(roleCode);
const user = await this.prisma.user.create({
data: { data: {
tenantId: data.tenantId, tenantId,
username: data.username, username: data.username ?? data.email ?? data.phone ?? '',
email: normalizeOptional(data.email),
phone: normalizeOptional(data.phone),
displayName: data.displayName, displayName: data.displayName,
passwordHash: hashPassword(data.password), passwordHash: hashPassword(data.password),
status: data.status ?? 'active', status: data.status ?? 'active',
roles: { create: [{ roleId: role.id }] },
},
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(tenantId, data.operatorId, 'user.created', user.id, { roleCode, username: user.username });
return user;
}
async update(id: string, data: UpdateUserDto, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const roleCode = data.roleCode ?? current.roles[0]?.role.code as UserRoleCode | undefined;
this.assertUserInput({
tenantId: data.tenantId ?? current.tenantId ?? undefined,
username: data.username ?? current.username,
email: data.email === undefined ? current.email ?? undefined : data.email ?? undefined,
phone: data.phone === undefined ? current.phone ?? undefined : data.phone ?? undefined,
displayName: data.displayName ?? current.displayName,
password: 'unchanged-password',
roleCode: roleCode ?? 'enterprise_admin',
}, scopeTenantId, false);
const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId;
const role = roleCode ? await this.ensureRole(roleCode) : null;
const updated = await this.prisma.$transaction(async (tx) => {
if (role) {
await tx.userRole.deleteMany({ where: { userId: id } });
await tx.userRole.create({ data: { userId: id, roleId: role.id } });
}
return tx.user.update({
where: { id },
data: {
tenantId,
username: data.username ?? current.username,
email: data.email === undefined ? undefined : normalizeOptional(data.email),
phone: data.phone === undefined ? undefined : normalizeOptional(data.phone),
displayName: data.displayName ?? current.displayName,
status: data.status ?? current.status,
},
include: { tenant: true, roles: { include: { role: true } } },
});
});
await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username });
return updated;
}
async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: data.status },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username });
return updated;
}
async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) {
if (!data.password || data.password.length < 6) {
throw new BadRequestException('password must be at least 6 characters');
}
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
return updated;
}
async remove(id: string, operatorId?: string, scopeTenantId?: string) {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: 'deleted', deletedAt: new Date() },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username });
return updated;
}
async recordLoginSuccess(id: string) {
return this.prisma.user.update({
where: { id },
data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() },
});
}
async recordLoginFailure(id: string) {
const current = await this.prisma.user.findUnique({ where: { id } });
if (!current) return null;
const failedLoginCount = current.failedLoginCount + 1;
return this.prisma.user.update({
where: { id },
data: {
failedLoginCount,
lockedUntil: failedLoginCount >= 5 ? new Date(Date.now() + 24 * 60 * 60 * 1000) : current.lockedUntil,
}, },
}); });
} }
@@ -104,6 +260,65 @@ export class UsersService {
create: data, create: data,
}); });
} }
private async ensureRole(code: UserRoleCode) {
const info = roleNames[code];
return this.prisma.role.upsert({
where: { code },
update: { name: info.name, scope: info.scope },
create: { code, name: info.name, scope: info.scope },
});
}
private async getExisting(id: string, scopeTenantId?: string) {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null, ...(scopeTenantId ? { tenantId: scopeTenantId } : {}) },
include: { tenant: true, roles: { include: { role: true } } },
});
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
private assertUserInput(data: CreateUserDto, scopeTenantId: string | undefined, requirePassword: boolean) {
if (!data.displayName?.trim()) {
throw new BadRequestException('displayName is required');
}
if (requirePassword && (!data.password || data.password.length < 6)) {
throw new BadRequestException('password must be at least 6 characters');
}
if (!data.email && !data.phone) {
throw new BadRequestException('email or phone is required');
}
if (data.roleCode === 'platform_admin' && (scopeTenantId || data.tenantId)) {
throw new BadRequestException('platform_admin must not be linked to a tenant');
}
if (data.roleCode === 'enterprise_admin' && !(scopeTenantId || data.tenantId)) {
throw new BadRequestException('enterprise_admin must be linked to a tenant');
}
if (scopeTenantId && data.roleCode === 'platform_admin') {
throw new BadRequestException('client user management cannot create platform admins');
}
}
private writeLog(tenantId: string | null | undefined, userId: string | undefined, action: string, resourceId: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: {
tenantId: tenantId ?? undefined,
userId,
action,
resource: 'user',
resourceId,
detail: detail as Prisma.InputJsonObject,
},
});
}
}
function normalizeOptional(value?: string | null) {
const next = value?.trim();
return next ? next : null;
} }
export function hashPassword(password: string) { export function hashPassword(password: string) {
+32 -2
View File
@@ -835,7 +835,7 @@
2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。 2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。
3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。 3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。
4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。 4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。
5. 原型阶段的 mock、localStorage 或静态数据只能作为开发临时兜底,不得作为真实开发完成标准;审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写。 5. 原型阶段的 mock、localStorage 或静态数据只能用于早期界面占位;进入系统功能验收后不得继续作为兜底通过路径。审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写;API 不可用时应展示错误态或空态,并将用例标记为阻塞或未通过
6. 补充错误处理、权限校验、操作日志。 6. 补充错误处理、权限校验、操作日志。
7. 运行构建和相关测试,并更新测试进度文档。 7. 运行构建和相关测试,并更新测试进度文档。
``` ```
@@ -1158,10 +1158,40 @@
- 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。 - 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。
- NestJS 负责业务审核、风控、计费、报备、路由和发送编排。 - NestJS 负责业务审核、风控、计费、报备、路由和发送编排。
- Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。 - Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。
- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据只能临时兜底,不能作为功能完成标准。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补测试。 - 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据不得作为功能完成标准,也不得作为验收兜底通过路径。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补真实后端 smoke 或 E2E 测试。
请从 docs/first-version-development-requirements.md 的“阶段 0:技术 Spike”开始执行。 请从 docs/first-version-development-requirements.md 的“阶段 0:技术 Spike”开始执行。
## 追加需求:登录与用户管理闭环
### 1. 登录入口
- 客户端和运营端必须使用独立登录页面:客户端 `/client/login`,运营端 `/admin/login`。
- 两端登录均输入邮箱或手机号、密码和图形验证码。
- 登录接口必须调用真实 NestJS API,不允许前端静态用户、localStorage mock 或纯前端验证码作为验收依据。
- 运营端登录仅允许平台管理员;客户端登录仅允许已关联企业的企业管理员。
### 2. 用户类型和企业关联
- 运营端用户管理支持创建两类用户:平台管理员、企业管理员。
- 平台管理员不得关联企业,只能登录运营端。
- 企业管理员必须关联一个企业,只能登录客户端。
- 企业管理员登录客户端后,所有客户端 API 必须继承当前企业租户范围,用户、Dashboard、账务、日志等数据不得越权访问其他企业。
### 3. 用户管理功能
- 运营端和客户端用户管理均需接入真实 API,支持添加、编辑、启用/禁用、删除、修改密码。
- 启用/禁用、删除必须弹窗确认。
- 删除采用软删除,历史系统日志、审核记录和业务记录仍可追溯;删除后用户不可登录且列表默认不展示。
- 客户端用户字段必须包含邮箱和手机号,并支持邮箱或手机号登录。
- 所有创建、编辑、启用、禁用、删除、修改密码动作必须写系统日志。
### 4. fail2ban
- 用户连续登录失败 5 次后,账号锁定 24 小时。
- 锁定状态必须写入后端持久化字段,后续登录直接拒绝。
- 登录成功后清空失败次数和锁定状态。
第一步请先不要大规模写业务代码,先输出并创建阶段 0 Spike 的最小工程计划,包括: 第一步请先不要大规模写业务代码,先输出并创建阶段 0 Spike 的最小工程计划,包括:
1. 目录结构建议。 1. 目录结构建议。
2. NestJS 与 Go Gateway 的队列消息格式。 2. NestJS 与 Go Gateway 的队列消息格式。
+22 -5
View File
@@ -2,7 +2,7 @@
## 1. 用例说明 ## 1. 用例说明
本文档补充第一版系统功能测试用例,面向验收测试、人工回归测试和后续 E2E 自动化改造。用例不依赖真实运营商 CMPP 网关Gateway 场景使用项目模拟器、队列契约或测试替身 本文档补充第一版系统功能测试用例,面向验收测试、人工回归测试和后续 E2E 自动化改造。系统功能用例必须依赖真实 NestJS API、Prisma/PostgreSQL、Redis/BullMQ、MinIO 或对应本地服务完成闭环;用例不依赖真实运营商 CMPP 网关Gateway 场景使用 Go Gateway、本地 SMSC 模拟器、队列契约和连接状态回写 API 验证
优先级定义: 优先级定义:
@@ -77,7 +77,7 @@
### TC-CLIENT-003 签名创建、材料上传与提交审核 ### TC-CLIENT-003 签名创建、材料上传与提交审核
- 优先级:P0 - 优先级:P0
- 前置条件:MinIO 不可用时使用文件服务 mock 或跳过真实上传 - 前置条件:MinIO 或等价对象存储测试服务可用;如不可用,本用例标记为阻塞或未执行,不得用文件服务 mock 作为验收通过依据
- 步骤: - 步骤:
1. 创建短信签名,填写名称、用途、引流信息。 1. 创建短信签名,填写名称、用途、引流信息。
2. 上传或登记签名证明材料。 2. 上传或登记签名证明材料。
@@ -2360,7 +2360,7 @@ npm run test:gateway
npm run verify:phase8 npm run verify:phase8
``` ```
测试环境具备 PostgreSQL、Redis、MinIO,再补充执行 E2E smoke 和真实 API HTTP 测试。 测试环境必须具备 PostgreSQL、Redis、MinIO 或等价本地服务后,才能将 E2E smoke 和真实 API HTTP 测试记为系统功能通过;缺失时对应用例标记为阻塞或未执行
## 17. 新增和更新用例细化执行清单 ## 17. 新增和更新用例细化执行清单
@@ -2370,7 +2370,7 @@ npm run verify:phase8
| 断言类型 | 检查点 | | 断言类型 | 检查点 |
| --- | --- | | --- | --- |
| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可有兜底展示,但兜底不能计入通过。 | | 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可以展示错误态或空态,但静态兜底数据不能计入通过。 |
| 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 | | 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 |
| 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 | | 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 |
| 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 | | 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 |
@@ -2421,7 +2421,7 @@ npm run verify:phase8
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | | TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 | | TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 |
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
| TC-BILLING-009 | 无权限用户、审核员、管理员、大额审批分别执行充值。 | 权限不足被拒绝并写失败日志;大额充值 pending 时不更新余额;审批通过才入账,驳回不入账。 | | TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
### 17.6 系统日志细化 ### 17.6 系统日志细化
@@ -2482,3 +2482,20 @@ npm run verify:phase8
| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 | | Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 |
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 | | 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 |
| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 | | 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 |
### 17.9 登录和用户管理闭环
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-AUTH-001 | 打开 `/admin/login`,输入平台管理员邮箱或手机号、密码和正确图形验证码。 | 登录成功进入运营端;session 用户角色为 `platform_admin`;后续运营端 API 使用真实后端。 |
| TC-AUTH-002 | 使用企业管理员账号登录 `/admin/login`。 | 登录失败;返回“仅平台管理员可登录运营端”类错误;失败次数累计。 |
| TC-AUTH-003 | 打开 `/client/login`,输入已关联企业的企业管理员邮箱或手机号、密码和正确图形验证码。 | 登录成功进入客户端;客户端 API 自动携带当前企业 `tenantId`;Dashboard、用户、日志等仅展示当前企业数据。 |
| TC-AUTH-004 | 使用平台管理员或未关联企业的用户登录 `/client/login`。 | 登录失败;不进入客户端。 |
| TC-AUTH-005 | 同一用户连续输错密码 5 次。 | 第 5 次后用户锁定 24 小时;锁定期内正确密码也被拒绝;系统记录失败次数和锁定时间。 |
| TC-AUTH-006 | 输入错误或过期图形验证码登录。 | 返回 400 可读错误;必须刷新验证码后重试。 |
| TC-USER-ADMIN-001 | 运营端新增平台管理员,填写邮箱或手机号、初始密码。 | 创建成功;用户无 `tenantId`;可登录运营端;写 `user.created` 日志。 |
| TC-USER-ADMIN-002 | 运营端新增企业管理员但不选择企业。 | 返回 400;不创建用户。 |
| TC-USER-ADMIN-003 | 运营端新增企业管理员并选择企业。 | 创建成功;用户关联企业;可登录客户端;客户端数据按该企业隔离。 |
| TC-USER-ADMIN-004 | 运营端编辑用户、启用/禁用、删除、修改密码。 | 编辑和改密调用真实 API;启停/删除有确认弹窗;删除后列表不展示且不可登录;均写系统日志。 |
| TC-USER-CLIENT-001 | 企业管理员在客户端用户管理新增同企业用户。 | 创建成功;用户自动归属当前企业;不可创建平台管理员;写系统日志。 |
| TC-USER-CLIENT-002 | 客户端编辑、启用/禁用、删除、修改密码。 | 调用真实 `/api/client/users` API;仅影响当前企业用户;启停/删除有确认弹窗。 |
+29
View File
@@ -325,3 +325,32 @@ GET /api/admin/system-logs?page=1&pageSize=2
### 剩余说明 ### 剩余说明
-`npm run dev` 为稳定预览模式,不提供 Vite HMR;保留原因是 Vite 8/Rolldown dev transform 在当前 Windows + 中文路径工作区下会阻塞模块请求并造成白屏。开发时如需热更新,可另行评估降级 Vite 或迁移工作区路径后恢复原生 dev server。 -`npm run dev` 为稳定预览模式,不提供 Vite HMR;保留原因是 Vite 8/Rolldown dev transform 在当前 Windows + 中文路径工作区下会阻塞模块请求并造成白屏。开发时如需热更新,可另行评估降级 Vite 或迁移工作区路径后恢复原生 dev server。
## 2026-07-02 登录与用户管理闭环补充
### 本轮修复范围
- 新增用户登录字段和 fail2ban 持久化字段:`email``phone``failedLoginCount``lockedUntil``lastLoginAt``deletedAt`
- 登录入口拆分为 `/client/login``/admin/login`,两端均调用真实验证码和登录 API。
- 运营端登录仅允许 `platform_admin`;客户端登录仅允许已关联企业的 `enterprise_admin`
- 运营端用户管理接入真实 `/api/admin/users`,支持平台管理员和企业管理员的新增、编辑、启用/禁用、删除、改密;企业管理员必须关联企业。
- 客户端用户管理接入真实 `/api/client/users`,所有操作继承当前登录企业 `tenantId`
- 启用/禁用、删除均通过确认弹窗执行;用户删除采用软删除,不破坏历史日志和业务记录。
- 连续 5 次登录失败后锁定 24 小时;登录成功清空失败次数和锁定状态。
### 已执行测试
```bash
npm --prefix api test -- users.service.spec.ts auth.service.spec.ts
npm --prefix api run prisma:generate
npm --prefix api run build
npm run build
```
### 当前结果
- `users.service.spec.ts``auth.service.spec.ts`:通过,覆盖用户类型约束、企业关联约束、操作日志、端登录隔离和失败次数累计。
- API build:通过。
- 前端 build:通过,仍有既有大 chunk warning。
- `tools/smoke/real-env-smoke.mjs` 已同步企业管理员邮箱/手机号、角色 seed、验证码登录和 CMPP 端口 `17890`
- 真实数据库迁移、浏览器端登录 smoke 需要在生产验证环境执行 `prisma migrate deploy` 后补充记录。
+73 -6
View File
@@ -1,3 +1,5 @@
import { getSessionTenantId, readSession, type LoginSession } from './session';
type RequestOptions = RequestInit & { type RequestOptions = RequestInit & {
tenantId?: string; tenantId?: string;
}; };
@@ -7,8 +9,13 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
if (options.tenantId) { const session = readSession();
headers.set('x-tenant-id', options.tenantId); if (session?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
if (tenantId) {
headers.set('x-tenant-id', tenantId);
} }
const response = await fetch(`/api${path}`, { ...options, headers }); const response = await fetch(`/api${path}`, { ...options, headers });
if (!response.ok) { if (!response.ok) {
@@ -83,6 +90,40 @@ export type TenantOption = {
status: string; status: string;
}; };
export type CaptchaResponse = {
captchaId: string;
challenge: string;
expiresInSeconds: number;
};
export type ManagedUser = {
id: string;
tenantId?: string | null;
username: string;
email?: string | null;
phone?: string | null;
displayName: string;
status: string;
failedLoginCount: number;
lockedUntil?: string | null;
lastLoginAt?: string | null;
createdAt: string;
tenant?: TenantOption | null;
roles: Array<{ role: { code: string; name: string; scope: string } }>;
};
export type UserPayload = {
tenantId?: string | null;
username?: string;
email?: string | null;
phone?: string | null;
displayName: string;
password?: string;
status?: string;
roleCode: 'platform_admin' | 'enterprise_admin';
operatorId?: string;
};
export type DashboardResponse = { export type DashboardResponse = {
taskCount: number; taskCount: number;
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
@@ -223,7 +264,18 @@ function withQuery(path: string, query: Record<string, string | number | undefin
} }
export const adminApi = { export const adminApi = {
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listTenants: () => request<TenantOption[]>('/admin/tenants'), listTenants: () => request<TenantOption[]>('/admin/tenants'),
listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request<ManagedUser[]>(withQuery('/admin/users', query)),
createUser: (body: UserPayload) => request<ManagedUser>('/admin/users', { method: 'POST', body: JSON.stringify(body) }),
updateUser: (id: string, body: Omit<UserPayload, 'password'>) => request<ManagedUser>(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeUserStatus: (id: string, status: string, operatorId?: string) =>
request<ManagedUser>(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }),
deleteUser: (id: string, operatorId?: string) => request<ManagedUser>(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }),
changeUserPassword: (id: string, password: string, operatorId?: string) =>
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })), getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)), request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
@@ -293,12 +345,27 @@ export const adminApi = {
}; };
export const clientApi = { export const clientApi = {
getDashboard: (tenantId = DEFAULT_CLIENT_TENANT_ID) => getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser[]>('/client/users', { tenantId }),
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DashboardResponse>('/client/operations/dashboard', { tenantId }), request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = DEFAULT_CLIENT_TENANT_ID) => listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }), request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
listTransactions: (tenantId = DEFAULT_CLIENT_TENANT_ID) => listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }), request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
listOrders: (tenantId = DEFAULT_CLIENT_TENANT_ID) => listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }), request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
}; };
+42
View File
@@ -0,0 +1,42 @@
export type Portal = 'admin' | 'client';
export type SessionUser = {
id: string;
tenantId?: string | null;
tenantName?: string | null;
username: string;
email?: string | null;
phone?: string | null;
displayName: string;
roles: string[];
};
export type LoginSession = {
accessToken: string;
tokenType: string;
portal: Portal;
user: SessionUser;
};
const sessionKey = 'cmpp-auth-session';
export function readSession(): LoginSession | null {
try {
const raw = window.localStorage.getItem(sessionKey);
return raw ? JSON.parse(raw) as LoginSession : null;
} catch {
return null;
}
}
export function writeSession(session: LoginSession) {
window.localStorage.setItem(sessionKey, JSON.stringify(session));
}
export function clearSession() {
window.localStorage.removeItem(sessionKey);
}
export function getSessionTenantId() {
return readSession()?.user.tenantId ?? undefined;
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
import { writeSession, type Portal } from '@/api/session';
import { Button, Input } from '@/components/ui';
type LoginPageProps = {
portal: Portal;
};
export function LoginPage({ portal }: LoginPageProps) {
const navigate = useNavigate();
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [captchaText, setCaptchaText] = useState('');
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const isAdmin = portal === 'admin';
async function refreshCaptcha() {
setError('');
setCaptchaText('');
setCaptcha(await (isAdmin ? adminApi.getCaptcha() : clientApi.getCaptcha()));
}
useEffect(() => {
void refreshCaptcha();
}, [portal]);
async function submit() {
if (!captcha) return;
setLoading(true);
setError('');
try {
const session = await (isAdmin ? adminApi.login : clientApi.login)({
login,
password,
captchaId: captcha.captchaId,
captchaText,
});
writeSession(session);
navigate(isAdmin ? '/admin' : '/client', { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
await refreshCaptcha();
} finally {
setLoading(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="login-brand">
<span><ShieldCheck size={28} /></span>
<div>
<h1>{isAdmin ? 'CMPP 运营端' : 'CMPP 客户端'}</h1>
<p>{isAdmin ? '平台管理员登录' : '企业管理员登录'}</p>
</div>
</div>
<div className="login-form">
<Input label="邮箱或手机号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入邮箱或手机号" value={login} />
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
<div className="login-captcha-row">
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
<button className="login-captcha" onClick={() => void refreshCaptcha()} type="button">
{captcha?.challenge ?? '刷新'}
</button>
</div>
{error ? <p className="login-error">{error}</p> : null}
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
</div>
</section>
</main>
);
}
+185 -131
View File
@@ -1,155 +1,166 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react'; import { KeyRound, Plus, Search } from 'lucide-react';
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
import { readSession } from '@/api/session';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
type UserStatus = 'enabled' | 'disabled'; type UserForm = {
tenantId: string;
type AdminUser = { displayName: string;
id: string; username: string;
name: string; email: string;
account: string;
role: string;
phone: string; phone: string;
status: UserStatus; roleCode: 'platform_admin' | 'enterprise_admin';
createdAt: string; status: string;
lastLogin: string; password: string;
}; };
const statusLabelMap: Record<UserStatus, string> = { type ConfirmAction = {
enabled: '启用', type: 'status' | 'delete';
disabled: '停用', user: ManagedUser;
}; };
const statusToneMap: Record<UserStatus, 'success' | 'neutral'> = { const emptyForm: UserForm = {
enabled: 'success', tenantId: '',
disabled: 'neutral', displayName: '',
username: '',
email: '',
phone: '',
roleCode: 'platform_admin',
status: 'active',
password: '',
}; };
const initialUsers: AdminUser[] = [ const roleLabel: Record<string, string> = {
{ id: 'USR20260630001', name: '李明', account: 'liming', role: '平台管理员', phone: '13800001234', status: 'enabled', createdAt: '2026-06-01 09:20:10', lastLogin: '2026-06-30 09:15:22' }, platform_admin: '平台管理员',
{ id: 'USR20260630002', name: '张青', account: 'zhangqing', role: '审核专员', phone: '13900005678', status: 'enabled', createdAt: '2026-06-03 14:05:36', lastLogin: '2026-06-29 18:22:11' }, enterprise_admin: '企业管理员',
{ id: 'USR20260630003', name: '王珊', account: 'wangshan', role: '运营人员', phone: '13755558888', status: 'disabled', createdAt: '2026-06-08 11:12:48', lastLogin: '2026-06-21 10:08:09' },
{ id: 'USR20260630004', name: '赵一', account: 'zhaoyi', role: '财务人员', phone: '13677779999', status: 'enabled', createdAt: '2026-06-12 16:30:00', lastLogin: '2026-06-30 08:40:18' },
];
function createUserId() {
return `USR${Date.now()}`;
}
type UserFormModalProps = {
item?: AdminUser;
onClose: () => void;
onSubmit: (item: AdminUser) => void;
}; };
function UserFormModal({ item, onClose, onSubmit }: UserFormModalProps) { function toForm(user?: ManagedUser): UserForm {
const [form, setForm] = useState<AdminUser>(() => item ?? { const roleCode = user?.roles[0]?.role.code === 'enterprise_admin' ? 'enterprise_admin' : 'platform_admin';
id: createUserId(), return user ? {
name: '', tenantId: user.tenantId ?? '',
account: '', displayName: user.displayName,
role: '运营人员', username: user.username,
phone: '', email: user.email ?? '',
status: 'enabled', phone: user.phone ?? '',
createdAt: '2026-06-30 10:00:00', roleCode,
lastLogin: '-', status: user.status,
}); password: '',
} : emptyForm;
function updateField<Key extends keyof AdminUser>(key: Key, value: AdminUser[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
function handleSubmit() {
onSubmit(form);
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={handleSubmit}></Button>
</>
)}
onClose={onClose}
open
title={item ? '编辑用户' : '新增用户'}
>
<div className="admin-system-modal-form">
<Input label="用户姓名" onChange={(event) => updateField('name', event.target.value)} value={form.name} />
<Input label="登录账号" onChange={(event) => updateField('account', event.target.value)} value={form.account} />
<Select
label="角色"
onChange={(event) => updateField('role', event.target.value)}
options={[
{ label: '平台管理员', value: '平台管理员' },
{ label: '审核专员', value: '审核专员' },
{ label: '运营人员', value: '运营人员' },
{ label: '财务人员', value: '财务人员' },
]}
value={form.role}
/>
<Input label="手机号码" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
<Select
label="状态"
onChange={(event) => updateField('status', event.target.value as UserStatus)}
options={[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]}
value={form.status}
/>
</div>
</Modal>
);
} }
export function AdminUsersPage() { export function AdminUsersPage() {
const [users, setUsers] = useState(initialUsers); const session = readSession();
const [users, setUsers] = useState<ManagedUser[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [editingUser, setEditingUser] = useState<AdminUser | null>(null); const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [form, setForm] = useState<UserForm>(emptyForm);
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [error, setError] = useState('');
const filteredUsers = useMemo( async function load() {
() => users.filter((user) => [user.name, user.account, user.role, user.phone].some((value) => value.includes(keyword))), const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
[keyword, users], setUsers(nextUsers);
); setTenants(nextTenants);
function upsertUser(nextUser: AdminUser) {
setUsers((current) => {
const exists = current.some((item) => item.id === nextUser.id);
if (exists) {
return current.map((item) => (item.id === nextUser.id ? nextUser : item));
}
return [nextUser, ...current];
});
setEditingUser(null);
setCreating(false);
} }
const columns = useMemo<Array<TableColumn<AdminUser>>>(() => [ useEffect(() => {
{ key: 'name', title: '用户姓名', width: '150px', render: (record) => <strong>{record.name}</strong> }, void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
{ key: 'account', title: '登录账号', width: '160px', render: (record) => record.account }, }, []);
{ key: 'role', title: '角色', width: '150px', render: (record) => record.role },
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => record.phone }, const filteredUsers = useMemo(() => {
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> }, const value = keyword.trim().toLowerCase();
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt }, return users.filter((user) => {
{ key: 'lastLogin', title: '最近登录', width: '190px', render: (record) => record.lastLogin }, const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
return !value || target.includes(value);
});
}, [keyword, users]);
function openCreate() {
setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' });
setCreating(true);
}
function openEdit(user: ManagedUser) {
setForm(toForm(user));
setEditingUser(user);
}
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
setForm((current) => {
const next = { ...current, [key]: value };
if (key === 'roleCode' && value === 'platform_admin') {
next.tenantId = '';
}
return next;
});
}
async function saveUser() {
setError('');
const body: UserPayload = {
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
username: form.username || form.email || form.phone,
email: form.email,
phone: form.phone,
displayName: form.displayName,
status: form.status,
roleCode: form.roleCode,
operatorId: session?.user.id,
};
if (creating) {
await adminApi.createUser({ ...body, password: form.password });
} else if (editingUser) {
await adminApi.updateUser(editingUser.id, body);
}
setCreating(false);
setEditingUser(null);
await load();
}
async function runConfirm() {
if (!confirmAction) return;
if (confirmAction.type === 'delete') {
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
} else {
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
}
setConfirmAction(null);
await load();
}
async function savePassword() {
if (!passwordUser) return;
await adminApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id);
setPasswordUser(null);
setNewPassword('');
}
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
{ key: 'displayName', title: '用户姓名', width: '140px', render: (record) => <strong>{record.displayName}</strong> },
{ key: 'account', title: '邮箱/手机号', width: '230px', render: (record) => <span>{record.email ?? '-'}<br /><small className="muted">{record.phone ?? '-'}</small></span> },
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
{ {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
width: '170px', width: '280px',
align: 'right', align: 'right',
render: (record) => ( render: (record) => (
<div className="admin-system-actions"> <div className="admin-system-actions">
<Button onClick={() => setEditingUser(record)} size="sm" variant="ghost"></Button> <Button onClick={() => openEdit(record)} size="sm" variant="ghost"></Button>
<Button <Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
onClick={() => setUsers((current) => current.filter((item) => item.id !== record.id))} <Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">
size="sm" {record.status === 'active' ? '禁用' : '启用'}
variant="danger"
>
</Button> </Button>
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div> </div>
), ),
}, },
@@ -165,16 +176,59 @@ export function AdminUsersPage() {
</div> </div>
<div className="surface admin-system-toolbar"> <div className="surface admin-system-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、号、角色或手机号" prefix={<Search size={16} />} value={keyword} /> <Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}></Button> <Button icon={<Plus size={16} />} onClick={openCreate}></Button>
</div> </div>
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface admin-system-table-card"> <div className="surface admin-system-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" /> <Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
</div> </div>
{creating ? <UserFormModal onClose={() => setCreating(false)} onSubmit={upsertUser} /> : null} {(creating || editingUser) ? (
{editingUser ? <UserFormModal item={editingUser} onClose={() => setEditingUser(null)} onSubmit={upsertUser} /> : null} <Modal
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost"></Button><Button onClick={() => void saveUser()}></Button></>}
onClose={() => { setCreating(false); setEditingUser(null); }}
open
title={creating ? '新增用户' : '编辑用户'}
>
<div className="admin-system-modal-form">
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} value={form.displayName} />
<Input label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
<Select
label="用户类型"
onChange={(event) => updateField('roleCode', event.target.value as UserForm['roleCode'])}
options={[{ label: '平台管理员', value: 'platform_admin' }, { label: '企业管理员', value: 'enterprise_admin' }]}
value={form.roleCode}
/>
{form.roleCode === 'enterprise_admin' ? (
<Select
label="关联企业"
onChange={(event) => updateField('tenantId', event.target.value)}
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
value={form.tenantId}
/>
) : null}
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
</div>
</Modal>
) : null}
{passwordUser ? (
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="ghost"></Button><Button icon={<KeyRound size={16} />} onClick={() => void savePassword()}></Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
<div className="admin-system-modal-form">
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
</div>
</Modal>
) : null}
{confirmAction ? (
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="ghost"></Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}></Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p>
</Modal>
) : null}
</section> </section>
); );
} }
+146 -113
View File
@@ -1,95 +1,138 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Edit3, Plus, Search, Trash2, Users } from 'lucide-react'; import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
import { import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
Button, import { readSession } from '@/api/session';
Input, import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
Modal,
Pagination,
Select,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
type UserRole = 'enterprise_admin' | 'user'; type UserForm = {
type UserStatus = 'active' | 'disabled'; displayName: string;
username: string;
type ClientUser = {
id: string;
name: string;
email: string; email: string;
phone: string; phone: string;
role: UserRole; status: string;
status: UserStatus; password: string;
lastLoginAt: string;
}; };
const roleLabelMap: Record<UserRole, string> = { type ConfirmAction = {
enterprise_admin: '企业管理员', type: 'status' | 'delete';
user: '普通用户', user: ManagedUser;
}; };
const usersSeed: ClientUser[] = [ const emptyForm: UserForm = {
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'enterprise_admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' }, displayName: '',
{ id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' }, username: '',
{ id: 'USER003', name: '王五', email: 'wangwu@example.com', phone: '13800138002', role: 'user', status: 'active', lastLoginAt: '2026-03-15 11:30:00' },
{ id: 'USER004', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138003', role: 'user', status: 'disabled', lastLoginAt: '2026-02-20 14:00:00' },
{ id: 'USER005', name: '孙七', email: 'sunqi@example.com', phone: '13800138004', role: 'user', status: 'active', lastLoginAt: '2026-03-17 08:00:00' },
];
const emptyUser: ClientUser = {
id: 'NEW',
name: '',
email: '', email: '',
phone: '', phone: '',
role: 'user',
status: 'active', status: 'active',
lastLoginAt: '-', password: '',
}; };
function toForm(user?: ManagedUser): UserForm {
return user ? {
displayName: user.displayName,
username: user.username,
email: user.email ?? '',
phone: user.phone ?? '',
status: user.status,
password: '',
} : emptyForm;
}
export function ClientUsersPage() { export function ClientUsersPage() {
const session = readSession();
const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState<ManagedUser[]>([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [editingUser, setEditingUser] = useState<ClientUser | null>(null); const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [draft, setDraft] = useState<ClientUser>(emptyUser); const [creating, setCreating] = useState(false);
const enterpriseAdmin = usersSeed.find((item) => item.role === 'enterprise_admin'); const [form, setForm] = useState<UserForm>(emptyForm);
const canSelectEnterpriseAdmin = !enterpriseAdmin || editingUser?.id === enterpriseAdmin.id; const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [error, setError] = useState('');
const filteredUsers = usersSeed.filter((item) => { async function load() {
const target = `${item.name} ${item.email} ${item.phone}`; if (!tenantId) return;
return !keyword || target.toLowerCase().includes(keyword.toLowerCase()); setUsers(await clientApi.listUsers(tenantId));
});
function openEditor(user?: ClientUser) {
const nextUser = user ?? emptyUser;
setEditingUser(nextUser);
setDraft(nextUser);
} }
const columns = useMemo<Array<TableColumn<ClientUser>>>(() => [ useEffect(() => {
{ key: 'name', title: '用户名', width: '120px', render: (record) => <strong className="text-strong">{record.name}</strong> }, void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email}</span> }, }, [tenantId]);
{ key: 'phone', title: '手机号', width: '180px', render: (record) => <span className="muted">{record.phone}</span> },
{ const filteredUsers = useMemo(() => {
key: 'role', const value = keyword.trim().toLowerCase();
title: '角色', return users.filter((item) => {
width: '150px', const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase();
render: (record) => <Tag tone={record.role === 'enterprise_admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>, return !value || target.includes(value);
}, });
{ }, [keyword, users]);
key: 'status',
title: '状态', function openEditor(user?: ManagedUser) {
width: '130px', setForm(toForm(user));
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag>, setEditingUser(user ?? null);
}, setCreating(!user);
{ key: 'lastLoginAt', title: '最后登录时间', width: '210px', render: (record) => <span className="muted">{record.lastLoginAt}</span> }, }
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
async function saveUser() {
const body: UserPayload = {
displayName: form.displayName,
username: form.username || form.email || form.phone,
email: form.email,
phone: form.phone,
status: form.status,
roleCode: 'enterprise_admin',
operatorId: session?.user.id,
};
if (creating) {
await clientApi.createUser({ ...body, password: form.password }, tenantId);
} else if (editingUser) {
await clientApi.updateUser(editingUser.id, body, tenantId);
}
setCreating(false);
setEditingUser(null);
await load();
}
async function runConfirm() {
if (!confirmAction) return;
if (confirmAction.type === 'delete') {
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
} else {
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
}
setConfirmAction(null);
await load();
}
async function savePassword() {
if (!passwordUser) return;
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
setPasswordUser(null);
setNewPassword('');
}
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> },
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info"></Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'}</span> },
{ {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
width: '170px', width: '290px',
render: (record) => ( render: (record) => (
<div className="inline-actions"> <div className="inline-actions">
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button> <Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} size="sm" variant="danger"></Button> <Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div> </div>
), ),
}, },
@@ -106,56 +149,46 @@ export function ClientUsersPage() {
</div> </div>
<div className="system-filter-row"> <div className="system-filter-row">
<Input <Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索用户名、邮箱或手机号"
prefix={<Search size={16} />}
value={keyword}
/>
</div> </div>
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface system-table-card"> <div className="surface system-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" /> <Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
<Pagination total={filteredUsers.length} page={1} /> <Pagination total={filteredUsers.length} page={1} />
</div> </div>
<Modal {(creating || editingUser) ? (
footer={( <Modal
<> footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary"></Button><Button onClick={() => void saveUser()}></Button></>}
<Button onClick={() => setEditingUser(null)} variant="secondary"></Button> onClose={() => { setCreating(false); setEditingUser(null); }}
<Button onClick={() => setEditingUser(null)}></Button> open
</> size="xl"
)} title={creating ? '添加用户' : '编辑用户'}
onClose={() => setEditingUser(null)} >
open={Boolean(editingUser)} <div className="system-user-form">
size="xl" <Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
title={editingUser?.id === 'NEW' ? '添加用户' : '编辑用户'} <Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
> <Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
<div className="system-user-form"> <Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
<Input label="用户名 *" onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="请输入用户名" value={draft.name} /> {creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
<Input label="邮箱 *" onChange={(event) => setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} /> <Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
<Input label="手机号 *" onChange={(event) => setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} /> </div>
<Select </Modal>
hint={canSelectEnterpriseAdmin ? '企业管理员拥有企业空间最高权限。' : `当前企业管理员为 ${enterpriseAdmin?.name},每个企业仅允许 1 位企业管理员。`} ) : null}
label="角色 *"
onChange={(event) => setDraft({ ...draft, role: event.target.value as UserRole })} {passwordUser ? (
options={[ <Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary"></Button><Button onClick={() => void savePassword()}></Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
...(canSelectEnterpriseAdmin ? [{ label: '企业管理员', value: 'enterprise_admin' }] : []), <div className="system-user-form">
{ label: '普通用户', value: 'user' }, <Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
]} </div>
value={draft.role} </Modal>
/> ) : null}
<Select
label="状态 *" {confirmAction ? (
onChange={(event) => setDraft({ ...draft, status: event.target.value as UserStatus })} <Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="secondary"></Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}></Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
options={[ <p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p>
{ label: '正常', value: 'active' }, </Modal>
{ label: '禁用', value: 'disabled' }, ) : null}
]}
value={draft.status}
/>
</div>
</Modal>
</section> </section>
); );
} }
+9 -1
View File
@@ -24,15 +24,23 @@ import {
Users, Users,
UserX, UserX,
} from 'lucide-react'; } from 'lucide-react';
import { Navigate } from 'react-router-dom';
import { readSession } from '@/api/session';
import { AppShell } from '@/layouts/AppShell'; import { AppShell } from '@/layouts/AppShell';
export function AdminLayout() { export function AdminLayout() {
const session = readSession();
if (session?.portal !== 'admin') {
return <Navigate to="/admin/login" replace />;
}
return ( return (
<AppShell <AppShell
title="CMPP 运营端" title="CMPP 运营端"
subtitle="平台运营管理中心" subtitle="平台运营管理中心"
workspaceName="平台运营工作区" workspaceName="平台运营工作区"
userName="运营" loginPath="/admin/login"
userName={session.user.displayName}
userRole="平台管理员" userRole="平台管理员"
auditNotifications={[ auditNotifications={[
{ label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' }, { label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' },
+10 -2
View File
@@ -12,7 +12,8 @@ import {
Search, Search,
Sparkles, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { clearSession } from '@/api/session';
export type ShellNavItem = { export type ShellNavItem = {
label: string; label: string;
@@ -37,6 +38,7 @@ type AppShellProps = {
title: string; title: string;
subtitle: string; subtitle: string;
workspaceName: string; workspaceName: string;
loginPath: string;
userName: string; userName: string;
userRole: string; userRole: string;
navSections: ShellNavSection[]; navSections: ShellNavSection[];
@@ -47,6 +49,7 @@ export function AppShell({
title, title,
subtitle, subtitle,
workspaceName, workspaceName,
loginPath,
userName, userName,
userRole, userRole,
navSections, navSections,
@@ -56,6 +59,7 @@ export function AppShell({
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({}); const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false); const [noticeOpen, setNoticeOpen] = useState(false);
const navigate = useNavigate();
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose; const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
const auditTotal = useMemo( const auditTotal = useMemo(
() => auditNotifications.reduce((sum, item) => sum + item.count, 0), () => auditNotifications.reduce((sum, item) => sum + item.count, 0),
@@ -205,7 +209,11 @@ export function AppShell({
<KeyRound size={16} /> <KeyRound size={16} />
</button> </button>
<button onClick={() => setUserMenuOpen(false)} role="menuitem" type="button"> <button onClick={() => {
clearSession();
setUserMenuOpen(false);
navigate(loginPath, { replace: true });
}} role="menuitem" type="button">
<LogOut size={16} /> <LogOut size={16} />
退 退
</button> </button>
+10 -2
View File
@@ -10,15 +10,23 @@ import {
ShieldCheck, ShieldCheck,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import { Navigate } from 'react-router-dom';
import { readSession } from '@/api/session';
import { AppShell } from '@/layouts/AppShell'; import { AppShell } from '@/layouts/AppShell';
export function ClientLayout() { export function ClientLayout() {
const session = readSession();
if (session?.portal !== 'client') {
return <Navigate to="/client/login" replace />;
}
return ( return (
<AppShell <AppShell
title="CMPP 客户端" title="CMPP 客户端"
subtitle="短信服务控制台" subtitle="短信服务控制台"
workspaceName="华东一区客户空间" workspaceName={session.user.tenantName ?? '企业客户空间'}
userName="赵先生" loginPath="/client/login"
userName={session.user.displayName}
userRole="企业管理员" userRole="企业管理员"
navSections={[ navSections={[
{ {
+3
View File
@@ -56,6 +56,7 @@ import { ClientSystemLogsPage } from '@/apps/client/ClientSystemLogsPage';
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage'; import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage'; import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage';
import { ClientUsersPage } from '@/apps/client/ClientUsersPage'; import { ClientUsersPage } from '@/apps/client/ClientUsersPage';
import { LoginPage } from '@/apps/LoginPage';
import { PagePlaceholder } from '@/components/PagePlaceholder'; import { PagePlaceholder } from '@/components/PagePlaceholder';
import { AdminLayout } from '@/layouts/AdminLayout'; import { AdminLayout } from '@/layouts/AdminLayout';
import { ClientLayout } from '@/layouts/ClientLayout'; import { ClientLayout } from '@/layouts/ClientLayout';
@@ -64,6 +65,8 @@ export function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/" element={<Navigate to="/client" replace />} /> <Route path="/" element={<Navigate to="/client" replace />} />
<Route path="/client/login" element={<LoginPage portal="client" />} />
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
<Route path="/client" element={<ClientLayout />}> <Route path="/client" element={<ClientLayout />}>
<Route index element={<ClientHome />} /> <Route index element={<ClientHome />} />
<Route path="send" element={<ClientSendPage />} /> <Route path="send" element={<ClientSendPage />} />
+76
View File
@@ -3856,6 +3856,82 @@ h3 {
margin-top: var(--space-2); margin-top: var(--space-2);
} }
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(16, 185, 129, 0.08)),
var(--color-bg);
}
.login-panel {
width: min(440px, 100%);
padding: 28px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: var(--shadow-lg);
}
.login-brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
}
.login-brand > span {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 8px;
color: var(--color-primary);
background: var(--color-primary-soft);
}
.login-brand h1 {
margin: 0;
font-size: 24px;
}
.login-brand p {
margin: 4px 0 0;
color: var(--color-text-muted);
}
.login-form {
display: grid;
gap: 16px;
}
.login-captcha-row {
display: grid;
grid-template-columns: 1fr 136px;
gap: 12px;
align-items: end;
}
.login-captcha {
height: 40px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-muted);
color: var(--color-text-strong);
font-weight: 700;
cursor: pointer;
}
.login-error {
margin: 0;
padding: 10px 12px;
border-radius: 8px;
color: var(--color-danger);
background: rgba(239, 68, 68, 0.1);
}
.system-page { .system-page {
gap: 28px; gap: 28px;
} }
+25 -3
View File
@@ -10,6 +10,8 @@ const prisma = new PrismaClient({
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api'; const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api';
const tenantCode = 'smoke-tenant'; const tenantCode = 'smoke-tenant';
const username = 'smoke_client'; const username = 'smoke_client';
const email = 'smoke-client@example.com';
const phone = '13800138099';
const password = 'SmokePass123!'; const password = 'SmokePass123!';
function hashPassword(value) { function hashPassword(value) {
@@ -27,19 +29,37 @@ async function ensureSmokeData() {
where: { username }, where: { username },
update: { update: {
tenantId: tenant.id, tenantId: tenant.id,
email,
phone,
displayName: 'Smoke Client Admin', displayName: 'Smoke Client Admin',
passwordHash: hashPassword(password), passwordHash: hashPassword(password),
status: 'active', status: 'active',
failedLoginCount: 0,
lockedUntil: null,
deletedAt: null,
}, },
create: { create: {
tenantId: tenant.id, tenantId: tenant.id,
username, username,
email,
phone,
displayName: 'Smoke Client Admin', displayName: 'Smoke Client Admin',
passwordHash: hashPassword(password), passwordHash: hashPassword(password),
status: 'active', status: 'active',
}, },
}); });
const enterpriseAdminRole = await prisma.role.upsert({
where: { code: 'enterprise_admin' },
update: { name: '企业管理员', scope: 'tenant' },
create: { code: 'enterprise_admin', name: '企业管理员', scope: 'tenant' },
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: enterpriseAdminRole.id } },
update: {},
create: { userId: user.id, roleId: enterpriseAdminRole.id },
});
await prisma.tenantAccount.upsert({ await prisma.tenantAccount.upsert({
where: { tenantId: tenant.id }, where: { tenantId: tenant.id },
update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' }, update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' },
@@ -51,7 +71,7 @@ async function ensureSmokeData() {
update: { update: {
name: 'Smoke CMPP Channel', name: 'Smoke CMPP Channel',
gatewayHost: '127.0.0.1', gatewayHost: '127.0.0.1',
gatewayPort: 7890, gatewayPort: 17890,
account: 'smoke-account', account: 'smoke-account',
passwordCipher: 'smoke-password', passwordCipher: 'smoke-password',
srcId: '10690000', srcId: '10690000',
@@ -65,7 +85,7 @@ async function ensureSmokeData() {
carrier: 'all', carrier: 'all',
protocol: 'CMPP', protocol: 'CMPP',
gatewayHost: '127.0.0.1', gatewayHost: '127.0.0.1',
gatewayPort: 7890, gatewayPort: 17890,
account: 'smoke-account', account: 'smoke-account',
passwordCipher: 'smoke-password', passwordCipher: 'smoke-password',
srcId: '10690000', srcId: '10690000',
@@ -180,9 +200,11 @@ async function run() {
const health = await request('/health'); const health = await request('/health');
assert(health.status === 'ok', 'health should return ok'); assert(health.status === 'ok', 'health should return ok');
const captcha = await request('/client/auth/captcha');
const captchaText = String(captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0));
const login = await request('/client/auth/login', { const login = await request('/client/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }), body: JSON.stringify({ login: email, password, captchaId: captcha.captchaId, captchaText }),
}); });
assert(login.accessToken && login.user?.tenantId === data.tenant.id, 'login should return smoke tenant user'); assert(login.accessToken && login.user?.tenantId === data.tenant.id, 'login should return smoke tenant user');