diff --git a/.env b/.env index 284562e..8672cee 100644 --- a/.env +++ b/.env @@ -1,5 +1,8 @@ PORT=3030 JWT_SECRET=change-me +ADMIN_JWT_SECRET=change-me-admin +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 # 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. @@ -8,3 +11,5 @@ DB_PORT=22246 DB_USERNAME=root DB_PASSWORD=zhxiao1124.. DB_DATABASE=onion_tiktok + +DB_SYNCHRONIZE = true diff --git a/.env.example b/.env.example index 3fa8a9a..d404830 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,5 @@ DB_PORT=3306 DB_USERNAME=root DB_PASSWORD= DB_DATABASE=cth_tk_backend + +DB_SYNCHRONIZE = true diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000..044636a Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/package.json b/package.json index 49d86ad..f5e60f6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "dotenv": "17.4.1", "mysql2": "^3.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 373793f..8e52f72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: class-validator: specifier: ^0.14.0 version: 0.14.4 + dotenv: + specifier: 17.4.1 + version: 17.4.1 mysql2: specifier: ^3.0.0 version: 3.22.5(@types/node@24.13.2) diff --git a/src/main.ts b/src/main.ts index 55dc53e..2f934fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { bootstrapApp } from './bootstrap'; diff --git a/src/modules/admin/admin.service.ts b/src/modules/admin/admin.service.ts index 1472c03..72e677d 100644 --- a/src/modules/admin/admin.service.ts +++ b/src/modules/admin/admin.service.ts @@ -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 { + 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 { + ): Promise> { + if (this.hasDatabase()) { + await this.loadDatabaseRecords(); + } + return this.paginate([...this.permissions], pagination); } - createRole(dto: CreateRoleDto): RoleRecord { + async createRole(dto: CreateRoleDto): Promise { + 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 { + async listRoles(pagination: PaginationInput): Promise> { + 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 { + 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 { + 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 { + ): Promise> { + 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 { + 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 { + 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 { + 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 { + ): Promise> { + 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( records: T[], input: PaginationInput, diff --git a/src/modules/admin/entities/admin-user.entity.ts b/src/modules/admin/entities/admin-user.entity.ts index 199dbcf..febfd8d 100644 --- a/src/modules/admin/entities/admin-user.entity.ts +++ b/src/modules/admin/entities/admin-user.entity.ts @@ -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; diff --git a/src/modules/admin/guards/admin-jwt-auth.guard.ts b/src/modules/admin/guards/admin-jwt-auth.guard.ts index f597ea0..6f9cbd4 100644 --- a/src/modules/admin/guards/admin-jwt-auth.guard.ts +++ b/src/modules/admin/guards/admin-jwt-auth.guard.ts @@ -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'); } diff --git a/src/modules/admin/guards/permissions.guard.ts b/src/modules/admin/guards/permissions.guard.ts index a11d006..81fab69 100644 --- a/src/modules/admin/guards/permissions.guard.ts +++ b/src/modules/admin/guards/permissions.guard.ts @@ -14,7 +14,7 @@ export class PermissionsGuard implements CanActivate { private readonly adminService: AdminService, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const required = this.reflector.getAllAndOverride( 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), ); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index e7b6356..cd7ac37 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -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, diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts index a440098..3fb8931 100644 --- a/src/modules/auth/guards/jwt-auth.guard.ts +++ b/src/modules/auth/guards/jwt-auth.guard.ts @@ -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'); } diff --git a/src/modules/data/data.service.ts b/src/modules/data/data.service.ts index 1c95c7b..573c54f 100644 --- a/src/modules/data/data.service.ts +++ b/src/modules/data/data.service.ts @@ -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 { + async listDramaMetrics(input: PaginationInput): Promise> { + 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 { + ): Promise> { + 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 { + async listSyncJobs(input: PaginationInput): Promise> { + 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(records: T[], input: PaginationInput): PaginatedResponse { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts index 450a4ae..afdb43d 100644 --- a/src/modules/database/database.module.ts +++ b/src/modules/database/database.module.ts @@ -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, diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 1f03e6e..4c71627 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -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 { + async listUsers(pagination: PaginationInput): Promise> { + 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( records: T[], input: PaginationInput, diff --git a/test/database-persistence.e2e-spec.ts b/test/database-persistence.e2e-spec.ts index 3ad7214..0000798 100644 --- a/test/database-persistence.e2e-spec.ts +++ b/test/database-persistence.e2e-spec.ts @@ -1,3 +1,4 @@ +import { AdminService } from '../src/modules/admin/admin.service'; import { DataService } from '../src/modules/data/data.service'; import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider'; import { PublishStatus } from '../src/modules/dramas/drama-status.enum'; @@ -48,6 +49,48 @@ function createDataSource(repositories: Record) { } 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 () => { const userRepository = createRepository(); const dataSource = createDataSource({ User: userRepository });