feat: 新增数据库持久化支持并完善后台管理功能

- 引入dotenv加载环境变量,新增数据库同步配置项
- 为用户服务、管理员服务、数据服务添加数据库持久化逻辑
- 新增管理员用户邮箱字段,完善权限校验逻辑
- 添加数据库初始化与种子数据脚本
- 新增e2e测试验证管理员数据持久化
This commit is contained in:
2026-07-01 17:11:29 +08:00
parent 026665b2cc
commit d3083bf16b
16 changed files with 641 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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