feat: 新增数据库持久化支持并完善后台管理功能
- 引入dotenv加载环境变量,新增数据库同步配置项 - 为用户服务、管理员服务、数据服务添加数据库持久化逻辑 - 新增管理员用户邮箱字段,完善权限校验逻辑 - 添加数据库初始化与种子数据脚本 - 新增e2e测试验证管理员数据持久化
This commit is contained in:
@@ -2,17 +2,24 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
Optional,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
||||
import { CreatePermissionDto } from './dto/create-permission.dto';
|
||||
import { CreateRoleDto } from './dto/create-role.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 {
|
||||
AdminUserRecord,
|
||||
AdminUserView,
|
||||
@@ -26,7 +33,7 @@ interface PaginationInput {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
export class AdminService implements OnModuleInit {
|
||||
private permissionSequence = 1;
|
||||
private roleSequence = 1;
|
||||
private adminSequence = 1;
|
||||
@@ -34,8 +41,22 @@ export class AdminService {
|
||||
private readonly roles: RoleRecord[] = [];
|
||||
private readonly admins: AdminUserRecord[] = [];
|
||||
|
||||
constructor(private readonly jwtService: JwtService) {
|
||||
this.seed();
|
||||
constructor(
|
||||
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) {
|
||||
@@ -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)) {
|
||||
throw new ConflictException('Permission code already exists');
|
||||
}
|
||||
@@ -77,13 +108,34 @@ export class AdminService {
|
||||
return permission;
|
||||
}
|
||||
|
||||
listPermissions(
|
||||
async listPermissions(
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<PermissionRecord> {
|
||||
): Promise<PaginatedResponse<PermissionRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
await this.loadDatabaseRecords();
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw new ConflictException('Role code already exists');
|
||||
}
|
||||
@@ -107,11 +159,27 @@ export class AdminService {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!role) {
|
||||
throw new NotFoundException('Role not found');
|
||||
@@ -126,7 +194,29 @@ export class AdminService {
|
||||
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)) {
|
||||
throw new ConflictException('Admin username already exists');
|
||||
}
|
||||
@@ -149,19 +239,35 @@ export class AdminService {
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
listAdminUsers(
|
||||
async listAdminUsers(
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<AdminUserView> {
|
||||
): Promise<PaginatedResponse<AdminUserView>> {
|
||||
if (this.hasDatabase()) {
|
||||
await this.loadDatabaseRecords();
|
||||
}
|
||||
|
||||
return this.paginate(
|
||||
this.admins.map((admin) => this.toAdminView(admin)),
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
updateAdminStatus(
|
||||
async updateAdminStatus(
|
||||
id: number,
|
||||
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);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
@@ -172,7 +278,19 @@ export class AdminService {
|
||||
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);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
@@ -183,10 +301,23 @@ export class AdminService {
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
updateProfile(
|
||||
async updateProfile(
|
||||
adminId: number,
|
||||
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);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
@@ -198,11 +329,26 @@ export class AdminService {
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
changePassword(
|
||||
async changePassword(
|
||||
adminId: number,
|
||||
oldPassword: 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);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
@@ -256,7 +402,7 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
private seed() {
|
||||
private seedMemory() {
|
||||
const codes = [
|
||||
['permission:read', '查看权限'],
|
||||
['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>(
|
||||
records: T[],
|
||||
input: PaginationInput,
|
||||
|
||||
@@ -22,6 +22,9 @@ export class AdminUser {
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
nickname?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: 'active' })
|
||||
status: string;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export class AdminJwtAuthGuard implements CanActivate {
|
||||
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') {
|
||||
throw new UnauthorizedException('Admin not authenticated');
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class PermissionsGuard implements CanActivate {
|
||||
private readonly adminService: AdminService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required =
|
||||
this.reflector.getAllAndOverride<string[]>(
|
||||
REQUIRED_PERMISSIONS_METADATA,
|
||||
@@ -38,7 +38,9 @@ export class PermissionsGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissions = this.adminService.getPermissionCodesForAdmin(adminUser.id);
|
||||
const permissions = await this.adminService.getPermissionCodesForAdmin(
|
||||
adminUser.id,
|
||||
);
|
||||
const allowed = required.every((permission) =>
|
||||
permissions.includes(permission),
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ export class AuthService {
|
||||
|
||||
async loginWithTikTok(dto: TikTokLoginDto) {
|
||||
const tiktokOpenId = dto.mockOpenId ?? this.resolveOpenIdFromCode(dto.code);
|
||||
const result = this.usersService.upsertTikTokUser({
|
||||
const result = await this.usersService.upsertTikTokUser({
|
||||
tiktokOpenId,
|
||||
tiktokUnionId: dto.tiktokUnionId,
|
||||
nickname: dto.nickname,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class JwtAuthGuard implements CanActivate {
|
||||
|
||||
try {
|
||||
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') {
|
||||
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 { 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';
|
||||
|
||||
interface PaginationInput {
|
||||
@@ -58,9 +63,12 @@ export class DataService {
|
||||
constructor(
|
||||
private readonly dramasService: DramasService,
|
||||
private readonly tiktokDataProvider: TikTokDataProvider,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
sync(input: SyncInput = {}) {
|
||||
async sync(input: SyncInput = {}) {
|
||||
const now = new Date().toISOString();
|
||||
const dramas = this.dramasService
|
||||
.listAllDramas()
|
||||
@@ -70,24 +78,28 @@ export class DataService {
|
||||
.listAllEpisodes()
|
||||
.filter((episode) => dramaIds.has(episode.dramaId));
|
||||
|
||||
for (const drama of dramas) {
|
||||
this.dramaMetrics.set(drama.id, {
|
||||
const dramaRecords = dramas.map((drama) => ({
|
||||
dramaId: drama.id,
|
||||
title: drama.title,
|
||||
...this.tiktokDataProvider.getDramaMetrics(drama),
|
||||
syncedAt: now,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
for (const episode of episodes) {
|
||||
this.episodeMetrics.set(episode.id, {
|
||||
const episodeRecords = episodes.map((episode) => ({
|
||||
dramaId: episode.dramaId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
episodeNo: episode.episodeNo,
|
||||
...this.tiktokDataProvider.getEpisodeMetrics(episode),
|
||||
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 = {
|
||||
@@ -101,12 +113,67 @@ export class DataService {
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
getOverview() {
|
||||
const dramaRecords = Array.from(this.dramaMetrics.values());
|
||||
const episodeRecords = Array.from(this.episodeMetrics.values());
|
||||
async getOverview() {
|
||||
const dramaRecords = this.hasDatabase()
|
||||
? (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 {
|
||||
totalDramas: dramaRecords.length,
|
||||
@@ -115,27 +182,119 @@ export class DataService {
|
||||
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
|
||||
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
|
||||
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
|
||||
latestSyncedAt: this.syncJobs[0]?.createdAt,
|
||||
latestSyncedAt: latestJob?.createdAt?.toISOString?.() ?? this.syncJobs[0]?.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
listDramaMetrics(input: PaginationInput): PaginatedResponse<DramaMetricRecord> {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
return this.paginate(Array.from(this.dramaMetrics.values()), input);
|
||||
}
|
||||
|
||||
listEpisodeMetrics(
|
||||
async listEpisodeMetrics(
|
||||
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(
|
||||
(item) => !input.dramaId || item.dramaId === input.dramaId,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
const page = Math.max(input.page, 1);
|
||||
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 { Permission } from '../admin/entities/permission.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 { Episode } from '../dramas/entities/episode.entity';
|
||||
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
|
||||
@@ -25,6 +28,9 @@ const databaseImports = process.env.DB_HOST
|
||||
AdminUser,
|
||||
Permission,
|
||||
Role,
|
||||
DataSyncJob,
|
||||
DramaMetric,
|
||||
EpisodeMetric,
|
||||
Drama,
|
||||
Episode,
|
||||
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 { User } from './entities/user.entity';
|
||||
import { UserRecord } from './interfaces/user-record.interface';
|
||||
|
||||
interface UpsertTikTokUserInput {
|
||||
@@ -19,7 +22,38 @@ export class UsersService {
|
||||
private userSequence = 1;
|
||||
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(
|
||||
(user) => user.tiktokOpenId === input.tiktokOpenId,
|
||||
);
|
||||
@@ -48,16 +82,39 @@ export class UsersService {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
getUserOrThrow(id: number) {
|
||||
const user = this.findById(id);
|
||||
async getUserOrThrow(id: number) {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
@@ -65,13 +122,41 @@ export class UsersService {
|
||||
return user;
|
||||
}
|
||||
|
||||
updateStatus(id: number, status: 'active' | 'disabled') {
|
||||
const user = this.getUserOrThrow(id);
|
||||
async updateStatus(id: number, status: 'active' | 'disabled') {
|
||||
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.updatedAt = new Date().toISOString();
|
||||
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>(
|
||||
records: T[],
|
||||
input: PaginationInput,
|
||||
|
||||
Reference in New Issue
Block a user