feat: 新增数据库持久化支持并完善后台管理功能
- 引入dotenv加载环境变量,新增数据库同步配置项 - 为用户服务、管理员服务、数据服务添加数据库持久化逻辑 - 新增管理员用户邮箱字段,完善权限校验逻辑 - 添加数据库初始化与种子数据脚本 - 新增e2e测试验证管理员数据持久化
This commit is contained in:
5
.env
5
.env
@@ -1,5 +1,8 @@
|
|||||||
PORT=3030
|
PORT=3030
|
||||||
JWT_SECRET=change-me
|
JWT_SECRET=change-me
|
||||||
|
ADMIN_JWT_SECRET=change-me-admin
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
# MySQL settings for the production TypeORM layer.
|
# MySQL settings for the production TypeORM layer.
|
||||||
# The current scaffold defines entities and API boundaries; repository persistence can be enabled when the database is ready.
|
# The current scaffold defines entities and API boundaries; repository persistence can be enabled when the database is ready.
|
||||||
@@ -8,3 +11,5 @@ DB_PORT=22246
|
|||||||
DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
DB_PASSWORD=zhxiao1124..
|
DB_PASSWORD=zhxiao1124..
|
||||||
DB_DATABASE=onion_tiktok
|
DB_DATABASE=onion_tiktok
|
||||||
|
|
||||||
|
DB_SYNCHRONIZE = true
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ DB_PORT=3306
|
|||||||
DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_DATABASE=cth_tk_backend
|
DB_DATABASE=cth_tk_backend
|
||||||
|
|
||||||
|
DB_SYNCHRONIZE = true
|
||||||
|
|||||||
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
@@ -23,6 +23,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"dotenv": "17.4.1",
|
||||||
"mysql2": "^3.0.0",
|
"mysql2": "^3.0.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.0
|
specifier: ^0.14.0
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
dotenv:
|
||||||
|
specifier: 17.4.1
|
||||||
|
version: 17.4.1
|
||||||
mysql2:
|
mysql2:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.22.5(@types/node@24.13.2)
|
version: 3.22.5(@types/node@24.13.2)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { bootstrapApp } from './bootstrap';
|
import { bootstrapApp } from './bootstrap';
|
||||||
|
|||||||
@@ -2,17 +2,24 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
OnModuleInit,
|
||||||
|
Optional,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||||
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
||||||
import { CreatePermissionDto } from './dto/create-permission.dto';
|
import { CreatePermissionDto } from './dto/create-permission.dto';
|
||||||
import { CreateRoleDto } from './dto/create-role.dto';
|
import { CreateRoleDto } from './dto/create-role.dto';
|
||||||
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
|
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
|
||||||
|
import { AdminUser } from './entities/admin-user.entity';
|
||||||
|
import { Permission } from './entities/permission.entity';
|
||||||
|
import { Role } from './entities/role.entity';
|
||||||
import {
|
import {
|
||||||
AdminUserRecord,
|
AdminUserRecord,
|
||||||
AdminUserView,
|
AdminUserView,
|
||||||
@@ -26,7 +33,7 @@ interface PaginationInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService implements OnModuleInit {
|
||||||
private permissionSequence = 1;
|
private permissionSequence = 1;
|
||||||
private roleSequence = 1;
|
private roleSequence = 1;
|
||||||
private adminSequence = 1;
|
private adminSequence = 1;
|
||||||
@@ -34,8 +41,22 @@ export class AdminService {
|
|||||||
private readonly roles: RoleRecord[] = [];
|
private readonly roles: RoleRecord[] = [];
|
||||||
private readonly admins: AdminUserRecord[] = [];
|
private readonly admins: AdminUserRecord[] = [];
|
||||||
|
|
||||||
constructor(private readonly jwtService: JwtService) {
|
constructor(
|
||||||
this.seed();
|
private readonly jwtService: JwtService,
|
||||||
|
@Optional()
|
||||||
|
@InjectDataSource()
|
||||||
|
readonly dataSource?: DataSource,
|
||||||
|
) {
|
||||||
|
if (!this.hasDatabase()) {
|
||||||
|
this.seedMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
await this.seedDatabase();
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: AdminLoginDto) {
|
async login(dto: AdminLoginDto) {
|
||||||
@@ -58,7 +79,17 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
createPermission(dto: CreatePermissionDto): PermissionRecord {
|
async createPermission(dto: CreatePermissionDto): Promise<PermissionRecord> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(Permission);
|
||||||
|
if (await repository.findOne({ where: { code: dto.code } })) {
|
||||||
|
throw new ConflictException('Permission code already exists');
|
||||||
|
}
|
||||||
|
const saved = await repository.save(repository.create(dto));
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.toPermissionRecord(saved);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.permissions.some((item) => item.code === dto.code)) {
|
if (this.permissions.some((item) => item.code === dto.code)) {
|
||||||
throw new ConflictException('Permission code already exists');
|
throw new ConflictException('Permission code already exists');
|
||||||
}
|
}
|
||||||
@@ -77,13 +108,34 @@ export class AdminService {
|
|||||||
return permission;
|
return permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
listPermissions(
|
async listPermissions(
|
||||||
pagination: PaginationInput,
|
pagination: PaginationInput,
|
||||||
): PaginatedResponse<PermissionRecord> {
|
): Promise<PaginatedResponse<PermissionRecord>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
}
|
||||||
|
|
||||||
return this.paginate([...this.permissions], pagination);
|
return this.paginate([...this.permissions], pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
createRole(dto: CreateRoleDto): RoleRecord {
|
async createRole(dto: CreateRoleDto): Promise<RoleRecord> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(Role);
|
||||||
|
if (await repository.findOne({ where: { code: dto.code } })) {
|
||||||
|
throw new ConflictException('Role code already exists');
|
||||||
|
}
|
||||||
|
const saved = await repository.save(
|
||||||
|
repository.create({
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
permissionIds: dto.permissionIds ?? [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.roles.find((role) => role.id === Number(saved.id)) as RoleRecord;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.roles.some((item) => item.code === dto.code)) {
|
if (this.roles.some((item) => item.code === dto.code)) {
|
||||||
throw new ConflictException('Role code already exists');
|
throw new ConflictException('Role code already exists');
|
||||||
}
|
}
|
||||||
@@ -107,11 +159,27 @@ export class AdminService {
|
|||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
listRoles(pagination: PaginationInput): PaginatedResponse<RoleRecord> {
|
async listRoles(pagination: PaginationInput): Promise<PaginatedResponse<RoleRecord>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
}
|
||||||
|
|
||||||
return this.paginate([...this.roles], pagination);
|
return this.paginate([...this.roles], pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRolePermissions(id: number, permissionIds: number[]): RoleRecord {
|
async updateRolePermissions(id: number, permissionIds: number[]): Promise<RoleRecord> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(Role);
|
||||||
|
const role = await repository.findOne({ where: { id } });
|
||||||
|
if (!role) {
|
||||||
|
throw new NotFoundException('Role not found');
|
||||||
|
}
|
||||||
|
role.permissionIds = [...new Set(permissionIds)];
|
||||||
|
await repository.save(role);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.roles.find((item) => item.id === id) as RoleRecord;
|
||||||
|
}
|
||||||
|
|
||||||
const role = this.roles.find((item) => item.id === id);
|
const role = this.roles.find((item) => item.id === id);
|
||||||
if (!role) {
|
if (!role) {
|
||||||
throw new NotFoundException('Role not found');
|
throw new NotFoundException('Role not found');
|
||||||
@@ -126,7 +194,29 @@ export class AdminService {
|
|||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
createAdminUser(dto: CreateAdminUserDto): AdminUserView {
|
async createAdminUser(dto: CreateAdminUserDto): Promise<AdminUserView> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(AdminUser);
|
||||||
|
if (await repository.findOne({ where: { username: dto.username } })) {
|
||||||
|
throw new ConflictException('Admin username already exists');
|
||||||
|
}
|
||||||
|
const saved = await repository.save(
|
||||||
|
repository.create({
|
||||||
|
username: dto.username,
|
||||||
|
passwordHash: bcrypt.hashSync(dto.password, 10),
|
||||||
|
nickname: dto.nickname,
|
||||||
|
email: dto.email,
|
||||||
|
status: 'active',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
roleIds: dto.roleIds ?? [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.toAdminView(
|
||||||
|
this.admins.find((admin) => admin.id === Number(saved.id)) as AdminUserRecord,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.admins.some((item) => item.username === dto.username)) {
|
if (this.admins.some((item) => item.username === dto.username)) {
|
||||||
throw new ConflictException('Admin username already exists');
|
throw new ConflictException('Admin username already exists');
|
||||||
}
|
}
|
||||||
@@ -149,19 +239,35 @@ export class AdminService {
|
|||||||
return this.toAdminView(admin);
|
return this.toAdminView(admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
listAdminUsers(
|
async listAdminUsers(
|
||||||
pagination: PaginationInput,
|
pagination: PaginationInput,
|
||||||
): PaginatedResponse<AdminUserView> {
|
): Promise<PaginatedResponse<AdminUserView>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
}
|
||||||
|
|
||||||
return this.paginate(
|
return this.paginate(
|
||||||
this.admins.map((admin) => this.toAdminView(admin)),
|
this.admins.map((admin) => this.toAdminView(admin)),
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAdminStatus(
|
async updateAdminStatus(
|
||||||
id: number,
|
id: number,
|
||||||
status: 'active' | 'disabled',
|
status: 'active' | 'disabled',
|
||||||
): AdminUserView {
|
): Promise<AdminUserView> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(AdminUser);
|
||||||
|
const admin = await repository.findOne({ where: { id } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new NotFoundException('Admin user not found');
|
||||||
|
}
|
||||||
|
admin.status = status;
|
||||||
|
await repository.save(admin);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.toAdminView(this.findAdminById(id) as AdminUserRecord);
|
||||||
|
}
|
||||||
|
|
||||||
const admin = this.findAdminById(id);
|
const admin = this.findAdminById(id);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new NotFoundException('Admin user not found');
|
throw new NotFoundException('Admin user not found');
|
||||||
@@ -172,7 +278,19 @@ export class AdminService {
|
|||||||
return this.toAdminView(admin);
|
return this.toAdminView(admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAdminRoles(id: number, roleIds: number[]): AdminUserView {
|
async updateAdminRoles(id: number, roleIds: number[]): Promise<AdminUserView> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(AdminUser);
|
||||||
|
const admin = await repository.findOne({ where: { id } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new NotFoundException('Admin user not found');
|
||||||
|
}
|
||||||
|
admin.roleIds = [...new Set(roleIds)];
|
||||||
|
await repository.save(admin);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.toAdminView(this.findAdminById(id) as AdminUserRecord);
|
||||||
|
}
|
||||||
|
|
||||||
const admin = this.findAdminById(id);
|
const admin = this.findAdminById(id);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new NotFoundException('Admin user not found');
|
throw new NotFoundException('Admin user not found');
|
||||||
@@ -183,10 +301,23 @@ export class AdminService {
|
|||||||
return this.toAdminView(admin);
|
return this.toAdminView(admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProfile(
|
async updateProfile(
|
||||||
adminId: number,
|
adminId: number,
|
||||||
dto: UpdateAdminProfileDto,
|
dto: UpdateAdminProfileDto,
|
||||||
): AdminUserView {
|
): Promise<AdminUserView> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(AdminUser);
|
||||||
|
const admin = await repository.findOne({ where: { id: adminId } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new NotFoundException('Admin user not found');
|
||||||
|
}
|
||||||
|
admin.nickname = dto.nickname;
|
||||||
|
admin.email = dto.email;
|
||||||
|
await repository.save(admin);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return this.toAdminView(this.findAdminById(adminId) as AdminUserRecord);
|
||||||
|
}
|
||||||
|
|
||||||
const admin = this.findAdminById(adminId);
|
const admin = this.findAdminById(adminId);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new NotFoundException('Admin user not found');
|
throw new NotFoundException('Admin user not found');
|
||||||
@@ -198,11 +329,26 @@ export class AdminService {
|
|||||||
return this.toAdminView(admin);
|
return this.toAdminView(admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
changePassword(
|
async changePassword(
|
||||||
adminId: number,
|
adminId: number,
|
||||||
oldPassword: string,
|
oldPassword: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
): Record<string, never> {
|
): Promise<Record<string, never>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(AdminUser);
|
||||||
|
const admin = await repository.findOne({ where: { id: adminId } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new NotFoundException('Admin user not found');
|
||||||
|
}
|
||||||
|
if (!bcrypt.compareSync(oldPassword, admin.passwordHash)) {
|
||||||
|
throw new UnprocessableEntityException('Old password is incorrect');
|
||||||
|
}
|
||||||
|
admin.passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||||
|
await repository.save(admin);
|
||||||
|
await this.loadDatabaseRecords();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
const admin = this.findAdminById(adminId);
|
const admin = this.findAdminById(adminId);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new NotFoundException('Admin user not found');
|
throw new NotFoundException('Admin user not found');
|
||||||
@@ -256,7 +402,7 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private seed() {
|
private seedMemory() {
|
||||||
const codes = [
|
const codes = [
|
||||||
['permission:read', '查看权限'],
|
['permission:read', '查看权限'],
|
||||||
['permission:create', '创建权限'],
|
['permission:create', '创建权限'],
|
||||||
@@ -300,6 +446,141 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||||
|
return Boolean(this.dataSource?.isInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedDatabase() {
|
||||||
|
const dataSource = this.dataSource as DataSource;
|
||||||
|
const permissionRepository = dataSource.getRepository(Permission);
|
||||||
|
for (const [code, name] of this.permissionSeeds()) {
|
||||||
|
const existing = await permissionRepository.findOne({ where: { code } });
|
||||||
|
if (!existing) {
|
||||||
|
await permissionRepository.save(
|
||||||
|
permissionRepository.create({ code, name }),
|
||||||
|
);
|
||||||
|
} else if (existing.name !== name) {
|
||||||
|
existing.name = name;
|
||||||
|
await permissionRepository.save(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRepository = dataSource.getRepository(AdminUser);
|
||||||
|
const username = process.env.ADMIN_USERNAME ?? 'admin';
|
||||||
|
const existingAdmin = await adminRepository.findOne({ where: { username } });
|
||||||
|
if (!existingAdmin) {
|
||||||
|
await adminRepository.save(
|
||||||
|
adminRepository.create({
|
||||||
|
username,
|
||||||
|
passwordHash: bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? 'admin123', 10),
|
||||||
|
nickname: 'Super Admin',
|
||||||
|
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
||||||
|
status: 'active',
|
||||||
|
isSuperAdmin: true,
|
||||||
|
roleIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDatabaseRecords() {
|
||||||
|
const dataSource = this.dataSource as DataSource;
|
||||||
|
const permissions = await dataSource.getRepository(Permission).find({
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
this.permissions.splice(
|
||||||
|
0,
|
||||||
|
this.permissions.length,
|
||||||
|
...permissions.map((permission) => this.toPermissionRecord(permission)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const roles = await dataSource.getRepository(Role).find({
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
this.roles.splice(
|
||||||
|
0,
|
||||||
|
this.roles.length,
|
||||||
|
...roles.map((role) => this.toRoleRecord(role)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const admins = await dataSource.getRepository(AdminUser).find({
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
this.admins.splice(
|
||||||
|
0,
|
||||||
|
this.admins.length,
|
||||||
|
...admins.map((admin) => this.toAdminRecord(admin)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private permissionSeeds() {
|
||||||
|
return [
|
||||||
|
['permission:read', '查看权限'],
|
||||||
|
['permission:create', '创建权限'],
|
||||||
|
['role:read', '查看角色'],
|
||||||
|
['role:create', '创建角色'],
|
||||||
|
['role:update', '更新角色'],
|
||||||
|
['admin-user:read', '查看人员'],
|
||||||
|
['admin-user:create', '创建人员'],
|
||||||
|
['admin-user:update', '更新人员'],
|
||||||
|
['user:read', '查看用户'],
|
||||||
|
['user:update', '更新用户'],
|
||||||
|
['drama:read', '查看短剧'],
|
||||||
|
['drama:create', '创建短剧'],
|
||||||
|
['drama:update', '更新短剧'],
|
||||||
|
['episode:create', '创建剧集'],
|
||||||
|
['episode:update', '更新剧集'],
|
||||||
|
['media:read', '查看媒体'],
|
||||||
|
['media:create', '创建媒体'],
|
||||||
|
['data:read', '查看数据'],
|
||||||
|
['data:sync', '同步数据'],
|
||||||
|
['payment:read', '查看支付记录'],
|
||||||
|
['log:read', '查看日志'],
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPermissionRecord(permission: Permission): PermissionRecord {
|
||||||
|
return {
|
||||||
|
id: Number(permission.id),
|
||||||
|
code: permission.code,
|
||||||
|
name: permission.name,
|
||||||
|
description: permission.description,
|
||||||
|
createdAt: permission.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
updatedAt: permission.updatedAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRoleRecord(role: Role): RoleRecord {
|
||||||
|
const permissionIds = role.permissionIds ?? [];
|
||||||
|
return {
|
||||||
|
id: Number(role.id),
|
||||||
|
code: role.code,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
permissionIds,
|
||||||
|
permissions: this.permissions.filter((permission) =>
|
||||||
|
permissionIds.includes(permission.id),
|
||||||
|
),
|
||||||
|
createdAt: role.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
updatedAt: role.updatedAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAdminRecord(admin: AdminUser): AdminUserRecord {
|
||||||
|
return {
|
||||||
|
id: Number(admin.id),
|
||||||
|
username: admin.username,
|
||||||
|
passwordHash: admin.passwordHash,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
email: admin.email,
|
||||||
|
status: admin.status as 'active' | 'disabled',
|
||||||
|
isSuperAdmin: admin.isSuperAdmin,
|
||||||
|
roleIds: admin.roleIds ?? [],
|
||||||
|
createdAt: admin.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
updatedAt: admin.updatedAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private paginate<T>(
|
private paginate<T>(
|
||||||
records: T[],
|
records: T[],
|
||||||
input: PaginationInput,
|
input: PaginationInput,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export class AdminUser {
|
|||||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 32, default: 'active' })
|
@Column({ type: 'varchar', length: 32, default: 'active' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class AdminJwtAuthGuard implements CanActivate {
|
|||||||
throw new UnauthorizedException('Admin not authenticated');
|
throw new UnauthorizedException('Admin not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUser = this.adminService.findAdminById(payload.sub);
|
const adminUser = await this.adminService.findAdminById(payload.sub);
|
||||||
if (!adminUser || adminUser.status !== 'active') {
|
if (!adminUser || adminUser.status !== 'active') {
|
||||||
throw new UnauthorizedException('Admin not authenticated');
|
throw new UnauthorizedException('Admin not authenticated');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const required =
|
const required =
|
||||||
this.reflector.getAllAndOverride<string[]>(
|
this.reflector.getAllAndOverride<string[]>(
|
||||||
REQUIRED_PERMISSIONS_METADATA,
|
REQUIRED_PERMISSIONS_METADATA,
|
||||||
@@ -38,7 +38,9 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = this.adminService.getPermissionCodesForAdmin(adminUser.id);
|
const permissions = await this.adminService.getPermissionCodesForAdmin(
|
||||||
|
adminUser.id,
|
||||||
|
);
|
||||||
const allowed = required.every((permission) =>
|
const allowed = required.every((permission) =>
|
||||||
permissions.includes(permission),
|
permissions.includes(permission),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async loginWithTikTok(dto: TikTokLoginDto) {
|
async loginWithTikTok(dto: TikTokLoginDto) {
|
||||||
const tiktokOpenId = dto.mockOpenId ?? this.resolveOpenIdFromCode(dto.code);
|
const tiktokOpenId = dto.mockOpenId ?? this.resolveOpenIdFromCode(dto.code);
|
||||||
const result = this.usersService.upsertTikTokUser({
|
const result = await this.usersService.upsertTikTokUser({
|
||||||
tiktokOpenId,
|
tiktokOpenId,
|
||||||
tiktokUnionId: dto.tiktokUnionId,
|
tiktokUnionId: dto.tiktokUnionId,
|
||||||
nickname: dto.nickname,
|
nickname: dto.nickname,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync<{ sub: number }>(token);
|
const payload = await this.jwtService.verifyAsync<{ sub: number }>(token);
|
||||||
const user = this.usersService.findById(payload.sub);
|
const user = await this.usersService.findById(payload.sub);
|
||||||
if (!user || user.status !== 'active') {
|
if (!user || user.status !== 'active') {
|
||||||
throw new UnauthorizedException('Not authenticated');
|
throw new UnauthorizedException('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
import { DramasService } from '../dramas/dramas.service';
|
import { DramasService } from '../dramas/dramas.service';
|
||||||
|
import { DataSyncJob } from './entities/data-sync-job.entity';
|
||||||
|
import { DramaMetric } from './entities/drama-metric.entity';
|
||||||
|
import { EpisodeMetric } from './entities/episode-metric.entity';
|
||||||
import { TikTokDataProvider } from './providers/tiktok-data.provider';
|
import { TikTokDataProvider } from './providers/tiktok-data.provider';
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
@@ -58,9 +63,12 @@ export class DataService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dramasService: DramasService,
|
private readonly dramasService: DramasService,
|
||||||
private readonly tiktokDataProvider: TikTokDataProvider,
|
private readonly tiktokDataProvider: TikTokDataProvider,
|
||||||
|
@Optional()
|
||||||
|
@InjectDataSource()
|
||||||
|
readonly dataSource?: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
sync(input: SyncInput = {}) {
|
async sync(input: SyncInput = {}) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const dramas = this.dramasService
|
const dramas = this.dramasService
|
||||||
.listAllDramas()
|
.listAllDramas()
|
||||||
@@ -70,24 +78,28 @@ export class DataService {
|
|||||||
.listAllEpisodes()
|
.listAllEpisodes()
|
||||||
.filter((episode) => dramaIds.has(episode.dramaId));
|
.filter((episode) => dramaIds.has(episode.dramaId));
|
||||||
|
|
||||||
for (const drama of dramas) {
|
const dramaRecords = dramas.map((drama) => ({
|
||||||
this.dramaMetrics.set(drama.id, {
|
|
||||||
dramaId: drama.id,
|
dramaId: drama.id,
|
||||||
title: drama.title,
|
title: drama.title,
|
||||||
...this.tiktokDataProvider.getDramaMetrics(drama),
|
...this.tiktokDataProvider.getDramaMetrics(drama),
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
for (const episode of episodes) {
|
const episodeRecords = episodes.map((episode) => ({
|
||||||
this.episodeMetrics.set(episode.id, {
|
|
||||||
dramaId: episode.dramaId,
|
dramaId: episode.dramaId,
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
title: episode.title,
|
title: episode.title,
|
||||||
episodeNo: episode.episodeNo,
|
episodeNo: episode.episodeNo,
|
||||||
...this.tiktokDataProvider.getEpisodeMetrics(episode),
|
...this.tiktokDataProvider.getEpisodeMetrics(episode),
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
for (const record of dramaRecords) {
|
||||||
|
this.dramaMetrics.set(record.dramaId, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of episodeRecords) {
|
||||||
|
this.episodeMetrics.set(record.episodeId, record);
|
||||||
}
|
}
|
||||||
|
|
||||||
const job: SyncJobRecord = {
|
const job: SyncJobRecord = {
|
||||||
@@ -101,12 +113,67 @@ export class DataService {
|
|||||||
};
|
};
|
||||||
this.syncJobs.unshift(job);
|
this.syncJobs.unshift(job);
|
||||||
|
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const dramaMetricRepository = this.dataSource.getRepository(DramaMetric);
|
||||||
|
const episodeMetricRepository = this.dataSource.getRepository(EpisodeMetric);
|
||||||
|
const syncJobRepository = this.dataSource.getRepository(DataSyncJob);
|
||||||
|
|
||||||
|
for (const record of dramaRecords) {
|
||||||
|
const existing = await dramaMetricRepository.findOne({
|
||||||
|
where: { dramaId: record.dramaId },
|
||||||
|
});
|
||||||
|
await dramaMetricRepository.save(
|
||||||
|
dramaMetricRepository.create({
|
||||||
|
...existing,
|
||||||
|
...record,
|
||||||
|
syncedAt: new Date(record.syncedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of episodeRecords) {
|
||||||
|
const existing = await episodeMetricRepository.findOne({
|
||||||
|
where: { episodeId: record.episodeId },
|
||||||
|
});
|
||||||
|
await episodeMetricRepository.save(
|
||||||
|
episodeMetricRepository.create({
|
||||||
|
...existing,
|
||||||
|
...record,
|
||||||
|
syncedAt: new Date(record.syncedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncJobRepository.save(
|
||||||
|
syncJobRepository.create({
|
||||||
|
...job,
|
||||||
|
createdAt: new Date(job.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
getOverview() {
|
async getOverview() {
|
||||||
const dramaRecords = Array.from(this.dramaMetrics.values());
|
const dramaRecords = this.hasDatabase()
|
||||||
const episodeRecords = Array.from(this.episodeMetrics.values());
|
? (await this.dataSource.getRepository(DramaMetric).find()).map((record) =>
|
||||||
|
this.toDramaMetricRecord(record),
|
||||||
|
)
|
||||||
|
: Array.from(this.dramaMetrics.values());
|
||||||
|
const episodeRecords = this.hasDatabase()
|
||||||
|
? (await this.dataSource.getRepository(EpisodeMetric).find()).map((record) =>
|
||||||
|
this.toEpisodeMetricRecord(record),
|
||||||
|
)
|
||||||
|
: Array.from(this.episodeMetrics.values());
|
||||||
|
const latestJob = this.hasDatabase()
|
||||||
|
? (
|
||||||
|
await this.dataSource.getRepository(DataSyncJob).find({
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
take: 1,
|
||||||
|
})
|
||||||
|
)[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalDramas: dramaRecords.length,
|
totalDramas: dramaRecords.length,
|
||||||
@@ -115,27 +182,119 @@ export class DataService {
|
|||||||
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
|
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
|
||||||
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
|
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
|
||||||
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
|
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
|
||||||
latestSyncedAt: this.syncJobs[0]?.createdAt,
|
latestSyncedAt: latestJob?.createdAt?.toISOString?.() ?? this.syncJobs[0]?.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDramaMetrics(input: PaginationInput): Promise<PaginatedResponse<DramaMetricRecord>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const page = Math.max(input.page, 1);
|
||||||
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
const repository = this.dataSource.getRepository(DramaMetric);
|
||||||
|
const [records, total] = await repository.findAndCount({
|
||||||
|
order: { dramaId: 'DESC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: records.map((record) => this.toDramaMetricRecord(record)),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
listDramaMetrics(input: PaginationInput): PaginatedResponse<DramaMetricRecord> {
|
|
||||||
return this.paginate(Array.from(this.dramaMetrics.values()), input);
|
return this.paginate(Array.from(this.dramaMetrics.values()), input);
|
||||||
}
|
}
|
||||||
|
|
||||||
listEpisodeMetrics(
|
async listEpisodeMetrics(
|
||||||
input: PaginationInput & { dramaId?: number },
|
input: PaginationInput & { dramaId?: number },
|
||||||
): PaginatedResponse<EpisodeMetricRecord> {
|
): Promise<PaginatedResponse<EpisodeMetricRecord>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const page = Math.max(input.page, 1);
|
||||||
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
const repository = this.dataSource.getRepository(EpisodeMetric);
|
||||||
|
const [records, total] = await repository.findAndCount({
|
||||||
|
where: input.dramaId ? { dramaId: input.dramaId } : {},
|
||||||
|
order: { episodeNo: 'ASC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: records.map((record) => this.toEpisodeMetricRecord(record)),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const records = Array.from(this.episodeMetrics.values()).filter(
|
const records = Array.from(this.episodeMetrics.values()).filter(
|
||||||
(item) => !input.dramaId || item.dramaId === input.dramaId,
|
(item) => !input.dramaId || item.dramaId === input.dramaId,
|
||||||
);
|
);
|
||||||
return this.paginate(records, input);
|
return this.paginate(records, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
listSyncJobs(input: PaginationInput): PaginatedResponse<SyncJobRecord> {
|
async listSyncJobs(input: PaginationInput): Promise<PaginatedResponse<SyncJobRecord>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const page = Math.max(input.page, 1);
|
||||||
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
const repository = this.dataSource.getRepository(DataSyncJob);
|
||||||
|
const [records, total] = await repository.findAndCount({
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: records.map((record) => this.toSyncJobRecord(record)),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return this.paginate(this.syncJobs, input);
|
return this.paginate(this.syncJobs, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||||
|
return Boolean(this.dataSource?.isInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDramaMetricRecord(record: DramaMetric): DramaMetricRecord {
|
||||||
|
return {
|
||||||
|
dramaId: Number(record.dramaId),
|
||||||
|
title: record.title,
|
||||||
|
plays: Number(record.plays),
|
||||||
|
likes: Number(record.likes),
|
||||||
|
shares: Number(record.shares),
|
||||||
|
comments: Number(record.comments),
|
||||||
|
completionRate: record.completionRate,
|
||||||
|
averageWatchSeconds: record.averageWatchSeconds,
|
||||||
|
syncedAt: record.syncedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEpisodeMetricRecord(record: EpisodeMetric): EpisodeMetricRecord {
|
||||||
|
return {
|
||||||
|
dramaId: Number(record.dramaId),
|
||||||
|
episodeId: Number(record.episodeId),
|
||||||
|
title: record.title,
|
||||||
|
episodeNo: record.episodeNo,
|
||||||
|
plays: Number(record.plays),
|
||||||
|
likes: Number(record.likes),
|
||||||
|
shares: Number(record.shares),
|
||||||
|
comments: Number(record.comments),
|
||||||
|
completionRate: record.completionRate,
|
||||||
|
averageWatchSeconds: record.averageWatchSeconds,
|
||||||
|
syncedAt: record.syncedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSyncJobRecord(record: DataSyncJob): SyncJobRecord {
|
||||||
|
return {
|
||||||
|
id: Number(record.id),
|
||||||
|
jobId: record.jobId,
|
||||||
|
status: record.status as 'completed' | 'failed',
|
||||||
|
dramaId: record.dramaId ? Number(record.dramaId) : undefined,
|
||||||
|
dramaCount: record.dramaCount,
|
||||||
|
episodeCount: record.episodeCount,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
|
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
|
||||||
const page = Math.max(input.page, 1);
|
const page = Math.max(input.page, 1);
|
||||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { AdminUser } from '../admin/entities/admin-user.entity';
|
import { AdminUser } from '../admin/entities/admin-user.entity';
|
||||||
import { Permission } from '../admin/entities/permission.entity';
|
import { Permission } from '../admin/entities/permission.entity';
|
||||||
import { Role } from '../admin/entities/role.entity';
|
import { Role } from '../admin/entities/role.entity';
|
||||||
|
import { DataSyncJob } from '../data/entities/data-sync-job.entity';
|
||||||
|
import { DramaMetric } from '../data/entities/drama-metric.entity';
|
||||||
|
import { EpisodeMetric } from '../data/entities/episode-metric.entity';
|
||||||
import { Drama } from '../dramas/entities/drama.entity';
|
import { Drama } from '../dramas/entities/drama.entity';
|
||||||
import { Episode } from '../dramas/entities/episode.entity';
|
import { Episode } from '../dramas/entities/episode.entity';
|
||||||
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
|
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
|
||||||
@@ -25,6 +28,9 @@ const databaseImports = process.env.DB_HOST
|
|||||||
AdminUser,
|
AdminUser,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
|
DataSyncJob,
|
||||||
|
DramaMetric,
|
||||||
|
EpisodeMetric,
|
||||||
Drama,
|
Drama,
|
||||||
Episode,
|
Episode,
|
||||||
PlaybackErrorLog,
|
PlaybackErrorLog,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
import { UserRecord } from './interfaces/user-record.interface';
|
import { UserRecord } from './interfaces/user-record.interface';
|
||||||
|
|
||||||
interface UpsertTikTokUserInput {
|
interface UpsertTikTokUserInput {
|
||||||
@@ -19,7 +22,38 @@ export class UsersService {
|
|||||||
private userSequence = 1;
|
private userSequence = 1;
|
||||||
private readonly users: UserRecord[] = [];
|
private readonly users: UserRecord[] = [];
|
||||||
|
|
||||||
upsertTikTokUser(input: UpsertTikTokUserInput) {
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@InjectDataSource()
|
||||||
|
readonly dataSource?: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upsertTikTokUser(input: UpsertTikTokUserInput) {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(User);
|
||||||
|
const existing = await repository.findOne({
|
||||||
|
where: { tiktokOpenId: input.tiktokOpenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.tiktokUnionId = input.tiktokUnionId ?? existing.tiktokUnionId;
|
||||||
|
existing.nickname = input.nickname ?? existing.nickname;
|
||||||
|
existing.avatarUrl = input.avatarUrl ?? existing.avatarUrl;
|
||||||
|
const saved = await repository.save(existing);
|
||||||
|
return { user: this.toRecord(saved), isNewUser: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = repository.create({
|
||||||
|
tiktokOpenId: input.tiktokOpenId,
|
||||||
|
tiktokUnionId: input.tiktokUnionId,
|
||||||
|
nickname: input.nickname,
|
||||||
|
avatarUrl: input.avatarUrl,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const saved = await repository.save(user);
|
||||||
|
return { user: this.toRecord(saved), isNewUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
const existing = this.users.find(
|
const existing = this.users.find(
|
||||||
(user) => user.tiktokOpenId === input.tiktokOpenId,
|
(user) => user.tiktokOpenId === input.tiktokOpenId,
|
||||||
);
|
);
|
||||||
@@ -48,16 +82,39 @@ export class UsersService {
|
|||||||
return { user, isNewUser: true };
|
return { user, isNewUser: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
findById(id: number) {
|
async findById(id: number) {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const user = await this.dataSource.getRepository(User).findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return user ? this.toRecord(user) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return this.users.find((user) => user.id === id);
|
return this.users.find((user) => user.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
listUsers(pagination: PaginationInput): PaginatedResponse<UserRecord> {
|
async listUsers(pagination: PaginationInput): Promise<PaginatedResponse<UserRecord>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const page = Math.max(pagination.page, 1);
|
||||||
|
const pageSize = Math.min(Math.max(pagination.pageSize, 1), 100);
|
||||||
|
const repository = this.dataSource.getRepository(User);
|
||||||
|
const [records, total] = await repository.findAndCount({
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: records.map((user) => this.toRecord(user)),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return this.paginate([...this.users].reverse(), pagination);
|
return this.paginate([...this.users].reverse(), pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserOrThrow(id: number) {
|
async getUserOrThrow(id: number) {
|
||||||
const user = this.findById(id);
|
const user = await this.findById(id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
@@ -65,13 +122,41 @@ export class UsersService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus(id: number, status: 'active' | 'disabled') {
|
async updateStatus(id: number, status: 'active' | 'disabled') {
|
||||||
const user = this.getUserOrThrow(id);
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(User);
|
||||||
|
const user = await repository.findOne({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
user.status = status;
|
||||||
|
return this.toRecord(await repository.save(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.getUserOrThrow(id);
|
||||||
user.status = status;
|
user.status = status;
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||||
|
return Boolean(this.dataSource?.isInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRecord(user: User): UserRecord {
|
||||||
|
return {
|
||||||
|
id: Number(user.id),
|
||||||
|
tiktokOpenId: user.tiktokOpenId,
|
||||||
|
tiktokUnionId: user.tiktokUnionId,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
status: user.status as 'active' | 'disabled',
|
||||||
|
createdAt: user.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
updatedAt: user.updatedAt?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private paginate<T>(
|
private paginate<T>(
|
||||||
records: T[],
|
records: T[],
|
||||||
input: PaginationInput,
|
input: PaginationInput,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AdminService } from '../src/modules/admin/admin.service';
|
||||||
import { DataService } from '../src/modules/data/data.service';
|
import { DataService } from '../src/modules/data/data.service';
|
||||||
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider';
|
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider';
|
||||||
import { PublishStatus } from '../src/modules/dramas/drama-status.enum';
|
import { PublishStatus } from '../src/modules/dramas/drama-status.enum';
|
||||||
@@ -48,6 +49,48 @@ function createDataSource(repositories: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('database-backed persistence', () => {
|
describe('database-backed persistence', () => {
|
||||||
|
it('persists permissions and admin profile changes through TypeORM repositories', async () => {
|
||||||
|
const permissionRepository = createRepository();
|
||||||
|
const roleRepository = createRepository();
|
||||||
|
const adminUserRepository = createRepository();
|
||||||
|
const dataSource = createDataSource({
|
||||||
|
Permission: permissionRepository,
|
||||||
|
Role: roleRepository,
|
||||||
|
AdminUser: adminUserRepository,
|
||||||
|
});
|
||||||
|
const service = new AdminService(
|
||||||
|
{ signAsync: jest.fn() } as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
const permissions = await service.listPermissions({ page: 1, pageSize: 50 });
|
||||||
|
expect(permissions.list).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: 'permission:read', name: '查看权限' }),
|
||||||
|
expect.objectContaining({ code: 'data:read', name: '查看数据' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await service.updateProfile(1, {
|
||||||
|
nickname: '数据库管理员',
|
||||||
|
email: 'db-admin@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
nickname: '数据库管理员',
|
||||||
|
email: 'db-admin@example.com',
|
||||||
|
});
|
||||||
|
expect(adminUserRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nickname: '数据库管理员',
|
||||||
|
email: 'db-admin@example.com',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists app users through TypeORM repositories when a DataSource is configured', async () => {
|
it('persists app users through TypeORM repositories when a DataSource is configured', async () => {
|
||||||
const userRepository = createRepository();
|
const userRepository = createRepository();
|
||||||
const dataSource = createDataSource({ User: userRepository });
|
const dataSource = createDataSource({ User: userRepository });
|
||||||
|
|||||||
Reference in New Issue
Block a user